Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2cd79b547 | ||
|
|
0a18290137 | ||
|
|
efc53435a2 | ||
|
|
67674d297e | ||
|
|
2d4b126f14 | ||
|
|
5671324eb2 | ||
|
|
83391e0453 | ||
|
|
172b099a04 | ||
|
|
cae72b5179 | ||
|
|
b3b8e0d66d | ||
|
|
787d2b121a | ||
|
|
1b323c2fa5 | ||
|
|
1ca571458c | ||
|
|
768801dbe4 | ||
|
|
375ecfcd95 | ||
|
|
ce95b34d19 | ||
|
|
9d430a3079 | ||
|
|
bac68d2bfb | ||
|
|
634a9ebe07 | ||
|
|
150f3cdb09 | ||
|
|
6377bfb8fd | ||
|
|
2bc12e6022 | ||
|
|
fe7c85e471 | ||
|
|
8a04e4f9d1 | ||
|
|
5347954a88 | ||
|
|
00a81edadd | ||
|
|
4bd3638012 | ||
|
|
7f540a58e5 | ||
|
|
7ac5355a77 | ||
|
|
a9eb90f457 | ||
|
|
b5c9156e64 | ||
|
|
3213a0989a | ||
|
|
fbf729b25c | ||
|
|
e37df9d838 | ||
|
|
673aac6aba | ||
|
|
c8eb344a0b | ||
|
|
65966200fe | ||
|
|
2130d6a1a5 | ||
|
|
9a99c6064e | ||
|
|
4f93522f7d | ||
|
|
a325ec807e | ||
|
|
c2b19f7545 | ||
|
|
925fc0276f | ||
|
|
65a8197419 | ||
|
|
ff67387c42 | ||
|
|
c42794c5e2 | ||
|
|
0964af7b73 | ||
|
|
15fb008954 | ||
|
|
f4c3ffed43 | ||
|
|
5f965daa6a | ||
|
|
5940d6b4bd | ||
|
|
190e3200e0 | ||
|
|
49ec4d8f6a | ||
|
|
173c00fca7 | ||
|
|
3a903934c0 |
@@ -8,3 +8,8 @@
|
|||||||
/config/dot/nvim/lazy-lock.json
|
/config/dot/nvim/lazy-lock.json
|
||||||
# Local visual-companion sessions and generated design mocks.
|
# Local visual-companion sessions and generated design mocks.
|
||||||
/.superpowers/
|
/.superpowers/
|
||||||
|
# Local linked worktrees used for reviewed feature development.
|
||||||
|
/.worktrees/
|
||||||
|
# Python helper bytecode is local runtime state.
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
|||||||
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
|
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
|
||||||
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
|
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
|
||||||
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
|
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
|
||||||
| Quick settings | Wi-Fi, Bluetooth, audio input/output, caffeine, night light, focus | Live |
|
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live |
|
||||||
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
|
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
|
||||||
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
|
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
|
||||||
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
||||||
@@ -35,6 +35,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
|||||||
| Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live |
|
| Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live |
|
||||||
| Removable media | udiskie plus udisks notifications | Live |
|
| Removable media | udiskie plus udisks notifications | Live |
|
||||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||||
|
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||||
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||||
|
|
||||||
## GNOME extension migration
|
## GNOME extension migration
|
||||||
@@ -52,8 +53,8 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
|||||||
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
||||||
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
|
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
|
||||||
| GSConnect | No paired phone profile was present; KDE Connect is installed and awaiting pairing |
|
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
|
||||||
| Home Assistant | Useful integration, but needs a purpose-built panel and secure credential migration |
|
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||||
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
|
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
|
||||||
| App Hider | Hidden app list is now applied in Vicinae |
|
| App Hider | Hidden app list is now applied in Vicinae |
|
||||||
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
|
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
|
||||||
@@ -63,20 +64,11 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
|||||||
These are not blockers for normal use, but they are the remaining places where
|
These are not blockers for normal use, but they are the remaining places where
|
||||||
a polished general-purpose desktop can go beyond the current shell.
|
a polished general-purpose desktop can go beyond the current shell.
|
||||||
|
|
||||||
1. Home Assistant favourites in a dedicated, secure quick panel. The token must
|
1. A real external RustDesk test while hyprlock is active. Local process state
|
||||||
stay in the keyring or a private state file, never in this repository.
|
|
||||||
2. Phone integration through KDE Connect, including battery/share status in the
|
|
||||||
tray. The package is installed and ready for a new pairing.
|
|
||||||
3. A printer administration GUI. CUPS is already active; the
|
|
||||||
`system-config-printer` package supplies the missing UI.
|
|
||||||
4. A real external RustDesk test while hyprlock is active. Local process state
|
|
||||||
cannot prove what a remote client sees or whether input is accepted.
|
cannot prove what a remote client sees or whether input is accepted.
|
||||||
|
|
||||||
Supporting packages tracked in `setup/packages/hyprland-packages`:
|
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
||||||
|
tracked in `setup/packages/hyprland-packages`.
|
||||||
```sh
|
|
||||||
sudo dnf install kde-connect system-config-printer tesseract zbar
|
|
||||||
```
|
|
||||||
|
|
||||||
## Global menu decision
|
## Global menu decision
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ Don't "fix" them.
|
|||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
||||||
|
| `prefs.lua` | Reads the settings file Panama Settings writes. See below |
|
||||||
| `env.lua` | Environment. Note the uwsm caveat below |
|
| `env.lua` | Environment. Note the uwsm caveat below |
|
||||||
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
||||||
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
||||||
@@ -43,6 +44,109 @@ Validate any change without leaving your session:
|
|||||||
Hyprland --verify-config
|
Hyprland --verify-config
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Generated configuration
|
||||||
|
|
||||||
|
Two ecosystem tools cannot read the shared settings file, and their configs live
|
||||||
|
in this directory — which is a symlink into the Panama repository, so writing to
|
||||||
|
them at runtime would put machine state into a tracked file. Both are therefore
|
||||||
|
generated elsewhere:
|
||||||
|
|
||||||
|
| Tool | Generated to | Pointed there by |
|
||||||
|
|---|---|---|
|
||||||
|
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
|
||||||
|
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
|
||||||
|
|
||||||
|
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
|
||||||
|
settings store and restarts the daemon. `hypridle.conf` in this directory
|
||||||
|
remains the shipped default and is what runs when the drop-in is not installed;
|
||||||
|
Panama Settings shows which of the two states you are in rather than presenting
|
||||||
|
controls that quietly do nothing.
|
||||||
|
|
||||||
|
Remove the drop-in and go back to the shipped config with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
~/.config/quickshell/scripts/panama-idle remove
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings: one file, both sides
|
||||||
|
|
||||||
|
`~/.config/panama/settings.json` is shared with the Quickshell side. The
|
||||||
|
relationship is:
|
||||||
|
|
||||||
|
- **This config is the default.** Every adjustable value is written
|
||||||
|
`prefs.get("key", <shipped value>)`, so the config still works standalone with
|
||||||
|
no settings file at all.
|
||||||
|
- **The JSON is the truth.** Hyprland and Quickshell both read it.
|
||||||
|
- **Panama Settings is the editor.** It writes the file *and* applies the change
|
||||||
|
live, so nothing needs a reload and the two sides cannot drift apart.
|
||||||
|
|
||||||
|
To add an adjustable setting: add an entry to
|
||||||
|
`quickshell/config/PreferenceSchema.qml` with a `hypr` block naming the
|
||||||
|
`hl.config` path, then read it here with `prefs.get`. Nothing else is needed —
|
||||||
|
persistence, validation, reset, and the live write are all derived from that
|
||||||
|
entry.
|
||||||
|
|
||||||
|
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
|
||||||
|
wrong-typed settings file costs you your customisations and nothing else;
|
||||||
|
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
|
||||||
|
accepts the config in each of those states.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
||||||
|
one has nothing readable beside its chord, and Panama Settings drops it from the
|
||||||
|
Input & Shortcuts page rather than showing a mystery row.
|
||||||
|
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
|
||||||
|
this cannot regress silently.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||||
|
```
|
||||||
|
|
||||||
|
The page groups shortcuts by what the description says they do, so a new bind
|
||||||
|
lands in the right section with no change to the UI.
|
||||||
|
|
||||||
|
### Never use `hyprctl keyword`
|
||||||
|
|
||||||
|
On a Lua-configured Hyprland it refuses the write, prints
|
||||||
|
`keyword can't work with non-legacy parsers` to **stdout**, and still **exits 0**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ hyprctl getoption decoration:rounding -j # → "int": 18
|
||||||
|
$ hyprctl keyword decoration:rounding 4 # → the refusal above
|
||||||
|
$ echo $? # → 0
|
||||||
|
$ hyprctl getoption decoration:rounding -j # → "int": 18, unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `hyprctl eval 'hl.config({ ... })'` instead. Note that `eval` *also* exits 0
|
||||||
|
on syntax and runtime errors, reporting them as an `error:` line on stdout — so
|
||||||
|
for either command, the only trustworthy signal that a write landed is reading
|
||||||
|
the value back with `hyprctl getoption`.
|
||||||
|
|
||||||
## The look
|
## The look
|
||||||
|
|
||||||
Tokyo Night Moon, with two accents that come from the tmux theme:
|
Tokyo Night Moon, with two accents that come from the tmux theme:
|
||||||
@@ -213,6 +317,35 @@ before a timed event until 5 minutes after it starts. It displays relative time
|
|||||||
(`12m` or `Now`); the event title is available on hover but otherwise stays out
|
(`12m` or `Now`); the event title is available on hover but otherwise stays out
|
||||||
of the bar. All-day events never trigger the capsule.
|
of the bar. All-day events never trigger the capsule.
|
||||||
|
|
||||||
|
## Control Center
|
||||||
|
|
||||||
|
Click the right-side status cluster or press `SUPER + S` for Panama's Control
|
||||||
|
Center. It is attached directly beneath the bar and keeps Wi-Fi, Bluetooth,
|
||||||
|
Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
|
||||||
|
one place. Home and Phone continue the same surface rather than opening extra
|
||||||
|
dashboard windows.
|
||||||
|
|
||||||
|
Home shows the first four selected favourites at rest and every selected light
|
||||||
|
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
|
||||||
|
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
||||||
|
only previews the value; releasing it sends one brightness request. A normal
|
||||||
|
power toggle leaves Home Assistant responsible for restoring its previous
|
||||||
|
level.
|
||||||
|
|
||||||
|
Credentials stay private in the gitignored `config/bash/env` file, with the
|
||||||
|
existing GNOME extension and Secret Service setup retained as a compatibility
|
||||||
|
fallback. Favourites, aliases, and order live in Quickshell state. No shell
|
||||||
|
restart is required after changing credentials; close and reopen Control Center
|
||||||
|
to refresh. If Home Assistant is offline, the last known values stay visible
|
||||||
|
with a stale-state label and Retry action.
|
||||||
|
|
||||||
|
Phone uses KDE Connect for the capabilities the paired iPhone actually
|
||||||
|
advertises: Send File, Send Clipboard, and Ring. The device remains visible
|
||||||
|
while iOS suspends KDE Connect, but those actions stay disabled until it
|
||||||
|
reconnects. Messages opens BlueBubbles independently of KDE Connect. No battery
|
||||||
|
percentage is invented when iOS reports none. Active file sends appear in
|
||||||
|
Daybook's Ongoing page; successful sends become a quiet recent exchange.
|
||||||
|
|
||||||
## Screen Intelligence
|
## Screen Intelligence
|
||||||
|
|
||||||
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
|
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
-- Check: Hyprland --verify-config
|
-- Check: Hyprland --verify-config
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Shared preferences first: looks/input/monitors read their defaults through it.
|
||||||
|
-- It never raises, so a missing or malformed settings file costs customisations
|
||||||
|
-- and nothing else.
|
||||||
|
require("prefs")
|
||||||
|
|
||||||
require("env")
|
require("env")
|
||||||
require("monitors")
|
require("monitors")
|
||||||
require("looks")
|
require("looks")
|
||||||
|
|||||||
@@ -5,28 +5,30 @@
|
|||||||
-- acceleration changes. The goal is that muscle memory transfers untouched.
|
-- acceleration changes. The goal is that muscle memory transfers untouched.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
input = {
|
input = {
|
||||||
kb_layout = "us",
|
kb_layout = prefs.get("keyboardLayout", "us"),
|
||||||
kb_variant = "",
|
kb_variant = "",
|
||||||
kb_model = "",
|
kb_model = "",
|
||||||
kb_options = "",
|
kb_options = "",
|
||||||
kb_rules = "",
|
kb_rules = "",
|
||||||
|
|
||||||
numlock_by_default = true,
|
numlock_by_default = prefs.get("numlockByDefault", true),
|
||||||
|
|
||||||
-- GNOME's defaults are 500ms delay / 33Hz repeat.
|
-- GNOME's defaults are 500ms delay / 33Hz repeat.
|
||||||
repeat_delay = 500,
|
repeat_delay = prefs.get("keyRepeatDelay", 500),
|
||||||
repeat_rate = 33,
|
repeat_rate = prefs.get("keyRepeatRate", 33),
|
||||||
|
|
||||||
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
|
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
|
||||||
follow_mouse = 1,
|
follow_mouse = prefs.getInt("followMouse", 1),
|
||||||
|
|
||||||
-- Don't refocus on mouse move alone -- only on click.
|
-- Don't refocus on mouse move alone -- only on click.
|
||||||
mouse_refocus = false,
|
mouse_refocus = false,
|
||||||
|
|
||||||
-- Flat pointer response, no acceleration. Matters for gaming.
|
-- Flat pointer response, no acceleration. Matters for gaming.
|
||||||
sensitivity = 0,
|
sensitivity = prefs.get("pointerSensitivity", 0),
|
||||||
accel_profile = "flat",
|
accel_profile = "flat",
|
||||||
|
|
||||||
-- Clicking a floating window raises and focuses it.
|
-- Clicking a floating window raises and focuses it.
|
||||||
|
|||||||
+133
-95
@@ -11,6 +11,8 @@
|
|||||||
-- SUPER + CTRL -> change layout structure (split, float, swap)
|
-- SUPER + CTRL -> change layout structure (split, float, swap)
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
local mod = "SUPER"
|
local mod = "SUPER"
|
||||||
|
|
||||||
-- Programs, matched to the GNOME custom keybindings and media-key settings.
|
-- Programs, matched to the GNOME custom keybindings and media-key settings.
|
||||||
@@ -33,109 +35,145 @@ local launcher = "vicinae toggle"
|
|||||||
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
||||||
|
|
||||||
-- ── Applications ────────────────────────────────────────────────────────────
|
-- ── Applications ────────────────────────────────────────────────────────────
|
||||||
hl.bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
|
-- ── User rebinding ──────────────────────────────────────────────────────────
|
||||||
hl.bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
|
-- Every bind below goes through `bind` rather than `hl.bind` directly, so a
|
||||||
hl.bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
-- chord can be replaced from Panama Settings without this file changing.
|
||||||
hl.bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
--
|
||||||
hl.bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
-- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken
|
||||||
hl.bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
-- from settings -- the action is always the Lua value written here. A stored
|
||||||
hl.bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
|
-- override can therefore move a shortcut but can never make one do something
|
||||||
hl.bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
-- else, which is the property that makes reading them from a JSON file the
|
||||||
|
-- user can edit safe.
|
||||||
|
--
|
||||||
|
-- Keyed by chord rather than by description because chords are unique and
|
||||||
|
-- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator
|
||||||
|
-- hardware key, and keying by description moved both of them onto the same new
|
||||||
|
-- chord, silently costing the hardware key.
|
||||||
|
--
|
||||||
|
-- An override whose chord is not a plausible chord is ignored and the shipped
|
||||||
|
-- one is used, so a hand-edited settings file cannot cost you a keymap.
|
||||||
|
|
||||||
|
local overrides = prefs.get("keybindOverrides", {})
|
||||||
|
|
||||||
|
local function valid_chord(chord)
|
||||||
|
if type(chord) ~= "string" or chord == "" or #chord > 64 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272"
|
||||||
|
return chord:match("^[%w_+%s:]+$") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bind(chord, action, opts)
|
||||||
|
local override = overrides[chord]
|
||||||
|
if valid_chord(override) then
|
||||||
|
chord = override
|
||||||
|
end
|
||||||
|
return hl.bind(chord, action, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
|
||||||
|
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
|
||||||
|
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
||||||
|
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
||||||
|
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||||
|
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||||
|
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
|
||||||
|
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
||||||
|
|
||||||
-- ── Launcher ────────────────────────────────────────────────────────────────
|
-- ── Launcher ────────────────────────────────────────────────────────────────
|
||||||
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
|
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
|
||||||
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
|
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
|
||||||
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
|
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
|
||||||
-- emoji and window switching, so a separate run dialog and app grid are gone.
|
-- emoji and window switching, so a separate run dialog and app grid are gone.
|
||||||
hl.bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||||
hl.bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||||
hl.bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||||
|
|
||||||
-- Emergency fallback launcher. Vicinae runs as a systemd user service and the
|
-- Emergency fallback launcher. Vicinae runs as a systemd user service and the
|
||||||
-- bar is Quickshell; if either fails to come up, this is how you start an
|
-- bar is Quickshell; if either fails to come up, this is how you start an
|
||||||
-- application without dropping to a TTY. Depends on nothing but wofi itself.
|
-- application without dropping to a TTY. Depends on nothing but wofi itself.
|
||||||
hl.bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
|
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
|
||||||
|
|
||||||
-- Clipboard history and emoji, straight into the relevant launcher view.
|
-- Clipboard history and emoji, straight into the relevant launcher view.
|
||||||
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
|
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
|
||||||
hl.bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
|
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
|
||||||
{ description = "Clipboard history" })
|
{ description = "Clipboard history" })
|
||||||
hl.bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
|
bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
|
||||||
{ description = "Emoji picker" })
|
{ description = "Emoji picker" })
|
||||||
|
|
||||||
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
|
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
|
||||||
-- SUPER+S was GNOME's quick settings; kept.
|
-- SUPER+S was GNOME's quick settings; kept.
|
||||||
hl.bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
|
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
|
||||||
|
|
||||||
-- Start a 45-minute focus session on the current workspace, or reveal its
|
-- Start a 45-minute focus session on the current workspace, or reveal its
|
||||||
-- Signal Glass controls if one is already running.
|
-- Signal Glass controls if one is already running.
|
||||||
hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
|
bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
|
||||||
|
|
||||||
-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a
|
-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a
|
||||||
-- modifier misfires when you're fast with SUPER+key combos, so it lives on a
|
-- modifier misfires when you're fast with SUPER+key combos, so it lives on a
|
||||||
-- real chord instead. SUPER+grave was Forge's "cycle windows of same app",
|
-- real chord instead. SUPER+grave was Forge's "cycle windows of same app",
|
||||||
-- which Hyprland has no equivalent for.
|
-- which Hyprland has no equivalent for.
|
||||||
hl.bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
|
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
|
||||||
|
|
||||||
-- Notification centre.
|
-- Notification centre.
|
||||||
hl.bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
|
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
|
||||||
|
|
||||||
-- Screenshot / screen record. One key, then pick screen / window / region and
|
-- Screenshot / screen record. One key, then pick screen / window / region and
|
||||||
-- whether to capture or record -- reproducing GNOME's Print-screen UI.
|
-- whether to capture or record -- reproducing GNOME's Print-screen UI.
|
||||||
hl.bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
|
bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
|
||||||
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
|
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
|
||||||
hl.bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
|
bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
|
||||||
hl.bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
|
bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
|
||||||
|
|
||||||
-- Local OCR and QR/barcode recognition through the same region picker. This
|
-- Local OCR and QR/barcode recognition through the same region picker. This
|
||||||
-- opens directly in Selection + Read mode; Print still exposes every mode.
|
-- opens directly in Selection + Read mode; Print still exposes every mode.
|
||||||
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
|
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
|
||||||
{ description = "Screen Intelligence" })
|
{ description = "Screen Intelligence" })
|
||||||
|
|
||||||
-- Colour picker: copies the hex under the cursor to the clipboard.
|
-- Colour picker: copies the hex under the cursor to the clipboard.
|
||||||
hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
|
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
|
||||||
|
|
||||||
-- ── Window management ───────────────────────────────────────────────────────
|
-- ── Window management ───────────────────────────────────────────────────────
|
||||||
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||||
hl.bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
|
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
|
||||||
|
|
||||||
-- Forge: window-toggle-float / window-toggle-always-float.
|
-- Forge: window-toggle-float / window-toggle-always-float.
|
||||||
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
|
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
|
||||||
-- is the nearest useful thing -- the window floats above every workspace.
|
-- is the nearest useful thing -- the window floats above every workspace.
|
||||||
hl.bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
|
bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
|
||||||
hl.bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
|
bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
|
||||||
|
|
||||||
-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical.
|
-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical.
|
||||||
hl.bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
|
bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
|
||||||
hl.bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
|
bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
|
||||||
hl.bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
|
bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
|
||||||
|
|
||||||
-- Forge: window-shrink / window-expand / window-reset-sizes.
|
-- Forge: window-shrink / window-expand / window-reset-sizes.
|
||||||
hl.bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
|
bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
|
||||||
hl.bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
|
bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
|
||||||
hl.bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
|
bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
|
||||||
|
|
||||||
-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge.
|
-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge.
|
||||||
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
|
bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
|
||||||
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
|
bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
|
||||||
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
|
bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
|
||||||
hl.bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
|
bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
|
||||||
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" }))
|
bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
|
||||||
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" }))
|
bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
|
||||||
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" }))
|
bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
|
||||||
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" }))
|
bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
|
||||||
|
|
||||||
-- Move (Forge: window-move-*).
|
-- Move (Forge: window-move-*).
|
||||||
hl.bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
|
bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
|
||||||
hl.bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
|
bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
|
||||||
hl.bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
|
bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
|
||||||
hl.bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
|
bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
|
||||||
|
|
||||||
-- Swap (Forge: window-swap-*).
|
-- Swap (Forge: window-swap-*).
|
||||||
hl.bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
|
bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
|
||||||
hl.bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
|
bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
|
||||||
hl.bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
|
bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
|
||||||
hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
|
bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
|
||||||
|
|
||||||
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
||||||
--
|
--
|
||||||
@@ -145,24 +183,24 @@ hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descrip
|
|||||||
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
|
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
|
||||||
-- and "increase" always grows while "decrease" always shrinks.
|
-- and "increase" always grows while "decrease" always shrinks.
|
||||||
local step = 60
|
local step = 60
|
||||||
hl.bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||||
hl.bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||||
hl.bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||||
hl.bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||||
hl.bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||||
hl.bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||||
hl.bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||||
hl.bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||||
|
|
||||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
|
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
|
||||||
hl.bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
||||||
hl.bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
||||||
-- Jump back to the previously focused window.
|
-- Jump back to the previously focused window.
|
||||||
hl.bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
||||||
|
|
||||||
-- Mouse: drag to move, right-drag to resize.
|
-- Mouse: drag to move, right-drag to resize.
|
||||||
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
|
bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
|
||||||
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
|
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
|
||||||
|
|
||||||
-- ── Workspaces ──────────────────────────────────────────────────────────────
|
-- ── Workspaces ──────────────────────────────────────────────────────────────
|
||||||
-- ALT is the workspace modifier, matching the GNOME setup.
|
-- ALT is the workspace modifier, matching the GNOME setup.
|
||||||
@@ -170,27 +208,27 @@ hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
|
|||||||
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
||||||
-- moving right past the last workspace creates a new one, and moving left from
|
-- moving right past the last workspace creates a new one, and moving left from
|
||||||
-- the first clamps instead of wrapping.
|
-- the first clamps instead of wrapping.
|
||||||
hl.bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||||
hl.bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||||
hl.bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
||||||
hl.bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
|
bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
|
||||||
|
|
||||||
-- GNOME also had these on CTRL+ALT+Up/Down.
|
-- GNOME also had these on CTRL+ALT+Up/Down.
|
||||||
hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }))
|
bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||||
hl.bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }))
|
bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||||
|
|
||||||
-- Direct jump. ALT+0 is workspace 10.
|
-- Direct jump. ALT+0 is workspace 10.
|
||||||
for i = 1, 10 do
|
for i = 1, 10 do
|
||||||
local key = i % 10
|
local key = i % 10
|
||||||
hl.bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
|
bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
|
||||||
hl.bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
|
bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
|
-- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
|
||||||
-- (Scrolling the workspace indicator in the bar does the same; that's handled
|
-- (Scrolling the workspace indicator in the bar does the same; that's handled
|
||||||
-- in quickshell/modules/bar/Workspaces.qml.)
|
-- in quickshell/modules/bar/Workspaces.qml.)
|
||||||
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }))
|
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||||
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
|
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||||
|
|
||||||
-- Minimise, as far as Hyprland has one.
|
-- Minimise, as far as Hyprland has one.
|
||||||
--
|
--
|
||||||
@@ -202,42 +240,42 @@ hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
|
|||||||
--
|
--
|
||||||
-- The scratchpad is the honest equivalent: the window goes away, and the same
|
-- The scratchpad is the honest equivalent: the window goes away, and the same
|
||||||
-- key brings it back. Bound to X to match the muscle memory it replaces.
|
-- key brings it back. Bound to X to match the muscle memory it replaces.
|
||||||
hl.bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
|
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
|
||||||
hl.bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
|
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
|
||||||
|
|
||||||
-- ── Session ─────────────────────────────────────────────────────────────────
|
-- ── Session ─────────────────────────────────────────────────────────────────
|
||||||
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
|
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
|
||||||
-- CTRL+ALT+L -- the other binding most people already have in muscle memory.
|
-- CTRL+ALT+L -- the other binding most people already have in muscle memory.
|
||||||
hl.bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
|
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
|
||||||
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
|
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
|
||||||
|
|
||||||
-- ── Media and volume ────────────────────────────────────────────────────────
|
-- ── Media and volume ────────────────────────────────────────────────────────
|
||||||
-- locked = true keeps these working on the lock screen, as they do in GNOME.
|
-- locked = true keeps these working on the lock screen, as they do in GNOME.
|
||||||
-- 6% steps match the GNOME volume-step setting.
|
-- 6% steps match the GNOME volume-step setting.
|
||||||
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true })
|
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" })
|
||||||
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true })
|
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" })
|
||||||
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
|
bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" })
|
||||||
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
|
bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" })
|
||||||
|
|
||||||
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
||||||
hl.bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true })
|
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" })
|
||||||
hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true })
|
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" })
|
||||||
|
|
||||||
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
|
bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
|
||||||
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
|
bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
|
||||||
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" })
|
||||||
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" })
|
||||||
hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true })
|
bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" })
|
||||||
|
|
||||||
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
|
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" })
|
||||||
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
|
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" })
|
||||||
|
|
||||||
-- Hardware keys GNOME mapped that have obvious equivalents.
|
-- Hardware keys GNOME mapped that have obvious equivalents.
|
||||||
hl.bind("XF86Tools", hl.dsp.exec_cmd(settings))
|
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||||
hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator))
|
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||||
hl.bind("XF86Explorer", hl.dsp.exec_cmd(files))
|
bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
|
||||||
hl.bind("XF86WWW", hl.dsp.exec_cmd(browser))
|
bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
||||||
hl.bind("XF86Mail", hl.dsp.exec_cmd(mail))
|
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||||
hl.bind("XF86Search", hl.dsp.exec_cmd(launcher))
|
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
+19
-17
@@ -12,12 +12,14 @@
|
|||||||
-- display.
|
-- display.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
general = {
|
general = {
|
||||||
gaps_in = 5,
|
gaps_in = prefs.get("gapsIn", 5),
|
||||||
gaps_out = 10,
|
gaps_out = prefs.get("gapsOut", 10),
|
||||||
|
|
||||||
border_size = 2,
|
border_size = prefs.get("borderSize", 2),
|
||||||
|
|
||||||
col = {
|
col = {
|
||||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
||||||
@@ -44,16 +46,16 @@ hl.config({
|
|||||||
decoration = {
|
decoration = {
|
||||||
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
||||||
-- next to each other read as the same object family.
|
-- next to each other read as the same object family.
|
||||||
rounding = 18,
|
rounding = prefs.get("windowRounding", 18),
|
||||||
rounding_power = 2,
|
rounding_power = 2,
|
||||||
|
|
||||||
active_opacity = 1.0,
|
active_opacity = 1.0,
|
||||||
inactive_opacity = 1.0,
|
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
||||||
|
|
||||||
blur = {
|
blur = {
|
||||||
enabled = true,
|
enabled = prefs.get("blurEnabled", true),
|
||||||
size = 8,
|
size = prefs.get("blurSize", 8),
|
||||||
passes = 3,
|
passes = prefs.get("blurPasses", 3),
|
||||||
|
|
||||||
-- Required for blur to be affordable. Never turn this off.
|
-- Required for blur to be affordable. Never turn this off.
|
||||||
new_optimizations = true,
|
new_optimizations = true,
|
||||||
@@ -75,8 +77,8 @@ hl.config({
|
|||||||
},
|
},
|
||||||
|
|
||||||
shadow = {
|
shadow = {
|
||||||
enabled = true,
|
enabled = prefs.get("shadowEnabled", true),
|
||||||
range = 20,
|
range = prefs.get("shadowRange", 20),
|
||||||
render_power = 3,
|
render_power = 3,
|
||||||
sharp = false,
|
sharp = false,
|
||||||
color = "rgba(15161eee)",
|
color = "rgba(15161eee)",
|
||||||
@@ -88,8 +90,8 @@ hl.config({
|
|||||||
-- border is the signature, and a strong halo would compete with it.
|
-- border is the signature, and a strong halo would compete with it.
|
||||||
-- This is just enough to lift the focused window off the wallpaper.
|
-- This is just enough to lift the focused window off the wallpaper.
|
||||||
glow = {
|
glow = {
|
||||||
enabled = true,
|
enabled = prefs.get("glowEnabled", true),
|
||||||
range = 8,
|
range = prefs.get("glowRange", 8),
|
||||||
render_power = 2,
|
render_power = 2,
|
||||||
color = "rgba(82aaff33)",
|
color = "rgba(82aaff33)",
|
||||||
color_inactive = "rgba(00000000)",
|
color_inactive = "rgba(00000000)",
|
||||||
@@ -99,7 +101,7 @@ hl.config({
|
|||||||
motion_blur = { enabled = false },
|
motion_blur = { enabled = false },
|
||||||
},
|
},
|
||||||
|
|
||||||
animations = { enabled = true },
|
animations = { enabled = prefs.get("animationsEnabled", true) },
|
||||||
|
|
||||||
dwindle = {
|
dwindle = {
|
||||||
-- Keep the split orientation a window was created with. Closest match
|
-- Keep the split orientation a window was created with. Closest match
|
||||||
@@ -118,7 +120,7 @@ hl.config({
|
|||||||
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
|
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
|
||||||
-- content type is "video" or "game" -- the tag rules.lua applies to
|
-- content type is "video" or "game" -- the tag rules.lua applies to
|
||||||
-- games. Keeps VRR off the desktop, where it causes visible flicker.
|
-- games. Keeps VRR off the desktop, where it causes visible flicker.
|
||||||
vrr = 3,
|
vrr = prefs.getInt("vrrPolicy", 3),
|
||||||
|
|
||||||
-- Blur behind the lock screen.
|
-- Blur behind the lock screen.
|
||||||
session_lock_blur = true,
|
session_lock_blur = true,
|
||||||
@@ -137,12 +139,12 @@ hl.config({
|
|||||||
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
||||||
-- that asks for it, and back out afterwards. This is how games get HDR
|
-- that asks for it, and back out afterwards. This is how games get HDR
|
||||||
-- without the desktop paying the screencopy cost. See monitors.lua.
|
-- without the desktop paying the screencopy cost. See monitors.lua.
|
||||||
cm_auto_hdr = 1,
|
cm_auto_hdr = prefs.getInt("autoHdr", 1),
|
||||||
|
|
||||||
-- 2 = direct scanout only for windows tagged content = "game"
|
-- 2 = direct scanout only for windows tagged content = "game"
|
||||||
-- (set by the rules in rules.lua). Bypasses compositing for real
|
-- (set by the rules in rules.lua). Bypasses compositing for real
|
||||||
-- fullscreen games.
|
-- fullscreen games.
|
||||||
direct_scanout = 2,
|
direct_scanout = prefs.getInt("directScanoutPolicy", 2),
|
||||||
},
|
},
|
||||||
|
|
||||||
cursor = {
|
cursor = {
|
||||||
@@ -158,7 +160,7 @@ hl.config({
|
|||||||
sync_gsettings_theme = true,
|
sync_gsettings_theme = true,
|
||||||
|
|
||||||
-- Fade the cursor out after 4s of no movement, like GNOME does.
|
-- Fade the cursor out after 4s of no movement, like GNOME does.
|
||||||
inactive_timeout = 4,
|
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
|
||||||
},
|
},
|
||||||
|
|
||||||
ecosystem = {
|
ecosystem = {
|
||||||
|
|||||||
@@ -17,11 +17,88 @@
|
|||||||
-- overrides.lua) and read the notes in that file first.
|
-- overrides.lua) and read the notes in that file first.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
|
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||||
|
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||||
|
--
|
||||||
|
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||||
|
-- stay here, because those are the settings with a documented reason attached
|
||||||
|
-- (see the header) rather than preferences, and a settings page has no way to
|
||||||
|
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||||
|
local displays = prefs.get("displays", {})
|
||||||
|
if type(displays) ~= "table" then
|
||||||
|
displays = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mode_dimensions(mode)
|
||||||
|
if type(mode) ~= "string" then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
|
||||||
|
if width == nil then
|
||||||
|
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
|
||||||
|
end
|
||||||
|
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
|
||||||
|
if width == nil or height == nil or refresh == nil
|
||||||
|
or width <= 0 or height <= 0 or refresh <= 0 then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_mode(mode)
|
||||||
|
local width = mode_dimensions(mode)
|
||||||
|
return width ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_scale(mode, scale)
|
||||||
|
local width, height = mode_dimensions(mode)
|
||||||
|
if width == nil or type(scale) ~= "number" or scale ~= scale
|
||||||
|
or scale <= 0 or scale > 4 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local logical_width = width / scale
|
||||||
|
local logical_height = height / scale
|
||||||
|
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
|
||||||
|
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_transform(transform)
|
||||||
|
return type(transform) == "number"
|
||||||
|
and transform == math.floor(transform)
|
||||||
|
and transform >= 0
|
||||||
|
and transform <= 3
|
||||||
|
end
|
||||||
|
|
||||||
|
local function display_entry(output)
|
||||||
|
if type(output) ~= "string" or output == ""
|
||||||
|
or output:match("^[%w_.-]+$") == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local entry = displays[output]
|
||||||
|
if type(entry) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if not valid_mode(entry.mode)
|
||||||
|
or not valid_scale(entry.mode, entry.scale)
|
||||||
|
or not valid_transform(entry.transform) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
local shipped_mode = "4500x3000@60"
|
||||||
|
local shipped_scale = 1.5
|
||||||
|
local shipped_transform = 0
|
||||||
|
local dp2 = display_entry("DP-2")
|
||||||
|
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "DP-2",
|
output = "DP-2",
|
||||||
mode = "4500x3000@60",
|
mode = dp2 and dp2.mode or shipped_mode,
|
||||||
position = "0x0",
|
position = "0x0",
|
||||||
scale = 1.5,
|
scale = dp2 and dp2.scale or shipped_scale,
|
||||||
|
transform = dp2 and dp2.transform or shipped_transform,
|
||||||
|
|
||||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
||||||
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
||||||
@@ -32,6 +109,24 @@ hl.monitor({
|
|||||||
cm = "auto",
|
cm = "auto",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Other connected outputs use the same validated per-output store. They keep
|
||||||
|
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||||
|
-- carries the panel-specific 10-bit policy documented above.
|
||||||
|
for output, _ in pairs(displays) do
|
||||||
|
if output ~= "DP-2" then
|
||||||
|
local entry = display_entry(output)
|
||||||
|
if entry ~= nil then
|
||||||
|
hl.monitor({
|
||||||
|
output = output,
|
||||||
|
mode = entry.mode,
|
||||||
|
position = "auto",
|
||||||
|
scale = entry.scale,
|
||||||
|
transform = entry.transform,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Any monitor not named above: sane defaults rather than nothing.
|
-- Any monitor not named above: sane defaults rather than nothing.
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "",
|
output = "",
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Shared preferences
|
||||||
|
--
|
||||||
|
-- Reads the same file Panama Settings writes:
|
||||||
|
-- $XDG_CONFIG_HOME/panama/settings.json (default ~/.config/panama/settings.json)
|
||||||
|
--
|
||||||
|
-- This is what makes the desktop one product rather than two that happen to
|
||||||
|
-- agree. The relationship is:
|
||||||
|
--
|
||||||
|
-- * the Lua config is the DEFAULT -- every prefs.get() call passes the shipped
|
||||||
|
-- value as its fallback, so this config still works standalone with no JSON
|
||||||
|
-- file at all;
|
||||||
|
-- * the JSON file is the TRUTH -- both Hyprland and Quickshell read it;
|
||||||
|
-- * Panama Settings is the EDITOR -- it writes the file and applies the change
|
||||||
|
-- live through `hyprctl eval`, so nothing needs a reload and the two sides
|
||||||
|
-- cannot drift apart.
|
||||||
|
--
|
||||||
|
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
|
||||||
|
-- file must cost the user nothing worse than their customisations; it must
|
||||||
|
-- never cost them a working compositor. Every failure path returns the caller's
|
||||||
|
-- fallback.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = {}
|
||||||
|
|
||||||
|
-- ── A small JSON reader ─────────────────────────────────────────────────────
|
||||||
|
-- Hyprland's Lua has no JSON support and pulling in a rock for a flat object of
|
||||||
|
-- scalars is not worth the dependency. Handles the whole format apart from
|
||||||
|
-- non-ASCII \u escapes, which are replaced rather than decoded -- no setting is
|
||||||
|
-- a non-ASCII string, and mangling one is preferable to failing the parse.
|
||||||
|
|
||||||
|
local function decode(text)
|
||||||
|
local pos = 1
|
||||||
|
|
||||||
|
local function skipSpace()
|
||||||
|
pos = text:find("[^ \t\r\n]", pos) or #text + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local parseValue
|
||||||
|
|
||||||
|
local function parseString()
|
||||||
|
pos = pos + 1 -- opening quote
|
||||||
|
local parts = {}
|
||||||
|
while true do
|
||||||
|
local char = text:sub(pos, pos)
|
||||||
|
if char == "" then
|
||||||
|
error("unterminated string")
|
||||||
|
elseif char == '"' then
|
||||||
|
pos = pos + 1
|
||||||
|
break
|
||||||
|
elseif char == "\\" then
|
||||||
|
local escape = text:sub(pos + 1, pos + 1)
|
||||||
|
local simple = {
|
||||||
|
n = "\n", t = "\t", r = "\r", b = "\b", f = "\f",
|
||||||
|
['"'] = '"', ["\\"] = "\\", ["/"] = "/",
|
||||||
|
}
|
||||||
|
if simple[escape] then
|
||||||
|
parts[#parts + 1] = simple[escape]
|
||||||
|
pos = pos + 2
|
||||||
|
elseif escape == "u" then
|
||||||
|
local code = tonumber(text:sub(pos + 2, pos + 5), 16)
|
||||||
|
parts[#parts + 1] = (code and code < 128) and string.char(code) or "?"
|
||||||
|
pos = pos + 6
|
||||||
|
else
|
||||||
|
error("invalid escape")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
parts[#parts + 1] = char
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(parts)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseNumber()
|
||||||
|
local literal = text:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos)
|
||||||
|
if not literal or literal == "" then
|
||||||
|
error("invalid number")
|
||||||
|
end
|
||||||
|
pos = pos + #literal
|
||||||
|
local value = tonumber(literal)
|
||||||
|
if not value then
|
||||||
|
error("invalid number")
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseObject()
|
||||||
|
pos = pos + 1 -- opening brace
|
||||||
|
local out = {}
|
||||||
|
skipSpace()
|
||||||
|
if text:sub(pos, pos) == "}" then
|
||||||
|
pos = pos + 1
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
while true do
|
||||||
|
skipSpace()
|
||||||
|
if text:sub(pos, pos) ~= '"' then
|
||||||
|
error("expected key")
|
||||||
|
end
|
||||||
|
local key = parseString()
|
||||||
|
skipSpace()
|
||||||
|
if text:sub(pos, pos) ~= ":" then
|
||||||
|
error("expected colon")
|
||||||
|
end
|
||||||
|
pos = pos + 1
|
||||||
|
out[key] = parseValue()
|
||||||
|
skipSpace()
|
||||||
|
local char = text:sub(pos, pos)
|
||||||
|
pos = pos + 1
|
||||||
|
if char == "}" then
|
||||||
|
return out
|
||||||
|
elseif char ~= "," then
|
||||||
|
error("expected comma or closing brace")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseArray()
|
||||||
|
pos = pos + 1 -- opening bracket
|
||||||
|
local out = {}
|
||||||
|
skipSpace()
|
||||||
|
if text:sub(pos, pos) == "]" then
|
||||||
|
pos = pos + 1
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
while true do
|
||||||
|
out[#out + 1] = parseValue()
|
||||||
|
skipSpace()
|
||||||
|
local char = text:sub(pos, pos)
|
||||||
|
pos = pos + 1
|
||||||
|
if char == "]" then
|
||||||
|
return out
|
||||||
|
elseif char ~= "," then
|
||||||
|
error("expected comma or closing bracket")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
parseValue = function()
|
||||||
|
skipSpace()
|
||||||
|
local char = text:sub(pos, pos)
|
||||||
|
if char == "{" then
|
||||||
|
return parseObject()
|
||||||
|
elseif char == "[" then
|
||||||
|
return parseArray()
|
||||||
|
elseif char == '"' then
|
||||||
|
return parseString()
|
||||||
|
elseif text:sub(pos, pos + 3) == "true" then
|
||||||
|
pos = pos + 4
|
||||||
|
return true
|
||||||
|
elseif text:sub(pos, pos + 4) == "false" then
|
||||||
|
pos = pos + 5
|
||||||
|
return false
|
||||||
|
elseif text:sub(pos, pos + 3) == "null" then
|
||||||
|
pos = pos + 4
|
||||||
|
return nil
|
||||||
|
elseif char == "" then
|
||||||
|
error("unexpected end of input")
|
||||||
|
else
|
||||||
|
return parseNumber()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local value = parseValue()
|
||||||
|
if type(value) ~= "table" then
|
||||||
|
error("top level value is not an object")
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Loading ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local function settingsPath()
|
||||||
|
local configHome = os.getenv("XDG_CONFIG_HOME")
|
||||||
|
if configHome == nil or configHome == "" then
|
||||||
|
local home = os.getenv("HOME")
|
||||||
|
if home == nil or home == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
configHome = home .. "/.config"
|
||||||
|
end
|
||||||
|
return configHome .. "/panama/settings.json"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function read()
|
||||||
|
local path = settingsPath()
|
||||||
|
if not path then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local file = io.open(path, "r")
|
||||||
|
if not file then
|
||||||
|
return {} -- no file yet is the normal first-run case, not an error
|
||||||
|
end
|
||||||
|
local text = file:read("*a")
|
||||||
|
file:close()
|
||||||
|
if not text or text:match("^%s*$") then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local ok, parsed = pcall(decode, text)
|
||||||
|
if ok and type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Loaded once at config time. A pcall around the whole thing so that even an
|
||||||
|
-- unanticipated failure in the reader degrades to shipped defaults.
|
||||||
|
local values = {}
|
||||||
|
do
|
||||||
|
local ok, parsed = pcall(read)
|
||||||
|
if ok and type(parsed) == "table" then
|
||||||
|
values = parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Public interface ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Returns the stored value for `key`, or `fallback` when it is absent or is not
|
||||||
|
-- the same type as the fallback. The type guard matters: a stale or hand-edited
|
||||||
|
-- file that puts a string where Hyprland needs a number would otherwise abort
|
||||||
|
-- the config, taking down far more than the one setting that was wrong.
|
||||||
|
function prefs.get(key, fallback)
|
||||||
|
local value = values[key]
|
||||||
|
if value == nil then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
if type(value) ~= type(fallback) then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Hyprland has no boolean-to-integer coercion for options that take 0/1, and
|
||||||
|
-- several of them read more naturally as a toggle in the settings UI.
|
||||||
|
function prefs.getInt(key, fallback)
|
||||||
|
local value = values[key]
|
||||||
|
if type(value) == "boolean" then
|
||||||
|
return value and 1 or 0
|
||||||
|
end
|
||||||
|
if type(value) ~= "number" then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
return math.floor(value + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- True when a settings file was actually read. Useful from overrides.lua.
|
||||||
|
function prefs.loaded()
|
||||||
|
return next(values) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return prefs
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
// User choices that Panama Settings may change at runtime. This is deliberately
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// separate from Settings.qml: the latter remains the stable public surface used
|
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
|
||||||
// by the shell, while this object owns durable mutation and shipped defaults.
|
//
|
||||||
|
// This object owns reading, validating, and writing. It knows nothing about
|
||||||
|
// which settings exist -- that is the schema's job -- so adding a setting never
|
||||||
|
// requires touching this file. That is the point: the previous implementation
|
||||||
|
// restated every key four times (a property alias, a JSON adapter property, a
|
||||||
|
// change handler, and a line in reset), and each omission failed silently.
|
||||||
|
//
|
||||||
|
// Reads go through get(), writes through set(). Typed accessors for the values
|
||||||
|
// the shell reads on every frame live in Settings.qml, which remains the stable
|
||||||
|
// public surface.
|
||||||
|
//
|
||||||
|
// The store lives at ~/.config/panama/settings.json rather than inside
|
||||||
|
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
|
||||||
|
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
|
||||||
|
// able to back it up, diff it, or keep it in a dotfiles repo.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
@@ -11,110 +26,133 @@ import QtQuick
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property alias use24Hour: values.use24Hour
|
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
|
||||||
property alias showSeconds: values.showSeconds
|
|
||||||
property alias showWeekday: values.showWeekday
|
// Bumped on every accepted change. get() reads it so bindings built on get()
|
||||||
property alias showCpu: values.showCpu
|
// have something to invalidate; a bare function call would otherwise capture
|
||||||
property alias showMemory: values.showMemory
|
// no dependency and every reader would silently go stale.
|
||||||
property alias showGpu: values.showGpu
|
property int revision: 0
|
||||||
property alias dockAutohide: values.dockAutohide
|
|
||||||
property alias dockRevealDelayMs: values.dockRevealDelayMs
|
// Everything currently on disk, including keys this build does not know
|
||||||
property alias dockHideDelayMs: values.dockHideDelayMs
|
// about. Unknown keys are carried through untouched so that rolling back to
|
||||||
property alias focusDurationMinutes: values.focusDurationMinutes
|
// an older Panama does not discard a newer version's settings.
|
||||||
property alias autoHdr: values.autoHdr
|
property var values: ({})
|
||||||
property alias vrrPolicy: values.vrrPolicy
|
|
||||||
property alias directScanoutPolicy: values.directScanoutPolicy
|
property bool loaded: false
|
||||||
property alias nightLightEnabled: values.nightLightEnabled
|
|
||||||
property alias nightLightAutomatic: values.nightLightAutomatic
|
function get(key: string): var {
|
||||||
property alias nightLightTemperature: values.nightLightTemperature
|
root.revision;
|
||||||
property alias lastPage: values.lastPage
|
const stored = root.values[key];
|
||||||
|
if (stored === undefined)
|
||||||
|
return PreferenceSchema.defaultFor(key);
|
||||||
|
const coerced = PreferenceSchema.coerce(key, stored);
|
||||||
|
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns false when the key is unknown or the value cannot be represented,
|
||||||
|
// so a caller can surface the rejection instead of assuming it took.
|
||||||
|
function set(key: string, value: var): bool {
|
||||||
|
if (!PreferenceSchema.has(key))
|
||||||
|
return false;
|
||||||
|
const coerced = PreferenceSchema.coerce(key, value);
|
||||||
|
if (coerced === undefined)
|
||||||
|
return false;
|
||||||
|
if (root.values[key] === coerced)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Reassign rather than mutate: QML does not notify on in-place changes
|
||||||
|
// to a var property's contents.
|
||||||
|
const next = Object.assign({}, root.values);
|
||||||
|
next[key] = coerced;
|
||||||
|
root.values = next;
|
||||||
|
root.revision++;
|
||||||
|
persistTimer.restart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores every schema default in one write. Complete by construction --
|
||||||
|
// there is no hand-maintained list to fall out of sync with the schema.
|
||||||
|
function resetDesktopDefaults(): void {
|
||||||
|
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
|
||||||
|
root.values = next;
|
||||||
|
root.revision++;
|
||||||
|
persistTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read the file from disk. Used after something outside the shell has
|
||||||
|
// rewritten it -- restoring a snapshot, or a hand edit -- so the running
|
||||||
|
// desktop reflects the new contents without waiting for the next change.
|
||||||
|
function reload(): void {
|
||||||
|
preferencesFile.reload();
|
||||||
|
root.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): void {
|
||||||
|
let parsed = {};
|
||||||
|
try {
|
||||||
|
const text = preferencesFile.text();
|
||||||
|
if (text && text.trim().length > 0)
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
// A corrupt file must not cost the user a working desktop. Fall
|
||||||
|
// back to shipped defaults and let the next write replace it.
|
||||||
|
parsed = {};
|
||||||
|
}
|
||||||
|
root.values = (parsed && typeof parsed === "object") ? parsed : {};
|
||||||
|
root.revision++;
|
||||||
|
root.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
id: preferencesFile
|
id: preferencesFile
|
||||||
|
|
||||||
path: Quickshell.stateDir + "/panama-settings.json"
|
path: root.path
|
||||||
blockLoading: true
|
blockLoading: true
|
||||||
printErrors: false
|
printErrors: false
|
||||||
atomicWrites: true
|
atomicWrites: true
|
||||||
|
|
||||||
JsonAdapter {
|
onLoaded: root.load()
|
||||||
id: values
|
// No file yet is the normal first-run case, not an error.
|
||||||
|
onLoadFailed: root.load()
|
||||||
property bool use24Hour: false
|
|
||||||
property bool showSeconds: true
|
|
||||||
property bool showWeekday: true
|
|
||||||
property bool showCpu: true
|
|
||||||
property bool showMemory: true
|
|
||||||
property bool showGpu: true
|
|
||||||
property bool dockAutohide: true
|
|
||||||
property int dockRevealDelayMs: 0
|
|
||||||
property int dockHideDelayMs: 250
|
|
||||||
property int focusDurationMinutes: 45
|
|
||||||
property bool autoHdr: true
|
|
||||||
property int vrrPolicy: 3
|
|
||||||
property int directScanoutPolicy: 2
|
|
||||||
property bool nightLightEnabled: false
|
|
||||||
property bool nightLightAutomatic: false
|
|
||||||
property int nightLightTemperature: 3500
|
|
||||||
property string lastPage: "home"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen after the adapter has been constructed instead of writing from
|
Component.onCompleted: {
|
||||||
// FileView.onAdapterUpdated. The latter also fires for default-property
|
migration.adopt();
|
||||||
// initialization, which can overwrite a valid file before it is loaded.
|
root.load();
|
||||||
Connections {
|
|
||||||
target: values
|
|
||||||
function onUse24HourChanged(): void { persistTimer.restart(); }
|
|
||||||
function onShowSecondsChanged(): void { persistTimer.restart(); }
|
|
||||||
function onShowWeekdayChanged(): void { persistTimer.restart(); }
|
|
||||||
function onShowCpuChanged(): void { persistTimer.restart(); }
|
|
||||||
function onShowMemoryChanged(): void { persistTimer.restart(); }
|
|
||||||
function onShowGpuChanged(): void { persistTimer.restart(); }
|
|
||||||
function onDockAutohideChanged(): void { persistTimer.restart(); }
|
|
||||||
function onDockRevealDelayMsChanged(): void { persistTimer.restart(); }
|
|
||||||
function onDockHideDelayMsChanged(): void { persistTimer.restart(); }
|
|
||||||
function onFocusDurationMinutesChanged(): void { persistTimer.restart(); }
|
|
||||||
function onAutoHdrChanged(): void { persistTimer.restart(); }
|
|
||||||
function onVrrPolicyChanged(): void { persistTimer.restart(); }
|
|
||||||
function onDirectScanoutPolicyChanged(): void { persistTimer.restart(); }
|
|
||||||
function onNightLightEnabledChanged(): void { persistTimer.restart(); }
|
|
||||||
function onNightLightAutomaticChanged(): void { persistTimer.restart(); }
|
|
||||||
function onNightLightTemperatureChanged(): void { persistTimer.restart(); }
|
|
||||||
function onLastPageChanged(): void { persistTimer.restart(); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton construction can happen after FileView's preload phase when a
|
// Coalesce a burst of changes into one atomic write. Writing on every
|
||||||
// test or a lazy page first references it. An explicit reload makes the
|
// assignment races a second assignment and can reload the older value.
|
||||||
// durable state authoritative in that case as well as in the main shell.
|
|
||||||
Component.onCompleted: preferencesFile.reload()
|
|
||||||
|
|
||||||
// Coalesce a group of UI changes into one atomic write. Writing from the
|
|
||||||
// adapter's change signal itself can race a second property assignment and
|
|
||||||
// reload the older value before the batch has finished.
|
|
||||||
Timer {
|
Timer {
|
||||||
id: persistTimer
|
id: persistTimer
|
||||||
interval: 0
|
interval: 0
|
||||||
onTriggered: preferencesFile.writeAdapter()
|
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetDesktopDefaults(): void {
|
// One-time move from the pre-Stage-1 location inside Quickshell's state
|
||||||
values.use24Hour = false;
|
// directory. Reads the old file only when the new one does not exist yet, so
|
||||||
values.showSeconds = true;
|
// it can never overwrite newer settings, and never deletes the original.
|
||||||
values.showWeekday = true;
|
QtObject {
|
||||||
values.showCpu = true;
|
id: migration
|
||||||
values.showMemory = true;
|
|
||||||
values.showGpu = true;
|
function adopt(): void {
|
||||||
values.dockAutohide = true;
|
if (preferencesFile.text())
|
||||||
values.dockRevealDelayMs = 0;
|
return;
|
||||||
values.dockHideDelayMs = 250;
|
try {
|
||||||
values.focusDurationMinutes = 45;
|
const legacy = legacyFile.text();
|
||||||
values.autoHdr = true;
|
if (legacy && legacy.trim().length > 0)
|
||||||
values.vrrPolicy = 3;
|
preferencesFile.setText(legacy);
|
||||||
values.directScanoutPolicy = 2;
|
} catch (error) {
|
||||||
values.nightLightEnabled = false;
|
// Nothing to migrate.
|
||||||
values.nightLightAutomatic = false;
|
}
|
||||||
values.nightLightTemperature = 3500;
|
}
|
||||||
values.lastPage = "home";
|
}
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: legacyFile
|
||||||
|
|
||||||
|
path: Quickshell.stateDir + "/panama-settings.json"
|
||||||
|
blockLoading: true
|
||||||
|
printErrors: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias initialized: values.initialized
|
||||||
|
property alias favorites: values.favorites
|
||||||
|
property string saveError: ""
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: preferencesFile
|
||||||
|
|
||||||
|
path: Quickshell.stateDir + "/panama-home.json"
|
||||||
|
blockLoading: true
|
||||||
|
printErrors: false
|
||||||
|
atomicWrites: true
|
||||||
|
onSaved: root.saveError = ""
|
||||||
|
onSaveFailed: error => root.saveError = "Could not save Home favourites."
|
||||||
|
|
||||||
|
JsonAdapter {
|
||||||
|
id: values
|
||||||
|
|
||||||
|
property bool initialized: false
|
||||||
|
property var favorites: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: values
|
||||||
|
function onInitializedChanged(): void { persistTimer.restart(); }
|
||||||
|
function onFavoritesChanged(): void { persistTimer.restart(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: preferencesFile.reload()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: persistTimer
|
||||||
|
interval: 180
|
||||||
|
repeat: false
|
||||||
|
onTriggered: preferencesFile.writeAdapter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneFavorites(): var {
|
||||||
|
var clone = [];
|
||||||
|
for (var index = 0; index < values.favorites.length; index++) {
|
||||||
|
var favorite = values.favorites[index];
|
||||||
|
clone.push({
|
||||||
|
id: favorite.id,
|
||||||
|
alias: favorite.alias
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEntityId(entityId: var): bool {
|
||||||
|
return typeof entityId === "string" && /^light\.[a-z0-9_]+$/.test(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialize(legacyIds: var): void {
|
||||||
|
if (values.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seededFavorites = [];
|
||||||
|
var seen = {};
|
||||||
|
if (legacyIds && typeof legacyIds.length === "number") {
|
||||||
|
for (var index = 0; index < legacyIds.length; index++) {
|
||||||
|
var entityId = legacyIds[index];
|
||||||
|
if (!isValidEntityId(entityId) || seen[entityId]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen[entityId] = true;
|
||||||
|
seededFavorites.push({ id: entityId, alias: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
values.favorites = seededFavorites;
|
||||||
|
values.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliasFor(entityId: string, sourceName: string): string {
|
||||||
|
for (var index = 0; index < values.favorites.length; index++) {
|
||||||
|
var favorite = values.favorites[index];
|
||||||
|
if (favorite.id === entityId && favorite.alias !== "") {
|
||||||
|
return favorite.alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sourceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(entityId: string): void {
|
||||||
|
if (!isValidEntityId(entityId) || isSelected(entityId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextFavorites = cloneFavorites();
|
||||||
|
nextFavorites.push({ id: entityId, alias: "" });
|
||||||
|
values.favorites = nextFavorites;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(entityId: string): void {
|
||||||
|
var nextFavorites = [];
|
||||||
|
var removed = false;
|
||||||
|
for (var index = 0; index < values.favorites.length; index++) {
|
||||||
|
var favorite = values.favorites[index];
|
||||||
|
if (favorite.id === entityId) {
|
||||||
|
removed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextFavorites.push({ id: favorite.id, alias: favorite.alias });
|
||||||
|
}
|
||||||
|
if (removed) {
|
||||||
|
values.favorites = nextFavorites;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAlias(entityId: string, alias: string): void {
|
||||||
|
var nextFavorites = cloneFavorites();
|
||||||
|
var updated = false;
|
||||||
|
var trimmedAlias = String(alias).trim();
|
||||||
|
for (var index = 0; index < nextFavorites.length; index++) {
|
||||||
|
if (nextFavorites[index].id === entityId) {
|
||||||
|
nextFavorites[index] = { id: entityId, alias: trimmedAlias };
|
||||||
|
updated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
values.favorites = nextFavorites;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(entityId: string, targetIndex: int): void {
|
||||||
|
var nextFavorites = cloneFavorites();
|
||||||
|
var currentIndex = -1;
|
||||||
|
for (var index = 0; index < nextFavorites.length; index++) {
|
||||||
|
if (nextFavorites[index].id === entityId) {
|
||||||
|
currentIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var favorite = nextFavorites.splice(currentIndex, 1)[0];
|
||||||
|
var clampedIndex = Math.max(0, Math.min(targetIndex, nextFavorites.length));
|
||||||
|
nextFavorites.splice(clampedIndex, 0, favorite);
|
||||||
|
values.favorites = nextFavorites;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrySave(): void {
|
||||||
|
preferencesFile.writeAdapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The schema is the single source of truth for every user-changeable setting.
|
||||||
|
//
|
||||||
|
// One entry describes a setting completely: its name, type, default, valid
|
||||||
|
// range, which group it belongs to, and how to label it. Persistence,
|
||||||
|
// validation, reset, and (from Stage 3) the settings UI itself are all derived
|
||||||
|
// from these entries rather than restated.
|
||||||
|
//
|
||||||
|
// Adding a setting means adding one entry here. It does not mean editing a
|
||||||
|
// property alias, a JSON adapter, a change handler, and a reset function --
|
||||||
|
// which is what it used to mean, and why the settings app stalled at sixteen
|
||||||
|
// knobs while forty comparable values stayed hardcoded one file away.
|
||||||
|
//
|
||||||
|
// Entry fields
|
||||||
|
// key unique identifier; also the JSON key on disk
|
||||||
|
// type "bool" | "int" | "real" | "string" | "enum" | "json"
|
||||||
|
// def shipped default, used when the file is absent or a value is invalid
|
||||||
|
// min/max inclusive bounds for int and real; values outside are clamped
|
||||||
|
// step UI increment for int and real
|
||||||
|
// options for "enum": [{ value, label }], the only accepted values
|
||||||
|
// group grouping id, used to build settings pages
|
||||||
|
// label short UI name
|
||||||
|
// detail one line explaining what changing it does
|
||||||
|
// internal true for state the shell keeps but the user never edits directly
|
||||||
|
// pattern for "string": a regular expression the value must match in full
|
||||||
|
//
|
||||||
|
// "json" holds a structured value -- a list or an object -- that the schema
|
||||||
|
// stores and resets but does not validate field by field. It exists so that
|
||||||
|
// settings like the dock's pinned applications live in the same file, and are
|
||||||
|
// covered by the same reset, as everything else rather than growing a fourth
|
||||||
|
// preference store. The service that owns such a value is responsible for
|
||||||
|
// validating it; see services/Dock-related consumers.
|
||||||
|
// hypr present when the setting maps onto an Hyprland option:
|
||||||
|
// path the hl.config table path, e.g. ["decoration","blur","size"]
|
||||||
|
// option the getoption path used to read the value back
|
||||||
|
// readAs which field getoption returns it in -- "int", "bool",
|
||||||
|
// "float", "str", or "css" (gaps, returned as a box)
|
||||||
|
//
|
||||||
|
// Anything with a `hypr` block is applied live by services/SystemSettings.qml
|
||||||
|
// and read at startup by config/dot/hypr/prefs.lua, using the same key. The Lua
|
||||||
|
// keeps the shipped value as its fallback, so the config still works with no
|
||||||
|
// settings file at all.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property var entries: [
|
||||||
|
// ── Clock ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "use24Hour", type: "bool", def: false, group: "clock",
|
||||||
|
label: "24-hour time",
|
||||||
|
detail: "Use 18:30 instead of 6:30 PM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "showSeconds", type: "bool", def: true, group: "clock",
|
||||||
|
label: "Show seconds",
|
||||||
|
detail: "Keep a precise clock in the center of the bar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "showWeekday", type: "bool", def: true, group: "clock",
|
||||||
|
label: "Show weekday",
|
||||||
|
detail: "Include the abbreviated weekday before the date"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Vitals ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "showCpu", type: "bool", def: true, group: "vitals",
|
||||||
|
label: "Processor",
|
||||||
|
detail: "Show processor usage beside the workspace indicator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "showMemory", type: "bool", def: true, group: "vitals",
|
||||||
|
label: "Memory",
|
||||||
|
detail: "Show memory usage beside the workspace indicator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "showGpu", type: "bool", def: true, group: "vitals",
|
||||||
|
label: "Graphics",
|
||||||
|
detail: "Show graphics usage beside the workspace indicator"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Dock ────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "dockAutohide", type: "bool", def: true, group: "dock",
|
||||||
|
label: "Automatically hide the Dock",
|
||||||
|
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
|
||||||
|
unit: "ms",
|
||||||
|
group: "dock",
|
||||||
|
label: "Reveal delay",
|
||||||
|
detail: "Zero reveals the Dock the instant the pointer reaches the edge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
|
||||||
|
unit: "ms",
|
||||||
|
group: "dock",
|
||||||
|
label: "Hide delay",
|
||||||
|
detail: "Prevents flicker when crossing between icons"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Focus ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||||
|
unit: "min",
|
||||||
|
group: "focus",
|
||||||
|
label: "Focus session length",
|
||||||
|
detail: "How long a focus session runs before it ends itself"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Display policy ──────────────────────────────────────────────────
|
||||||
|
// These three are written to the compositor and verified by read-back.
|
||||||
|
// See services/SystemSettings.qml for why the exit code cannot be
|
||||||
|
// trusted for either hyprctl keyword or hyprctl eval.
|
||||||
|
{
|
||||||
|
key: "autoHdr", type: "bool", def: true, group: "display",
|
||||||
|
label: "Game-aware HDR",
|
||||||
|
detail: "Hand HDR to fullscreen games while the desktop stays SDR",
|
||||||
|
hypr: { path: ["render", "cm_auto_hdr"], option: "render:cm_auto_hdr", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vrrPolicy", type: "enum", def: 3, group: "display",
|
||||||
|
label: "Variable refresh rate",
|
||||||
|
detail: "Content-aware matches the display to what is on screen",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 3, label: "Content-aware" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "directScanoutPolicy", type: "enum", def: 2, group: "display",
|
||||||
|
label: "Direct scanout",
|
||||||
|
detail: "Lets fullscreen content bypass compositing",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 2, label: "Automatic" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Window appearance ───────────────────────────────────────────────
|
||||||
|
// These adjust the parameters of the Prism identity -- how much space,
|
||||||
|
// how soft, how much motion -- rather than replacing it. Shipped values
|
||||||
|
// are duplicated as the fallbacks in config/dot/hypr/looks.lua so that
|
||||||
|
// the Hyprland config still stands on its own.
|
||||||
|
{
|
||||||
|
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "windows",
|
||||||
|
label: "Inner gaps",
|
||||||
|
detail: "Space between neighbouring tiled windows",
|
||||||
|
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "windows",
|
||||||
|
label: "Outer gaps",
|
||||||
|
detail: "Space between the tiled area and the screen edge",
|
||||||
|
hypr: { path: ["general", "gaps_out"], option: "general:gaps_out", readAs: "css" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "windows",
|
||||||
|
label: "Border width",
|
||||||
|
detail: "Thickness of the gradient border on the focused window",
|
||||||
|
hypr: { path: ["general", "border_size"], option: "general:border_size", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "windows",
|
||||||
|
label: "Corner radius",
|
||||||
|
detail: "Matches the shell's popover radius so windows and panels agree",
|
||||||
|
hypr: { path: ["decoration", "rounding"], option: "decoration:rounding", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inactiveOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||||
|
group: "windows",
|
||||||
|
label: "Unfocused window opacity",
|
||||||
|
detail: "Fade windows that do not have focus",
|
||||||
|
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Effects ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
||||||
|
label: "Blur",
|
||||||
|
detail: "Blur the desktop behind translucent surfaces",
|
||||||
|
hypr: { path: ["decoration", "blur", "enabled"], option: "decoration:blur:enabled", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
|
||||||
|
group: "effects",
|
||||||
|
label: "Blur radius",
|
||||||
|
detail: "Larger is softer and costs more frame time",
|
||||||
|
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "blurPasses", type: "int", def: 3, min: 1, max: 5, step: 1,
|
||||||
|
group: "effects",
|
||||||
|
label: "Blur passes",
|
||||||
|
detail: "More passes look smoother and cost more frame time",
|
||||||
|
hypr: { path: ["decoration", "blur", "passes"], option: "decoration:blur:passes", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "shadowEnabled", type: "bool", def: true, group: "effects",
|
||||||
|
label: "Window shadows",
|
||||||
|
detail: "Lift windows off the wallpaper with a soft shadow",
|
||||||
|
hypr: { path: ["decoration", "shadow", "enabled"], option: "decoration:shadow:enabled", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "effects",
|
||||||
|
label: "Shadow size",
|
||||||
|
detail: "How far the shadow spreads from the window edge",
|
||||||
|
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
||||||
|
label: "Focus glow",
|
||||||
|
detail: "A faint halo behind the focused window",
|
||||||
|
hypr: { path: ["decoration", "glow", "enabled"], option: "decoration:glow:enabled", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "effects",
|
||||||
|
label: "Glow size",
|
||||||
|
detail: "Kept small deliberately: the gradient border is the signature",
|
||||||
|
hypr: { path: ["decoration", "glow", "range"], option: "decoration:glow:range", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "animationsEnabled", type: "bool", def: true, group: "effects",
|
||||||
|
label: "Animations",
|
||||||
|
detail: "Window, workspace, and panel motion",
|
||||||
|
hypr: { path: ["animations", "enabled"], option: "animations:enabled", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Input ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "keyboardLayout", type: "string", def: "us", group: "input",
|
||||||
|
// Reaches an hl.config string, so it is constrained to the shape of
|
||||||
|
// an XKB layout list and nothing else.
|
||||||
|
pattern: "^[a-z]{2,8}(,[a-z]{2,8})*$",
|
||||||
|
label: "Keyboard layout",
|
||||||
|
detail: "XKB layout name, or a comma-separated list to switch between",
|
||||||
|
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "numlockByDefault", type: "bool", def: true, group: "input",
|
||||||
|
label: "Num Lock on login",
|
||||||
|
detail: "Turn Num Lock on when the session starts",
|
||||||
|
hypr: { path: ["input", "numlock_by_default"], option: "input:numlock_by_default", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
|
||||||
|
unit: "ms",
|
||||||
|
group: "input",
|
||||||
|
label: "Repeat delay",
|
||||||
|
detail: "How long a key is held before it starts repeating",
|
||||||
|
hypr: { path: ["input", "repeat_delay"], option: "input:repeat_delay", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
|
||||||
|
unit: "/s",
|
||||||
|
group: "input",
|
||||||
|
label: "Repeat rate",
|
||||||
|
detail: "How many characters a second a held key produces",
|
||||||
|
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "followMouse", type: "enum", def: 1, group: "input",
|
||||||
|
label: "Focus follows pointer",
|
||||||
|
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Never" },
|
||||||
|
{ value: 1, label: "Click to focus" },
|
||||||
|
{ value: 2, label: "Sloppy focus" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
|
||||||
|
group: "input",
|
||||||
|
label: "Pointer speed",
|
||||||
|
detail: "Zero is flat, unaccelerated response",
|
||||||
|
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||||
|
unit: "s",
|
||||||
|
group: "input",
|
||||||
|
label: "Hide pointer after",
|
||||||
|
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
||||||
|
// Reported as a float even though it is only ever set to whole
|
||||||
|
// seconds. `readAs` describes what getoption answers with, not what
|
||||||
|
// the setting means -- getting this wrong makes every write to it
|
||||||
|
// look rejected.
|
||||||
|
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Night light ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||||
|
label: "Night Light",
|
||||||
|
detail: "Shift the display warmer to reduce blue light"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nightLightAutomatic", type: "bool", def: false, group: "nightLight",
|
||||||
|
label: "Schedule automatically",
|
||||||
|
detail: "Turn Night Light on and off at the scheduled hours"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
|
||||||
|
unit: "K",
|
||||||
|
group: "nightLight",
|
||||||
|
label: "Color temperature",
|
||||||
|
detail: "Lower is warmer"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Desktop background ──────────────────────────────────────────────
|
||||||
|
// Applied through hyprpaper's IPC. Not an hl.config option, so it has
|
||||||
|
// no `hypr` block; services/Wallpaper.qml owns applying it.
|
||||||
|
{
|
||||||
|
key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
|
||||||
|
// Reaches hyprpaper as the "<output>,<path>" argument form, so a
|
||||||
|
// comma would split it into a different request. Absolute paths
|
||||||
|
// only, no commas, no newlines.
|
||||||
|
pattern: "^(|/[^,\\n]+)$",
|
||||||
|
label: "Wallpaper",
|
||||||
|
detail: "Shown on every output"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Idle, lock, and sleep ───────────────────────────────────────────
|
||||||
|
// Written into a generated hypridle config; see scripts/panama-idle.
|
||||||
|
// Zero means never for all three.
|
||||||
|
{
|
||||||
|
key: "screenBlankMinutes", type: "int", def: 5, min: 0, max: 120, step: 1,
|
||||||
|
unit: "min", group: "idle",
|
||||||
|
label: "Turn the screen off after",
|
||||||
|
detail: "Blanks the display; nothing is locked yet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lockMinutes", type: "int", def: 10, min: 0, max: 240, step: 1,
|
||||||
|
unit: "min", group: "idle",
|
||||||
|
label: "Lock the screen after",
|
||||||
|
detail: "Counted from when the session went idle, not from blanking"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "suspendMinutes", type: "int", def: 0, min: 0, max: 480, step: 5,
|
||||||
|
unit: "min", group: "idle",
|
||||||
|
label: "Suspend after",
|
||||||
|
detail: "This is a desktop, so Panama ships with automatic suspend off"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lockOnSleep", type: "bool", def: true, group: "idle",
|
||||||
|
label: "Lock before sleeping",
|
||||||
|
detail: "Requires your password when the machine wakes"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Accessibility ───────────────────────────────────────────────────
|
||||||
|
// Backed by gsettings so GTK applications agree with the shell, and
|
||||||
|
// pushed to the compositor as well where it has its own notion.
|
||||||
|
{
|
||||||
|
key: "cursorSize", type: "int", def: 24, min: 16, max: 64, step: 4,
|
||||||
|
unit: "px", group: "accessibility",
|
||||||
|
label: "Pointer size",
|
||||||
|
detail: "Applies to the compositor and to applications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "textScale", type: "real", def: 1.0, min: 0.75, max: 2.0, step: 0.05,
|
||||||
|
group: "accessibility",
|
||||||
|
label: "Text size",
|
||||||
|
detail: "Scales interface text everywhere; 1.00 is the design size"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Weather ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "temperatureUnit", type: "enum", def: "fahrenheit", group: "weather",
|
||||||
|
label: "Temperature unit",
|
||||||
|
detail: "Choose Fahrenheit or Celsius for the weather card",
|
||||||
|
options: [
|
||||||
|
{ value: "fahrenheit", label: "Fahrenheit" },
|
||||||
|
{ value: "celsius", label: "Celsius" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "weatherRefreshMinutes", type: "int", def: 20, min: 5, max: 120, step: 5,
|
||||||
|
unit: "min", group: "weather",
|
||||||
|
label: "Weather refresh",
|
||||||
|
detail: "How often Panama updates the current conditions"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Vitals refresh ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
|
||||||
|
unit: "ms", group: "vitals",
|
||||||
|
label: "Vitals refresh",
|
||||||
|
detail: "How often processor, memory, and graphics usage update"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Notifications ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "notificationTimeoutMs", type: "int", def: 5000, min: 1000, max: 30000, step: 500,
|
||||||
|
unit: "ms", group: "notifications",
|
||||||
|
label: "Notification duration",
|
||||||
|
detail: "How long ordinary notification banners remain visible"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notificationTimeoutCriticalMs", type: "int", def: 0, min: 0, max: 60000, step: 1000,
|
||||||
|
unit: "ms", group: "notifications",
|
||||||
|
label: "Critical notification duration",
|
||||||
|
detail: "Zero keeps critical notification banners visible until dismissed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notificationHistoryLimit", type: "int", def: 100, min: 10, max: 500, step: 10,
|
||||||
|
group: "notifications",
|
||||||
|
label: "Notification history",
|
||||||
|
detail: "Maximum notifications retained in the notification center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maxVisibleToasts", type: "int", def: 4, min: 1, max: 8, step: 1,
|
||||||
|
group: "notifications",
|
||||||
|
label: "Visible banners",
|
||||||
|
detail: "Maximum notification banners shown at once"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Capture ─────────────────────────────────────────────────────────
|
||||||
|
// Directories and encoder arguments are enums rather than free text:
|
||||||
|
// both are handed to a recorder process, and an arbitrary string there
|
||||||
|
// is a much larger surface than a settings page needs to expose.
|
||||||
|
{
|
||||||
|
key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
|
||||||
|
label: "Screenshot folder",
|
||||||
|
detail: "Folder under your home directory for screenshots",
|
||||||
|
options: [
|
||||||
|
{ value: "Pictures/Screenshots", label: "Pictures / Screenshots" },
|
||||||
|
{ value: "Pictures", label: "Pictures" },
|
||||||
|
{ value: "Desktop", label: "Desktop" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
|
||||||
|
label: "Recording folder",
|
||||||
|
detail: "Folder under your home directory for screen recordings",
|
||||||
|
options: [
|
||||||
|
{ value: "Videos/Recordings", label: "Videos / Recordings" },
|
||||||
|
{ value: "Videos", label: "Videos" },
|
||||||
|
{ value: "Desktop", label: "Desktop" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
|
||||||
|
group: "capture",
|
||||||
|
label: "Recording encoder",
|
||||||
|
detail: "Hardware encoding keeps recording off the processor while gaming",
|
||||||
|
options: [
|
||||||
|
{ value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
|
||||||
|
{ value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
|
||||||
|
{ value: "-c libx264", label: "CPU x264" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Dock contents ───────────────────────────────────────────────────
|
||||||
|
// A "json" value: the ordered list of desktop entry ids pinned to the
|
||||||
|
// dock. Kept in the shared store so that reordering the dock is covered
|
||||||
|
// by Restore defaults like everything else, rather than living in its
|
||||||
|
// own file. The shipped order is the GNOME dash it replaced.
|
||||||
|
{
|
||||||
|
key: "dockPinned", type: "json", group: "dock",
|
||||||
|
label: "Pinned applications",
|
||||||
|
detail: "Applications that stay in the Dock whether or not they are running",
|
||||||
|
def: [
|
||||||
|
"org.gnome.Settings", "kitty", "org.gnome.Nautilus",
|
||||||
|
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
||||||
|
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||||
|
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||||
|
"io.podman_desktop.PodmanDesktop", "claude-desktop",
|
||||||
|
"codex-desktop", "md.obsidian.Obsidian",
|
||||||
|
"com.obsproject.Studio", "steam"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Keyboard shortcut overrides ─────────────────────────────────────
|
||||||
|
// { "<bind description>": "<chord>" }. Only the chord is stored: the
|
||||||
|
// action always comes from hypr/keybinds.lua, so an override can move a
|
||||||
|
// shortcut but can never make one do something else. The Lua validates
|
||||||
|
// each chord and falls back to the shipped one, so a hand-edited file
|
||||||
|
// cannot cost you a keymap.
|
||||||
|
//
|
||||||
|
// Edited through the Input & Shortcuts page rather than as a row, hence
|
||||||
|
// internal.
|
||||||
|
{
|
||||||
|
key: "keybindOverrides", type: "json", def: ({}), group: "input",
|
||||||
|
internal: true,
|
||||||
|
label: "Keyboard shortcut overrides",
|
||||||
|
detail: "Shortcuts you have moved from their shipped chord"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
|
// { "<output>": { mode, scale, transform } }, applied by
|
||||||
|
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||||
|
// bit depth are deliberately not here: those carry a documented
|
||||||
|
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||||
|
// you would be changing it.
|
||||||
|
{
|
||||||
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
|
internal: true,
|
||||||
|
label: "Display configuration",
|
||||||
|
detail: "Resolution, scale, and rotation per connected display"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Internal ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||||
|
internal: true,
|
||||||
|
label: "Last settings page",
|
||||||
|
detail: "Restores the page Settings was left on"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// key -> entry, built once. Every lookup below goes through this rather than
|
||||||
|
// scanning `entries`, since get/set are called from bindings.
|
||||||
|
readonly property var byKey: {
|
||||||
|
const index = {};
|
||||||
|
for (const entry of root.entries)
|
||||||
|
index[entry.key] = entry;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var userKeys: root.entries.filter(entry => !entry.internal).map(entry => entry.key)
|
||||||
|
|
||||||
|
function spec(key: string): var {
|
||||||
|
return root.byKey[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(key: string): bool {
|
||||||
|
return root.byKey[key] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultFor(key: string): var {
|
||||||
|
const entry = root.byKey[key];
|
||||||
|
return entry ? entry.def : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaults(): var {
|
||||||
|
const out = {};
|
||||||
|
for (const entry of root.entries)
|
||||||
|
out[entry.key] = entry.def;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inGroup(group: string): var {
|
||||||
|
return root.entries.filter(entry => entry.group === group && !entry.internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries the compositor owns, used to build one hl.config{} payload.
|
||||||
|
function hyprEntries(): var {
|
||||||
|
return root.entries.filter(entry => entry.hypr !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the value coerced into the entry's type and range, or `undefined`
|
||||||
|
// if it cannot be represented at all. Out-of-range numbers are clamped
|
||||||
|
// rather than rejected: a stale file with a since-narrowed bound should
|
||||||
|
// still yield a usable desktop.
|
||||||
|
function coerce(key: string, value: var): var {
|
||||||
|
const entry = root.byKey[key];
|
||||||
|
if (!entry || value === undefined || value === null)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
switch (entry.type) {
|
||||||
|
case "bool":
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
case "int":
|
||||||
|
case "real": {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!isFinite(numeric)) return undefined;
|
||||||
|
const rounded = entry.type === "int" ? Math.round(numeric) : numeric;
|
||||||
|
const lower = entry.min !== undefined ? Math.max(rounded, entry.min) : rounded;
|
||||||
|
return entry.max !== undefined ? Math.min(lower, entry.max) : lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enum":
|
||||||
|
return entry.options.some(option => option.value === value) ? value : undefined;
|
||||||
|
|
||||||
|
case "json":
|
||||||
|
// Accepted as-is. Anything JSON.parse produced is representable,
|
||||||
|
// and per-field meaning belongs to the owning service rather than
|
||||||
|
// here. A scalar is rejected so a corrupt file falls back to the
|
||||||
|
// default instead of handing a list-shaped consumer a number.
|
||||||
|
return (typeof value === "object") ? value : undefined;
|
||||||
|
|
||||||
|
case "string": {
|
||||||
|
const text = typeof value === "string" ? value : String(value);
|
||||||
|
// A constrained string is rejected rather than sanitised. Several
|
||||||
|
// of these are serialised into an hl.config payload, and quietly
|
||||||
|
// stripping characters would turn a typo into a different setting
|
||||||
|
// instead of an error the user can see.
|
||||||
|
if (entry.pattern && !new RegExp(entry.pattern).test(text))
|
||||||
|
return undefined;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,9 @@ Singleton {
|
|||||||
|
|
||||||
// ── Clock ───────────────────────────────────────────────────────────────
|
// ── Clock ───────────────────────────────────────────────────────────────
|
||||||
// Carried over from GNOME: 12-hour, weekday + date shown, seconds on.
|
// Carried over from GNOME: 12-hour, weekday + date shown, seconds on.
|
||||||
readonly property bool use24Hour: DesktopPreferences.use24Hour
|
readonly property bool use24Hour: DesktopPreferences.get("use24Hour")
|
||||||
readonly property bool showSeconds: DesktopPreferences.showSeconds
|
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
|
||||||
readonly property bool showWeekday: DesktopPreferences.showWeekday
|
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
|
||||||
|
|
||||||
// ── Weather ─────────────────────────────────────────────────────────────
|
// ── Weather ─────────────────────────────────────────────────────────────
|
||||||
// Coordinates taken from the GNOME night-light setting, which had already
|
// Coordinates taken from the GNOME night-light setting, which had already
|
||||||
@@ -28,16 +28,16 @@ Singleton {
|
|||||||
// label deliberately general rather than exposing precise coordinates in
|
// label deliberately general rather than exposing precise coordinates in
|
||||||
// the UI or guessing at a city from them.
|
// the UI or guessing at a city from them.
|
||||||
readonly property string weatherLocation: "Local weather"
|
readonly property string weatherLocation: "Local weather"
|
||||||
readonly property string temperatureUnit: "fahrenheit"
|
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
|
||||||
readonly property int weatherRefreshMinutes: 20
|
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
|
||||||
|
|
||||||
// ── Vitals ──────────────────────────────────────────────────────────────
|
// ── Vitals ──────────────────────────────────────────────────────────────
|
||||||
// The GNOME Vitals extension showed processor usage, memory usage and GPU
|
// The GNOME Vitals extension showed processor usage, memory usage and GPU
|
||||||
// usage, in that order. Same here.
|
// usage, in that order. Same here.
|
||||||
readonly property int vitalsIntervalMs: 2000
|
readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
|
||||||
readonly property bool showCpu: DesktopPreferences.showCpu
|
readonly property bool showCpu: DesktopPreferences.get("showCpu")
|
||||||
readonly property bool showMemory: DesktopPreferences.showMemory
|
readonly property bool showMemory: DesktopPreferences.get("showMemory")
|
||||||
readonly property bool showGpu: DesktopPreferences.showGpu
|
readonly property bool showGpu: DesktopPreferences.get("showGpu")
|
||||||
|
|
||||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||||
// widget hides itself if the path is missing rather than showing zeros.
|
// widget hides itself if the path is missing rather than showing zeros.
|
||||||
@@ -45,60 +45,43 @@ Singleton {
|
|||||||
|
|
||||||
// ── Night light ─────────────────────────────────────────────────────────
|
// ── Night light ─────────────────────────────────────────────────────────
|
||||||
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
|
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
|
||||||
readonly property int nightLightTemperature: DesktopPreferences.nightLightTemperature
|
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
|
||||||
readonly property real nightLightFrom: 17.0
|
readonly property real nightLightFrom: 17.0
|
||||||
readonly property real nightLightTo: 10.0
|
readonly property real nightLightTo: 10.0
|
||||||
readonly property bool nightLightEnabledByDefault: DesktopPreferences.nightLightEnabled
|
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
|
||||||
|
|
||||||
// ── Notifications ───────────────────────────────────────────────────────
|
// ── Notifications ───────────────────────────────────────────────────────
|
||||||
readonly property int notificationTimeoutMs: 5000
|
readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
|
||||||
readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire
|
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
||||||
readonly property int notificationHistoryLimit: 100
|
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||||
readonly property int maxVisibleToasts: 4
|
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
||||||
|
|
||||||
// ── Focus ──────────────────────────────────────────────────────────────
|
// ── Focus ──────────────────────────────────────────────────────────────
|
||||||
// One deliberate default rather than a preset picker: quick settings and
|
// One deliberate default rather than a preset picker: quick settings and
|
||||||
// the keyboard shortcut should start a useful session in one action.
|
// the keyboard shortcut should start a useful session in one action.
|
||||||
readonly property int focusDurationMinutes: DesktopPreferences.focusDurationMinutes
|
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
||||||
|
|
||||||
// ── Dock ────────────────────────────────────────────────────────────────
|
// ── Dock ────────────────────────────────────────────────────────────────
|
||||||
// Pinned apps, in order, taken from the GNOME dash favourites.
|
// Pinned apps, in order, taken from the GNOME dash favourites.
|
||||||
readonly property list<string> dockPinned: [
|
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||||
"org.gnome.Settings",
|
|
||||||
"kitty",
|
|
||||||
"org.gnome.Nautilus",
|
|
||||||
"com.bitwarden.desktop",
|
|
||||||
"org.gnome.Software",
|
|
||||||
"helium",
|
|
||||||
"org.mozilla.thunderbird_esr",
|
|
||||||
"com.slack.Slack",
|
|
||||||
"app.bluebubbles.BlueBubbles",
|
|
||||||
"rustdesk",
|
|
||||||
"io.podman_desktop.PodmanDesktop",
|
|
||||||
"claude-desktop",
|
|
||||||
"codex-desktop",
|
|
||||||
"md.obsidian.Obsidian",
|
|
||||||
"com.obsproject.Studio",
|
|
||||||
"steam"
|
|
||||||
]
|
|
||||||
|
|
||||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||||
// when any window would overlap it, and comes back on hover.
|
// when any window would overlap it, and comes back on hover.
|
||||||
readonly property bool dockAutohide: DesktopPreferences.dockAutohide
|
readonly property bool dockAutohide: DesktopPreferences.get("dockAutohide")
|
||||||
|
|
||||||
// 0: reveal the instant the pointer reaches the bottom edge. A reveal delay
|
// 0: reveal the instant the pointer reaches the bottom edge. A reveal delay
|
||||||
// is indistinguishable from lag, because the user has already committed to
|
// is indistinguishable from lag, because the user has already committed to
|
||||||
// the gesture by the time the strip is hit.
|
// the gesture by the time the strip is hit.
|
||||||
readonly property int dockRevealDelayMs: DesktopPreferences.dockRevealDelayMs
|
readonly property int dockRevealDelayMs: DesktopPreferences.get("dockRevealDelayMs")
|
||||||
|
|
||||||
// Hiding keeps a delay, so brushing past the bottom edge or crossing the
|
// Hiding keeps a delay, so brushing past the bottom edge or crossing the
|
||||||
// gap between two icons doesn't make the dock flicker.
|
// gap between two icons doesn't make the dock flicker.
|
||||||
readonly property int dockHideDelayMs: DesktopPreferences.dockHideDelayMs
|
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
|
||||||
|
|
||||||
// ── Capture ─────────────────────────────────────────────────────────────
|
// ── Capture ─────────────────────────────────────────────────────────────
|
||||||
readonly property string screenshotDir: "Pictures/Screenshots"
|
readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
|
||||||
readonly property string recordingDir: "Videos/Recordings"
|
readonly property string recordingDir: DesktopPreferences.get("recordingDir")
|
||||||
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
|
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
|
||||||
// cost CPU while gaming.
|
// cost CPU while gaming.
|
||||||
readonly property string recorderArgs: "-c h264_vaapi -d /dev/dri/renderD128"
|
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ Singleton {
|
|||||||
readonly property int popoverRadius: 18
|
readonly property int popoverRadius: 18
|
||||||
readonly property int popoverPadding: 14
|
readonly property int popoverPadding: 14
|
||||||
readonly property int popoverWidth: 380
|
readonly property int popoverWidth: 380
|
||||||
|
readonly property int controlCenterWidth: 430
|
||||||
|
readonly property int controlCenterTopGap: 2
|
||||||
|
|
||||||
readonly property int cardRadius: 12
|
readonly property int cardRadius: 12
|
||||||
readonly property int pillRadius: 999
|
readonly property int pillRadius: 999
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
module qs.config
|
module qs.config
|
||||||
singleton DesktopPreferences 1.0 DesktopPreferences.qml
|
singleton DesktopPreferences 1.0 DesktopPreferences.qml
|
||||||
|
singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
||||||
|
singleton HomePreferences 1.0 HomePreferences.qml
|
||||||
singleton Settings 1.0 Settings.qml
|
singleton Settings 1.0 Settings.qml
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.qml
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "displays-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
|
||||||
|
return JSON.stringify({
|
||||||
|
count: Displays.monitors.length,
|
||||||
|
name: monitor ? monitor.name : "",
|
||||||
|
width: monitor ? monitor.width : 0,
|
||||||
|
height: monitor ? monitor.height : 0,
|
||||||
|
refresh: monitor ? monitor.refreshRate : 0,
|
||||||
|
mode: monitor ? monitor.mode : "",
|
||||||
|
scale: monitor ? monitor.scale : 0,
|
||||||
|
transform: monitor ? monitor.transform : -1,
|
||||||
|
modes: monitor ? monitor.modes.length : 0,
|
||||||
|
awaiting: Displays.awaitingConfirmation,
|
||||||
|
canConfirm: Displays.canConfirm,
|
||||||
|
secondsLeft: Displays.secondsLeft,
|
||||||
|
lastError: Displays.lastError,
|
||||||
|
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScale(scale: real): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.mode;
|
||||||
|
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshIdentityFixture(): string {
|
||||||
|
const modes = Displays.normaliseModes([
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]"
|
||||||
|
]);
|
||||||
|
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
|
||||||
|
return JSON.stringify({
|
||||||
|
count: modes.length,
|
||||||
|
modes: modes.map(mode => mode.mode),
|
||||||
|
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBad(kind: string): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.mode;
|
||||||
|
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
|
||||||
|
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
|
||||||
|
if (kind === "dirtyScale") {
|
||||||
|
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
|
||||||
|
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
|
||||||
|
}
|
||||||
|
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
|
||||||
|
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmChange(): bool { return Displays.confirm(); }
|
||||||
|
function revertChange(): void { Displays.revert(); }
|
||||||
|
function forget(): void {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (monitor) Displays.forget(monitor.name);
|
||||||
|
}
|
||||||
|
function refresh(): void { Displays.refresh(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.modules.quicksettings
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property int confirmedValue: 30
|
||||||
|
property int commitCount: 0
|
||||||
|
property int lastCommit: -1
|
||||||
|
|
||||||
|
HomeBrightnessSlider {
|
||||||
|
id: slider
|
||||||
|
width: 200
|
||||||
|
value: root.confirmedValue
|
||||||
|
accessibleName: "Desk lamp brightness"
|
||||||
|
onCommitted: value => {
|
||||||
|
root.commitCount += 1;
|
||||||
|
root.lastCommit = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "home-brightness-slider-test"
|
||||||
|
|
||||||
|
function reset(value: int): void {
|
||||||
|
root.confirmedValue = value;
|
||||||
|
root.commitCount = 0;
|
||||||
|
root.lastCommit = -1;
|
||||||
|
slider.cancelPointerInteraction();
|
||||||
|
}
|
||||||
|
function external(value: int): void { root.confirmedValue = value; }
|
||||||
|
function press(position: int): void { slider.beginPointerInteraction(position); }
|
||||||
|
function move(position: int): void { slider.movePointerInteraction(position); }
|
||||||
|
function release(): void { slider.releasePointerInteraction(); }
|
||||||
|
function cancel(): void { slider.cancelPointerInteraction(); }
|
||||||
|
function wheel(delta: int): void { slider.commitWheel(delta); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
confirmedValue: root.confirmedValue,
|
||||||
|
previewValue: slider.previewValue,
|
||||||
|
interactionActive: slider.interactionActive,
|
||||||
|
commitCount: root.commitCount,
|
||||||
|
lastCommit: root.lastCommit,
|
||||||
|
accessibleRoleIsSlider: slider.Accessible.role === Accessible.Slider,
|
||||||
|
accessibleName: slider.Accessible.name,
|
||||||
|
accessibleDescription: slider.Accessible.description,
|
||||||
|
accessibleFocusable: slider.Accessible.focusable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "home-pref-test"
|
||||||
|
|
||||||
|
function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
|
||||||
|
function add(id: string): void { HomePreferences.add(id); }
|
||||||
|
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
|
||||||
|
function move(id: string, index: int): void { HomePreferences.move(id, index); }
|
||||||
|
function remove(id: string): void { HomePreferences.remove(id); }
|
||||||
|
function reset(): void { HomePreferences.resetHomeDefaults(); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
initialized: HomePreferences.initialized,
|
||||||
|
favorites: HomePreferences.favorites,
|
||||||
|
saveError: HomePreferences.saveError,
|
||||||
|
stateDir: Quickshell.stateDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "keybinds-test"
|
||||||
|
|
||||||
|
function rebind(current: string, next: string): bool {
|
||||||
|
return Keybinds.rebind(current, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideState(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
overrides: Keybinds.overrides,
|
||||||
|
count: Object.keys(Keybinds.overrides).length,
|
||||||
|
lastError: Keybinds.lastError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
const grouped = Keybinds.grouped();
|
||||||
|
let groupedCount = 0;
|
||||||
|
for (const group of grouped)
|
||||||
|
groupedCount += group.binds.length;
|
||||||
|
|
||||||
|
const superT = Keybinds.binds.find(bind => bind.description === "Terminal");
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
loaded: Keybinds.loaded,
|
||||||
|
count: Keybinds.binds.length,
|
||||||
|
groupedCount: groupedCount,
|
||||||
|
groups: grouped.map(group => group.name),
|
||||||
|
sample: superT ? superT.chord : "",
|
||||||
|
emptyDescriptions: Keybinds.binds.filter(bind => !bind.description).length,
|
||||||
|
lastError: Keybinds.lastError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import Quickshell.Networking
|
|||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Pill {
|
Pill {
|
||||||
@@ -106,4 +107,10 @@ Pill {
|
|||||||
glyph: "\u{F0176}" // md-coffee
|
glyph: "\u{F0176}" // md-coffee
|
||||||
color: Theme.warn
|
color: Theme.warn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StatusGlyph {
|
||||||
|
visible: KdeConnect.phoneReachable
|
||||||
|
glyph: "\u{F03F2}" // md-cellphone-link
|
||||||
|
color: Theme.cyan
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property string action: ""
|
||||||
|
property bool actionEnabled: true
|
||||||
|
signal actionTriggered
|
||||||
|
|
||||||
|
implicitHeight: 24
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.label.toUpperCase()
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
font.letterSpacing: 0.9
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: actionLabel
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: root.action !== ""
|
||||||
|
text: root.action
|
||||||
|
color: root.actionEnabled
|
||||||
|
? (actionMouse.containsMouse ? Theme.accentAlt : Theme.accent)
|
||||||
|
: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.Medium
|
||||||
|
|
||||||
|
Behavior on color { ColorAnimation { duration: Theme.durFast } }
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: actionMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -6
|
||||||
|
enabled: root.actionEnabled
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: root.actionTriggered()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// A light dimmer that previews locally and sends one value when interaction
|
||||||
|
// ends. Home Assistant never sees the intermediate pointer positions.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property int value: 0
|
||||||
|
property int previewValue: 0
|
||||||
|
property bool interactionActive: false
|
||||||
|
property string accessibleName: "Brightness"
|
||||||
|
|
||||||
|
readonly property bool pressed: root.interactionActive
|
||||||
|
|
||||||
|
signal previewChanged(int value)
|
||||||
|
signal committed(int value)
|
||||||
|
|
||||||
|
implicitWidth: 160
|
||||||
|
implicitHeight: 32
|
||||||
|
activeFocusOnTab: root.enabled
|
||||||
|
opacity: root.enabled ? 1 : 0.42
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Slider
|
||||||
|
Accessible.name: root.accessibleName
|
||||||
|
// Qt 6.11's installed Accessible attached type has no structured value or
|
||||||
|
// range properties, so expose both in the supported live description.
|
||||||
|
Accessible.description: root.previewValue + " percent, range 0 to 100"
|
||||||
|
Accessible.focusable: root.enabled
|
||||||
|
Accessible.focused: root.activeFocus
|
||||||
|
Accessible.onIncreaseAction: root.commitStep(5)
|
||||||
|
Accessible.onDecreaseAction: root.commitStep(-5)
|
||||||
|
|
||||||
|
onValueChanged: {
|
||||||
|
if (!root.interactionActive)
|
||||||
|
root.updatePreview(root.value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.updatePreview(root.value, false)
|
||||||
|
|
||||||
|
Keys.onLeftPressed: root.commitStep(-5)
|
||||||
|
Keys.onDownPressed: root.commitStep(-5)
|
||||||
|
Keys.onRightPressed: root.commitStep(5)
|
||||||
|
Keys.onUpPressed: root.commitStep(5)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: track
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
height: 10
|
||||||
|
radius: 5
|
||||||
|
color: Theme.alpha(Theme.fg, 0.105)
|
||||||
|
border.width: root.activeFocus ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.72)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: fill
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: track.width * root.previewValue / 100
|
||||||
|
radius: track.radius
|
||||||
|
color: Theme.warn
|
||||||
|
|
||||||
|
Behavior on width {
|
||||||
|
enabled: !root.interactionActive
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.durFast
|
||||||
|
easing.type: Easing.OutQuad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: knob
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: Math.max(0, Math.min(track.width - width,
|
||||||
|
track.width * root.previewValue / 100 - width / 2))
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
radius: 8
|
||||||
|
color: Theme.fg
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.bgDark, 0.38)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 32 px interaction surface is intentionally much taller than the
|
||||||
|
// 10 px track, while remaining inside this component's layout bounds.
|
||||||
|
MouseArea {
|
||||||
|
id: drag
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: root.enabled
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
|
||||||
|
onPressed: event => {
|
||||||
|
root.forceActiveFocus();
|
||||||
|
root.beginPointerInteraction(event.x);
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
onPositionChanged: event => root.movePointerInteraction(event.x)
|
||||||
|
onReleased: event => {
|
||||||
|
root.releasePointerInteraction();
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
onCanceled: root.cancelPointerInteraction()
|
||||||
|
onWheel: event => {
|
||||||
|
root.commitWheel(event.angleDelta.y);
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(candidate: real): int {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(candidate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview(candidate: real, announce: bool): void {
|
||||||
|
const nextValue = root.clamp(candidate);
|
||||||
|
if (root.previewValue === nextValue)
|
||||||
|
return;
|
||||||
|
root.previewValue = nextValue;
|
||||||
|
if (announce)
|
||||||
|
root.previewChanged(nextValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAt(pointerX: real): void {
|
||||||
|
root.updatePreview(pointerX / Math.max(1, drag.width) * 100, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginPointerInteraction(pointerX: real): void {
|
||||||
|
if (!root.enabled)
|
||||||
|
return;
|
||||||
|
root.interactionActive = true;
|
||||||
|
root.previewAt(pointerX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePointerInteraction(pointerX: real): void {
|
||||||
|
if (root.interactionActive)
|
||||||
|
root.previewAt(pointerX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePointerInteraction(): void {
|
||||||
|
if (!root.interactionActive)
|
||||||
|
return;
|
||||||
|
root.interactionActive = false;
|
||||||
|
root.committed(root.previewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPointerInteraction(): void {
|
||||||
|
root.interactionActive = false;
|
||||||
|
root.updatePreview(root.value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitWheel(delta: int): void {
|
||||||
|
if (delta === 0)
|
||||||
|
return;
|
||||||
|
root.commitStep(delta > 0 ? 5 : -5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitStep(delta: int): void {
|
||||||
|
if (!root.enabled)
|
||||||
|
return;
|
||||||
|
root.updatePreview(root.previewValue + delta, true);
|
||||||
|
root.committed(root.previewValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool expanded: false
|
||||||
|
signal toggleExpanded
|
||||||
|
|
||||||
|
readonly property bool hasSelection: HomeAssistant.selectedEntities.length > 0
|
||||||
|
readonly property bool showSetup: HomeAssistant.phase === "ready" && !root.hasSelection
|
||||||
|
|
||||||
|
implicitHeight: content.implicitHeight
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
ControlSectionHeader {
|
||||||
|
width: parent.width
|
||||||
|
label: "Home"
|
||||||
|
action: root.headerAction()
|
||||||
|
actionEnabled: HomeAssistant.phase !== "loading"
|
||||||
|
onActionTriggered: {
|
||||||
|
if (root.hasSelection) {
|
||||||
|
root.toggleExpanded();
|
||||||
|
} else if (root.showSetup) {
|
||||||
|
root.openHomeSettings();
|
||||||
|
} else {
|
||||||
|
HomeAssistant.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: restingCard
|
||||||
|
width: parent.width
|
||||||
|
height: visible ? restingGrid.implicitHeight + 20 : 0
|
||||||
|
visible: root.hasSelection && !root.expanded
|
||||||
|
radius: Theme.cardRadius + 2
|
||||||
|
color: Theme.alpha(Theme.warn, 0.028)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.095)
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: restingGrid
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 10
|
||||||
|
columns: 2
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: HomeAssistant.visibleEntities
|
||||||
|
|
||||||
|
HomeTile {
|
||||||
|
required property var modelData
|
||||||
|
width: (restingGrid.width - restingGrid.spacing) / 2
|
||||||
|
entity: modelData
|
||||||
|
busy: HomeAssistant.isBusy(modelData.id)
|
||||||
|
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
|
||||||
|
actionError: HomeAssistant.errorFor(modelData.id)
|
||||||
|
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
|
||||||
|
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: visible ? 72 : 0
|
||||||
|
visible: !root.hasSelection
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: root.showSetup
|
||||||
|
? Theme.alpha(Theme.accent, 0.055)
|
||||||
|
: Theme.alpha(Theme.fg, 0.045)
|
||||||
|
border.width: 1
|
||||||
|
border.color: root.showSetup
|
||||||
|
? Theme.alpha(Theme.accent, 0.12)
|
||||||
|
: Theme.alpha(Theme.fg, 0.055)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: stateIcon
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 13
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.showSetup ? "\u{F0335}" : "\u{F02DC}"
|
||||||
|
color: root.showSetup ? Theme.accent : Theme.fgDim
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 17
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: stateIcon.right
|
||||||
|
anchors.leftMargin: 11
|
||||||
|
anchors.right: emptyAction.left
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.emptyTitle()
|
||||||
|
color: Theme.fg
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.emptyDetail()
|
||||||
|
color: Theme.fgDim
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: emptyAction
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: root.showSetup ? 68 : 52
|
||||||
|
height: 30
|
||||||
|
radius: 10
|
||||||
|
visible: HomeAssistant.phase !== "loading"
|
||||||
|
color: emptyActionMouse.containsMouse
|
||||||
|
? Theme.alpha(Theme.accent, 0.20)
|
||||||
|
: Theme.alpha(Theme.accent, 0.105)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.showSetup ? "Manage" : "Open"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: emptyActionMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
if (root.showSetup)
|
||||||
|
root.openHomeSettings();
|
||||||
|
else
|
||||||
|
HomeAssistant.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: visible ? 34 : 0
|
||||||
|
visible: HomeAssistant.stale
|
||||||
|
radius: 10
|
||||||
|
color: Theme.alpha(Theme.warn, 0.075)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Last known state · " + root.countSummary()
|
||||||
|
color: Theme.warn
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Retry"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -6
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: HomeAssistant.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
width: parent.width
|
||||||
|
expanded: root.expanded && root.hasSelection
|
||||||
|
|
||||||
|
ScrollColumn {
|
||||||
|
width: parent.width
|
||||||
|
maxHeight: 324
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: expandedGrid
|
||||||
|
width: parent.width
|
||||||
|
columns: 2
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: HomeAssistant.selectedEntities
|
||||||
|
|
||||||
|
HomeTile {
|
||||||
|
required property var modelData
|
||||||
|
width: (expandedGrid.width - expandedGrid.spacing) / 2
|
||||||
|
entity: modelData
|
||||||
|
busy: HomeAssistant.isBusy(modelData.id)
|
||||||
|
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
|
||||||
|
actionError: HomeAssistant.errorFor(modelData.id)
|
||||||
|
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
|
||||||
|
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowButton {
|
||||||
|
width: parent.width
|
||||||
|
icon: "preferences-system-symbolic"
|
||||||
|
label: "Manage in Settings"
|
||||||
|
sublabel: root.countSummary()
|
||||||
|
onClicked: root.openHomeSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerAction(): string {
|
||||||
|
if (root.hasSelection) {
|
||||||
|
const noun = HomeAssistant.configuredCount === 1 ? "accessory" : "accessories";
|
||||||
|
return HomeAssistant.configuredCount + " " + noun + (root.expanded ? " ⌃" : " ›");
|
||||||
|
}
|
||||||
|
if (HomeAssistant.phase === "loading")
|
||||||
|
return "Loading";
|
||||||
|
if (root.showSetup)
|
||||||
|
return "Manage";
|
||||||
|
return "Retry";
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSummary(): string {
|
||||||
|
return HomeAssistant.configuredCount + " selected · "
|
||||||
|
+ HomeAssistant.discoveredCount + " discovered";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTitle(): string {
|
||||||
|
if (root.showSetup)
|
||||||
|
return "Choose your accessories";
|
||||||
|
if (HomeAssistant.phase === "loading")
|
||||||
|
return "Loading your home";
|
||||||
|
if (HomeAssistant.lastError === "authentication-required")
|
||||||
|
return "Authentication required";
|
||||||
|
if (HomeAssistant.lastError === "not-configured")
|
||||||
|
return "Home Assistant is not configured";
|
||||||
|
return "Home Assistant is unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDetail(): string {
|
||||||
|
if (root.showSetup) {
|
||||||
|
if (HomeAssistant.discoveredCount === 0)
|
||||||
|
return "No lights discovered yet";
|
||||||
|
const noun = HomeAssistant.discoveredCount === 1 ? "light" : "lights";
|
||||||
|
return HomeAssistant.discoveredCount + " " + noun + " ready to add";
|
||||||
|
}
|
||||||
|
if (HomeAssistant.phase === "loading")
|
||||||
|
return "Finding your selected lights";
|
||||||
|
if (HomeAssistant.lastError === "authentication-required")
|
||||||
|
return "Update the long-lived access token";
|
||||||
|
if (HomeAssistant.lastError === "not-configured")
|
||||||
|
return "Connect Home Assistant in Settings";
|
||||||
|
return "Check the connection, then retry";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openHomeSettings(): void {
|
||||||
|
ShellState.close();
|
||||||
|
ShellState.openSettings("home-phone");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var entity
|
||||||
|
property bool busy: false
|
||||||
|
property int pendingBrightness: -1
|
||||||
|
property string actionError: ""
|
||||||
|
|
||||||
|
signal powerRequested
|
||||||
|
signal brightnessRequested(int value)
|
||||||
|
|
||||||
|
readonly property int confirmedBrightness: root.clamp(root.entity.brightnessPct ?? 0)
|
||||||
|
readonly property int displayedBrightness: brightnessSlider.previewValue
|
||||||
|
readonly property bool powerEnabled: root.entity.available && !root.busy
|
||||||
|
readonly property bool dimmerEnabled: root.entity.available
|
||||||
|
&& root.entity.dimmable
|
||||||
|
&& !root.busy
|
||||||
|
|
||||||
|
implicitHeight: 124
|
||||||
|
radius: 14
|
||||||
|
color: {
|
||||||
|
if (root.entity.active)
|
||||||
|
return Theme.alpha(Theme.warn, powerArea.containsMouse ? 0.145 : 0.095);
|
||||||
|
return Theme.alpha(Theme.fg, powerArea.containsMouse ? 0.078 : 0.045);
|
||||||
|
}
|
||||||
|
border.width: 1
|
||||||
|
border.color: {
|
||||||
|
if (root.activeFocus)
|
||||||
|
return Theme.alpha(Theme.accent, 0.72);
|
||||||
|
if (root.entity.active)
|
||||||
|
return Theme.alpha(Theme.warn, 0.17);
|
||||||
|
return Theme.alpha(Theme.fg, 0.065);
|
||||||
|
}
|
||||||
|
activeFocusOnTab: root.powerEnabled
|
||||||
|
|
||||||
|
Behavior on color { ColorAnimation { duration: Theme.durFast } }
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durFast } }
|
||||||
|
|
||||||
|
Keys.onSpacePressed: {
|
||||||
|
if (root.powerEnabled)
|
||||||
|
root.powerRequested();
|
||||||
|
}
|
||||||
|
Keys.onReturnPressed: {
|
||||||
|
if (root.powerEnabled)
|
||||||
|
root.powerRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: bulb
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 13
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 13
|
||||||
|
width: 30
|
||||||
|
height: 30
|
||||||
|
radius: 10
|
||||||
|
color: root.entity.active
|
||||||
|
? Theme.alpha(Theme.warn, 0.16)
|
||||||
|
: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.busy ? "\u{F0772}" : "\u{F0335}"
|
||||||
|
color: root.entity.active ? Theme.warn : Theme.fgDim
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 13
|
||||||
|
anchors.verticalCenter: bulb.verticalCenter
|
||||||
|
text: root.entity.dimmable ? root.displayedBrightness + "%" : "—"
|
||||||
|
color: root.entity.active || root.pendingBrightness >= 0
|
||||||
|
? Theme.warn
|
||||||
|
: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: aliasLabel
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 13
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 13
|
||||||
|
anchors.top: bulb.bottom
|
||||||
|
anchors.topMargin: 8
|
||||||
|
text: root.entity.name
|
||||||
|
color: Theme.fg
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: aliasLabel.left
|
||||||
|
anchors.right: aliasLabel.right
|
||||||
|
anchors.top: aliasLabel.bottom
|
||||||
|
anchors.topMargin: 2
|
||||||
|
text: root.secondaryText()
|
||||||
|
color: root.actionError !== ""
|
||||||
|
? Theme.warn
|
||||||
|
: (root.entity.active ? Theme.warn : Theme.fgMuted)
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: powerArea
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: brightnessSlider.top
|
||||||
|
anchors.bottomMargin: 1
|
||||||
|
enabled: root.powerEnabled
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onPressed: root.forceActiveFocus()
|
||||||
|
onClicked: root.powerRequested()
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeBrightnessSlider {
|
||||||
|
id: brightnessSlider
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 13
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 13
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 5
|
||||||
|
value: root.pendingBrightness >= 0
|
||||||
|
? root.pendingBrightness
|
||||||
|
: root.confirmedBrightness
|
||||||
|
enabled: root.dimmerEnabled
|
||||||
|
accessibleName: root.entity.name + " brightness"
|
||||||
|
onCommitted: value => root.brightnessRequested(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(candidate: real): int {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(candidate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondaryText(): string {
|
||||||
|
if (root.actionError !== "")
|
||||||
|
return root.errorText(root.actionError);
|
||||||
|
if (!root.entity.available)
|
||||||
|
return "Unavailable";
|
||||||
|
if (root.busy && root.pendingBrightness >= 0)
|
||||||
|
return "Setting " + root.pendingBrightness + "%";
|
||||||
|
if (root.busy)
|
||||||
|
return "Updating";
|
||||||
|
return root.entity.active ? "On" : "Off";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(code: string): string {
|
||||||
|
if (code === "authentication-required")
|
||||||
|
return "Authentication required";
|
||||||
|
if (code === "entity-not-discovered")
|
||||||
|
return "Light is unavailable";
|
||||||
|
return "Couldn’t update light";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
// Nonvisual owner of Phone Controls' stable action model and enablement rules.
|
||||||
|
// Keeping this logic free of delegates and icons makes it safe to exercise in
|
||||||
|
// disposable diagnostic engines.
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property var actionModels: [
|
||||||
|
{ id: "share", glyph: "\u{F0142}", label: "Send file" },
|
||||||
|
{ id: "clipboard", glyph: "\u{F014C}", label: "Clipboard" },
|
||||||
|
{ id: "ring", glyph: "\u{F009A}", label: "Ring" },
|
||||||
|
{ id: "messages", glyph: "\u{F0365}", label: "Messages" }
|
||||||
|
]
|
||||||
|
|
||||||
|
function actionEnabled(action: string): bool {
|
||||||
|
if (action === "messages")
|
||||||
|
return SystemSettings.bluebubblesAvailable;
|
||||||
|
return KdeConnect.phoneReachable
|
||||||
|
&& !KdeConnect.transferActive
|
||||||
|
&& KdeConnect.supports(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool expanded: false
|
||||||
|
signal toggleExpanded
|
||||||
|
|
||||||
|
readonly property var phone: KdeConnect.preferredPhone
|
||||||
|
readonly property var actionModels: phoneActions.actionModels
|
||||||
|
|
||||||
|
PhoneActions {
|
||||||
|
id: phoneActions
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitHeight: content.implicitHeight
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
ControlSectionHeader {
|
||||||
|
width: parent.width
|
||||||
|
label: "Phone"
|
||||||
|
action: root.phone ? (root.expanded ? "Details ⌃" : "Details ›") : "Refresh"
|
||||||
|
onActionTriggered: {
|
||||||
|
if (root.phone)
|
||||||
|
root.toggleExpanded();
|
||||||
|
else
|
||||||
|
KdeConnect.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: phoneContent.implicitHeight + 22
|
||||||
|
radius: Theme.cardRadius + 2
|
||||||
|
color: Theme.alpha(Theme.fg, 0.055)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.05)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: phoneContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 11
|
||||||
|
spacing: 9
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: 46
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: phoneSilhouette
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 29
|
||||||
|
height: 43
|
||||||
|
radius: 8
|
||||||
|
color: Theme.alpha(Theme.accent, 0.07)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.42)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 4
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
width: 10
|
||||||
|
height: 2
|
||||||
|
radius: 1
|
||||||
|
color: Theme.alpha(Theme.fg, 0.35)
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: -3
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 10
|
||||||
|
width: 6
|
||||||
|
height: 6
|
||||||
|
radius: 3
|
||||||
|
visible: KdeConnect.phoneReachable
|
||||||
|
color: Theme.ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: phoneSilhouette.right
|
||||||
|
anchors.leftMargin: 11
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.phone?.name ?? (KdeConnect.available ? "No paired phone" : "KDE Connect unavailable")
|
||||||
|
color: Theme.fg
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.phone
|
||||||
|
? (KdeConnect.phoneReachable ? "● Nearby on Wi-Fi" : "Not nearby")
|
||||||
|
: (KdeConnect.available ? "Pair a device to continue" : "Open settings to repair the service")
|
||||||
|
color: KdeConnect.phoneReachable ? Theme.ok : Theme.fgDim
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: actionsGrid
|
||||||
|
width: parent.width
|
||||||
|
columns: 4
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.actionModels
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: actionButton
|
||||||
|
required property var modelData
|
||||||
|
width: (actionsGrid.width - actionsGrid.spacing * Math.max(0, root.actionModels.length - 1)) / Math.max(1, root.actionModels.length)
|
||||||
|
height: 48
|
||||||
|
radius: 11
|
||||||
|
color: actionMouse.containsMouse && actionMouse.enabled
|
||||||
|
? Theme.alpha(Theme.accent, 0.15)
|
||||||
|
: Theme.alpha(Theme.accent, 0.075)
|
||||||
|
border.width: actionMouse.activeFocus ? 2 : 0
|
||||||
|
border.color: Theme.accent
|
||||||
|
opacity: actionMouse.enabled ? 1 : 0.48
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: actionButton.modelData.label
|
||||||
|
Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)
|
||||||
|
Accessible.focusable: actionMouse.enabled
|
||||||
|
Accessible.focused: actionMouse.activeFocus
|
||||||
|
Accessible.onPressAction: {
|
||||||
|
if (actionMouse.enabled)
|
||||||
|
root.invoke(actionButton.modelData.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 7
|
||||||
|
text: actionButton.modelData.glyph
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 6
|
||||||
|
text: actionButton.modelData.label
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: actionMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: root.actionEnabled(actionButton.modelData.id)
|
||||||
|
hoverEnabled: true
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: root.invoke(actionButton.modelData.id)
|
||||||
|
Keys.onReturnPressed: root.invoke(actionButton.modelData.id)
|
||||||
|
Keys.onSpacePressed: root.invoke(actionButton.modelData.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0
|
||||||
|
text: "KDE Connect actions are unavailable until the iPhone reconnects"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: !SystemSettings.bluebubblesAvailable
|
||||||
|
text: "BlueBubbles is not installed"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: KdeConnect.lastError !== ""
|
||||||
|
text: root.errorText(KdeConnect.lastError)
|
||||||
|
color: Theme.warn
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
visible: KdeConnect.recentExchange !== null
|
||||||
|
color: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecentExchange {
|
||||||
|
width: parent.width
|
||||||
|
height: visible ? implicitHeight : 0
|
||||||
|
visible: KdeConnect.recentExchange !== null
|
||||||
|
exchange: KdeConnect.recentExchange ?? ({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
width: parent.width
|
||||||
|
expanded: root.expanded && root.phone !== null
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
RowButton {
|
||||||
|
width: parent.width
|
||||||
|
icon: "network-wireless-symbolic"
|
||||||
|
label: "Connection"
|
||||||
|
sublabel: KdeConnect.phoneReachable ? "Nearby on Wi-Fi" : "Waiting for the phone"
|
||||||
|
selected: KdeConnect.phoneReachable
|
||||||
|
onClicked: KdeConnect.refresh()
|
||||||
|
}
|
||||||
|
RowButton {
|
||||||
|
width: parent.width
|
||||||
|
icon: "preferences-system-symbolic"
|
||||||
|
label: "Device settings"
|
||||||
|
sublabel: "Pairing, plugins and service health"
|
||||||
|
onClicked: {
|
||||||
|
ShellState.close();
|
||||||
|
ShellState.openSettings("connectivity");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: fileDialog
|
||||||
|
title: root.phone ? "Send a file to " + root.phone.name : "Send a file"
|
||||||
|
fileMode: FileDialog.OpenFile
|
||||||
|
nameFilters: ["All files (*)"]
|
||||||
|
onAccepted: {
|
||||||
|
const path = root.localPath(selectedFile);
|
||||||
|
if (path !== "")
|
||||||
|
KdeConnect.sendFile(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoke(action: string): void {
|
||||||
|
if (action === "share")
|
||||||
|
fileDialog.open();
|
||||||
|
else if (action === "clipboard")
|
||||||
|
KdeConnect.sendClipboard();
|
||||||
|
else if (action === "ring")
|
||||||
|
KdeConnect.ring();
|
||||||
|
else if (action === "messages") {
|
||||||
|
if (SystemSettings.bluebubblesAvailable && SystemSettings.openApplication("bluebubbles"))
|
||||||
|
ShellState.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionEnabled(action: string): bool {
|
||||||
|
return phoneActions.actionEnabled(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionAccessibleDescription(action: string): string {
|
||||||
|
if (action === "messages")
|
||||||
|
return SystemSettings.bluebubblesAvailable ? "Opens BlueBubbles" : "BlueBubbles is not installed";
|
||||||
|
return root.actionEnabled(action) ? "Available through KDE Connect" : "Unavailable through KDE Connect";
|
||||||
|
}
|
||||||
|
|
||||||
|
function localPath(selectedUrl: url): string {
|
||||||
|
const value = String(selectedUrl);
|
||||||
|
if (!value.startsWith("file://"))
|
||||||
|
return "";
|
||||||
|
return decodeURIComponent(value.slice(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(code: string): string {
|
||||||
|
if (code === "device-offline")
|
||||||
|
return "The iPhone is not nearby";
|
||||||
|
if (code === "unsupported-action")
|
||||||
|
return "That action is not available on this iPhone";
|
||||||
|
return "The phone action did not finish";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,15 +18,21 @@ PanelWindow {
|
|||||||
visible: ShellState.quickSettingsOpen
|
visible: ShellState.quickSettingsOpen
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
// Hangs off the top-right corner, clear of the floating bar.
|
// Hangs directly beneath the top-right status cluster. Two logical pixels
|
||||||
|
// preserve the Prism edge without making the surface feel detached.
|
||||||
anchors.top: true
|
anchors.top: true
|
||||||
anchors.right: true
|
anchors.right: true
|
||||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
margins.top: Theme.barHeight + Theme.controlCenterTopGap
|
||||||
margins.right: Theme.barSideMargin
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
|
|
||||||
implicitWidth: Theme.popoverWidth
|
implicitWidth: Theme.controlCenterWidth
|
||||||
implicitHeight: panel.implicitHeight
|
implicitHeight: panel.implicitHeight
|
||||||
|
readonly property string expandedSection: panel.expandedSection
|
||||||
|
|
||||||
|
function expand(name: string): void {
|
||||||
|
panel.expand(name);
|
||||||
|
}
|
||||||
|
|
||||||
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
|
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
|
||||||
WlrLayershell.namespace: "qs-popover-quicksettings"
|
WlrLayershell.namespace: "qs-popover-quicksettings"
|
||||||
@@ -36,11 +42,14 @@ PanelWindow {
|
|||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||||
|
|
||||||
onVisibleChanged: {
|
onVisibleChanged: {
|
||||||
if (root.visible)
|
if (root.visible) {
|
||||||
|
KdeConnect.refresh();
|
||||||
|
HomeAssistant.refresh();
|
||||||
openAnim.restart();
|
openAnim.restart();
|
||||||
else
|
} else {
|
||||||
panel.reset();
|
panel.reset();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Closes the panel when a click lands anywhere else on the desktop.
|
// Closes the panel when a click lands anywhere else on the desktop.
|
||||||
HyprlandFocusGrab {
|
HyprlandFocusGrab {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import qs.widgets
|
|||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
implicitWidth: Theme.popoverWidth
|
implicitWidth: Theme.controlCenterWidth
|
||||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||||
|
|
||||||
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
||||||
@@ -233,6 +233,19 @@ Item {
|
|||||||
width: content.width
|
width: content.width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Connected life ─────────────────────────────────────────────────
|
||||||
|
HomeControls {
|
||||||
|
width: content.width
|
||||||
|
expanded: root.expandedSection === "home"
|
||||||
|
onToggleExpanded: root.expand("home")
|
||||||
|
}
|
||||||
|
|
||||||
|
PhoneControls {
|
||||||
|
width: content.width
|
||||||
|
expanded: root.expandedSection === "phone"
|
||||||
|
onToggleExpanded: root.expand("phone")
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: content.width
|
width: content.width
|
||||||
height: 1
|
height: 1
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var exchange
|
||||||
|
|
||||||
|
implicitHeight: 40
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 24
|
||||||
|
height: 24
|
||||||
|
radius: 8
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: Theme.alpha(Theme.ok, 0.10)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "\u{F012C}"
|
||||||
|
color: Theme.ok
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 34
|
||||||
|
anchors.right: timeLabel.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.exchange.fileName || "File"
|
||||||
|
color: Theme.fg
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Sent to " + (root.exchange.deviceName || "phone")
|
||||||
|
color: Theme.fgMuted
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: timeLabel
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Now"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 9
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module qs.modules.quicksettings
|
||||||
|
AudioDeviceList 1.0 AudioDeviceList.qml
|
||||||
|
AudioSlider 1.0 AudioSlider.qml
|
||||||
|
BluetoothList 1.0 BluetoothList.qml
|
||||||
|
BrightnessControl 1.0 BrightnessControl.qml
|
||||||
|
ControlSectionHeader 1.0 ControlSectionHeader.qml
|
||||||
|
HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml
|
||||||
|
HomeControls 1.0 HomeControls.qml
|
||||||
|
HomeTile 1.0 HomeTile.qml
|
||||||
|
IconButton 1.0 IconButton.qml
|
||||||
|
PhoneActions 1.0 PhoneActions.qml
|
||||||
|
PhoneControls 1.0 PhoneControls.qml
|
||||||
|
QuickSettings 1.0 QuickSettings.qml
|
||||||
|
QuickSettingsPanel 1.0 QuickSettingsPanel.qml
|
||||||
|
RecentExchange 1.0 RecentExchange.qml
|
||||||
|
RowButton 1.0 RowButton.qml
|
||||||
|
ScrollColumn 1.0 ScrollColumn.qml
|
||||||
|
Section 1.0 Section.qml
|
||||||
|
WifiList 1.0 WifiList.qml
|
||||||
@@ -3,39 +3,49 @@ import Quickshell
|
|||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
Flickable {
|
title: "About Panama"
|
||||||
anchors.fill: parent
|
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
||||||
clip: true
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
|
||||||
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 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Panama Desktop"
|
title: "Panama Desktop"
|
||||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
||||||
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
|
|
||||||
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
|
TextRow {
|
||||||
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
|
label: "Hyprland"
|
||||||
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
|
value: SystemSettings.hyprlandVersion || "Detecting…"
|
||||||
|
}
|
||||||
|
TextRow {
|
||||||
|
label: "Quickshell"
|
||||||
|
value: SystemSettings.quickshellVersion
|
||||||
|
}
|
||||||
|
TextRow {
|
||||||
|
label: "Display"
|
||||||
|
value: SystemSettings.monitorName || "Detecting…"
|
||||||
|
}
|
||||||
|
TextRow {
|
||||||
|
label: "Configuration"
|
||||||
|
detail: Quickshell.shellDir
|
||||||
|
value: "Local"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Design principles"
|
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" }
|
TextRow {
|
||||||
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false }
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Accessibility.
|
||||||
|
//
|
||||||
|
// Pointer size and text scale have to agree across three consumers that share
|
||||||
|
// no configuration system -- the compositor, GTK applications, and the shell.
|
||||||
|
// Panama's store is the source of truth and services/Accessibility.qml pushes
|
||||||
|
// the value to the other two.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Accessibility"
|
||||||
|
lede: "Make the desktop easier to see and easier to hit."
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pointer"
|
||||||
|
subtitle: "Applied to the compositor and to applications at the same time."
|
||||||
|
|
||||||
|
SliderRow { setting: "cursorSize" }
|
||||||
|
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Text"
|
||||||
|
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected."
|
||||||
|
|
||||||
|
SliderRow { setting: "textScale"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Motion"
|
||||||
|
subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
|
||||||
|
|
||||||
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Contrast"
|
||||||
|
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read."
|
||||||
|
|
||||||
|
SliderRow { setting: "inactiveOpacity"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "System accessibility"
|
||||||
|
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "GNOME accessibility settings"
|
||||||
|
detail: "Opens in GNOME Settings"
|
||||||
|
action: "Open"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("universal-access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Accessibility.lastError !== ""
|
||||||
|
title: "Could not apply"
|
||||||
|
subtitle: Accessibility.lastError
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// A row whose trailing control is a button rather than a setting.
|
||||||
|
//
|
||||||
|
// ActionRow {
|
||||||
|
// label: "Keyboard"
|
||||||
|
// detail: "Use Fedora's hardware-backed input panels"
|
||||||
|
// action: "Open keyboard"
|
||||||
|
// onTriggered: SystemSettings.openGnomePanel("keyboard")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Not schema-bound: these hand off to GNOME, launch an application, or run a
|
||||||
|
// one-shot like restoring defaults. There is no key to name.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string action: ""
|
||||||
|
property bool enabled: true
|
||||||
|
|
||||||
|
signal triggered
|
||||||
|
|
||||||
|
controlWidth: Math.max(110, button.implicitWidth + 8)
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: button
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.action
|
||||||
|
enabled: root.enabled
|
||||||
|
onClicked: root.triggered()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +1,116 @@
|
|||||||
|
// Appearance — the page the rest of the settings vocabulary was built for.
|
||||||
|
//
|
||||||
|
// Before Stage 3 this page adjusted a clock format and three vitals toggles,
|
||||||
|
// and its "Theme" card was two rows of text pretending to be controls. It now
|
||||||
|
// drives the compositor directly: every row here is applied through
|
||||||
|
// `hyprctl eval` and confirmed by reading the value back before it is stored.
|
||||||
|
//
|
||||||
|
// The preview is pinned above the controls rather than sitting inline, because
|
||||||
|
// the numbers are meaningless on their own -- "outer gaps 24" only means
|
||||||
|
// something once you have watched the windows move apart by that much, at the
|
||||||
|
// scale of the display you actually use.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
Flickable {
|
id: root
|
||||||
anchors.fill: parent
|
|
||||||
clip: true
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
|
title: "Appearance"
|
||||||
|
lede: "Drag anything below. The preview above is your real geometry, to scale."
|
||||||
|
|
||||||
|
header: Component {
|
||||||
Column {
|
Column {
|
||||||
id: content
|
spacing: 9
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
DesktopPreview {
|
||||||
Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: SystemSettings.lastError !== ""
|
||||||
|
? SystemSettings.lastError
|
||||||
|
: "Live preview — the focused window wears the prism border"
|
||||||
|
color: SystemSettings.lastError !== "" ? Theme.warn : Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Theme"
|
title: "Background"
|
||||||
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
|
subtitle: Wallpaper.lastError !== ""
|
||||||
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
? Wallpaper.lastError
|
||||||
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||||
|
|
||||||
|
WallpaperPicker {
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Look for new images"
|
||||||
|
detail: Wallpaper.scanning
|
||||||
|
? "Scanning…"
|
||||||
|
: Wallpaper.available.length + " image" + (Wallpaper.available.length === 1 ? "" : "s") + " found"
|
||||||
|
action: "Rescan"
|
||||||
|
divider: false
|
||||||
|
enabled: !Wallpaper.scanning
|
||||||
|
onTriggered: Wallpaper.rescan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Windows"
|
||||||
|
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||||
|
|
||||||
|
SliderRow { setting: "windowRounding" }
|
||||||
|
SliderRow { setting: "gapsIn" }
|
||||||
|
SliderRow { setting: "gapsOut" }
|
||||||
|
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
||||||
|
SliderRow { setting: "inactiveOpacity"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Effects"
|
||||||
|
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
|
||||||
|
|
||||||
|
ToggleRow { setting: "blurEnabled" }
|
||||||
|
SliderRow { setting: "blurSize" }
|
||||||
|
SliderRow { setting: "blurPasses" }
|
||||||
|
ToggleRow { setting: "shadowEnabled" }
|
||||||
|
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
||||||
|
ToggleRow { setting: "glowEnabled" }
|
||||||
|
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
||||||
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Clock"
|
title: "Clock"
|
||||||
SettingRow {
|
|
||||||
label: "24-hour time"
|
ToggleRow { setting: "use24Hour" }
|
||||||
detail: "Use 18:30 instead of 6:30 PM"
|
ToggleRow { setting: "showSeconds" }
|
||||||
controlWidth: 48
|
ToggleRow { setting: "showWeekday"; divider: false }
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.use24Hour; onToggled: value => DesktopPreferences.use24Hour = value }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Show seconds"
|
|
||||||
detail: "Keep a precise clock in the center of the bar"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showSeconds; onToggled: value => DesktopPreferences.showSeconds = value }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Show weekday"
|
|
||||||
detail: "Include the abbreviated weekday before the date"
|
|
||||||
divider: false
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showWeekday; onToggled: value => DesktopPreferences.showWeekday = value }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "System vitals"
|
title: "System vitals"
|
||||||
subtitle: "Choose what appears beside the workspace indicator."
|
subtitle: "Choose what appears beside the workspace indicator."
|
||||||
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value } }
|
|
||||||
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showMemory; onToggled: value => DesktopPreferences.showMemory = value } }
|
ToggleRow { setting: "showCpu" }
|
||||||
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showGpu; onToggled: value => DesktopPreferences.showGpu = value } }
|
ToggleRow { setting: "showMemory" }
|
||||||
}
|
ToggleRow { setting: "showGpu"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Theme"
|
||||||
|
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
||||||
|
|
||||||
|
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
||||||
|
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// Applications and session startup.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
objectName: "applications"
|
||||||
|
title: "Applications"
|
||||||
|
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: "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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var entity
|
||||||
|
signal addRequested(string id)
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
implicitHeight: 62
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: stateCopy.left
|
||||||
|
anchors.rightMargin: 16
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.entity.sourceName
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.entity.id
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: stateCopy
|
||||||
|
anchors.right: addButton.left
|
||||||
|
anchors.rightMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 92
|
||||||
|
text: root.entity.available === false ? "Unavailable" : String(root.entity.state || "Unknown")
|
||||||
|
color: root.entity.available === false ? Theme.fgMuted : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: addButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Add"
|
||||||
|
tone: "accent"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 0
|
||||||
|
border.color: activeFocus ? Theme.fg : Theme.alpha(Theme.fg, 0)
|
||||||
|
onClicked: root.addRequested(root.entity.id)
|
||||||
|
Keys.onReturnPressed: root.addRequested(root.entity.id)
|
||||||
|
Keys.onSpacePressed: root.addRequested(root.entity.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 1
|
||||||
|
color: Theme.alpha(Theme.fg, 0.06)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// A row of choices that wraps, for options that do not fit a segmented control.
|
||||||
|
//
|
||||||
|
// ChoiceRow puts two or three options on one line. Scales and rotations are
|
||||||
|
// more numerous and their labels are wider, so they wrap into a grid rather
|
||||||
|
// than shrinking to illegibility on a narrow, tiled window.
|
||||||
|
//
|
||||||
|
// Unlike ChoiceRow this is not schema-bound: it reports a value and lets the
|
||||||
|
// caller decide what to do with it, because a display change has to go through
|
||||||
|
// an apply-then-confirm cycle rather than straight into the store.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property string detail: ""
|
||||||
|
property var options: []
|
||||||
|
property var current: null
|
||||||
|
property bool enabled: true
|
||||||
|
property bool divider: true
|
||||||
|
|
||||||
|
signal picked(var value)
|
||||||
|
|
||||||
|
spacing: 9
|
||||||
|
bottomPadding: 12
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.label
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.detail !== ""
|
||||||
|
text: root.detail
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.options
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: option
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: root.current === option.modelData.value
|
||||||
|
|
||||||
|
implicitWidth: Math.max(78, caption.implicitWidth + 26)
|
||||||
|
implicitHeight: 32
|
||||||
|
radius: 9
|
||||||
|
opacity: root.enabled ? 1 : 0.45
|
||||||
|
color: option.selected ? "transparent" : Theme.alpha(Theme.fg, hover.hovered && root.enabled ? 0.11 : 0.06)
|
||||||
|
border.width: option.selected ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
// The prism marks the selection here as everywhere else.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: option.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: option.modelData.label
|
||||||
|
color: option.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: option.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hover
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.enabled && !option.selected
|
||||||
|
onTapped: root.picked(option.modelData.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
visible: root.divider
|
||||||
|
color: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// An enum setting, bound to a schema key by name.
|
||||||
|
//
|
||||||
|
// ChoiceRow { setting: "vrrPolicy" }
|
||||||
|
//
|
||||||
|
// The options are the schema's, so a row can never offer a value the store
|
||||||
|
// would reject. Rendered as a segmented control rather than a dropdown: every
|
||||||
|
// choice here has two or three options, and showing them all is both faster to
|
||||||
|
// use and self-documenting.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string setting
|
||||||
|
|
||||||
|
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||||
|
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
|
||||||
|
readonly property var current: DesktopPreferences.get(root.setting)
|
||||||
|
|
||||||
|
label: root.spec ? root.spec.label : root.setting
|
||||||
|
detail: root.spec ? root.spec.detail : ""
|
||||||
|
controlWidth: Math.max(120, root.options.length * 92)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: segments.implicitWidth + 4
|
||||||
|
implicitHeight: 30
|
||||||
|
radius: 9
|
||||||
|
color: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: segments
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.options
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: segment
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: root.current === segment.modelData.value
|
||||||
|
|
||||||
|
implicitWidth: Math.max(70, caption.implicitWidth + 24)
|
||||||
|
implicitHeight: 26
|
||||||
|
radius: 7
|
||||||
|
border.width: 0
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
// The selected segment is the only place the prism appears
|
||||||
|
// in a row: blue leads into orchid, never orchid alone.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: segment.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.30) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.30) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
border.width: 0
|
||||||
|
visible: !segment.selected && hover.hovered
|
||||||
|
color: Theme.alpha(Theme.fg, 0.06)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: segment.modelData.label
|
||||||
|
color: segment.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: segment.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler { id: hover }
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: SystemSettings.commitPreference(root.setting, segment.modelData.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.quicksettings
|
import qs.modules.quicksettings
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
title: "Network & Devices"
|
||||||
|
lede: "Connect graphically—no terminal workflow required."
|
||||||
|
|
||||||
readonly property var wifiDevice: {
|
readonly property var wifiDevice: {
|
||||||
for (const device of Networking.devices.values) {
|
for (const device of Networking.devices.values) {
|
||||||
if (device.type === DeviceType.Wifi)
|
if (device.type === DeviceType.Wifi)
|
||||||
@@ -17,30 +20,15 @@ Item {
|
|||||||
}
|
}
|
||||||
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
|
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 {
|
SettingsCard {
|
||||||
title: "Wi‑Fi"
|
title: "Wi‑Fi"
|
||||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Wi‑Fi"
|
label: "Wi‑Fi"
|
||||||
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -49,40 +37,56 @@ Item {
|
|||||||
onToggled: value => Networking.wifiEnabled = value
|
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"
|
label: "Advanced network settings"
|
||||||
detail: "VPN, wired profiles, DNS, and connection details"
|
detail: "VPN, wired profiles, DNS, and connection details"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 104
|
action: "Open panel"
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
|
onTriggered: SystemSettings.openGnomePanel("network")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Bluetooth"
|
title: "Bluetooth"
|
||||||
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
|
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Bluetooth"
|
label: "Bluetooth"
|
||||||
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
|
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: root.bluetoothAdapter?.enabled ?? false
|
checked: root.bluetoothAdapter?.enabled ?? false
|
||||||
enabled: root.bluetoothAdapter !== null
|
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"
|
label: "Advanced Bluetooth settings"
|
||||||
detail: "Device details and system-level options"
|
detail: "Device details and system-level options"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 104
|
action: "Open panel"
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") }
|
onTriggered: SystemSettings.openGnomePanel("bluetooth")
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Date & Time.
|
||||||
|
//
|
||||||
|
// These belong to the machine rather than to Panama, so nothing here is stored
|
||||||
|
// in Panama's settings file -- it would be a second answer to a question the
|
||||||
|
// system already answers. Timezone and network time are read from and written
|
||||||
|
// to timedatectl directly; the clock's presentation lives on Appearance,
|
||||||
|
// because that genuinely is a Panama preference.
|
||||||
|
//
|
||||||
|
// Changing the timezone or network time needs privilege. timedatectl asks
|
||||||
|
// polkit, and a cancelled dialog surfaces as an error rather than as a value
|
||||||
|
// that appears to have been accepted.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Date & Time"
|
||||||
|
lede: "Timezone and network time, shared with the whole machine."
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Clock"
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Current time"
|
||||||
|
detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone
|
||||||
|
value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP")
|
||||||
|
}
|
||||||
|
SettingRow {
|
||||||
|
label: "Set automatically"
|
||||||
|
detail: DateTime.ntpEnabled
|
||||||
|
? (DateTime.ntpSynchronised ? "Synchronised with a time server" : "Waiting to synchronise")
|
||||||
|
: "The clock is set by hand"
|
||||||
|
controlWidth: 48
|
||||||
|
divider: false
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: DateTime.ntpEnabled
|
||||||
|
enabled: !DateTime.busy
|
||||||
|
onToggled: value => DateTime.setNtp(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Timezone"
|
||||||
|
subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone)
|
||||||
|
+ ". Type to narrow the list."
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: zoneSearch
|
||||||
|
width: parent.width
|
||||||
|
placeholder: "Search timezones"
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.matchingZones
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: DateTime.cityOf(modelData)
|
||||||
|
detail: DateTime.regionOf(modelData)
|
||||||
|
value: modelData === DateTime.timezone ? "Current" : ""
|
||||||
|
controlWidth: 90
|
||||||
|
divider: index < root.matchingZones.length - 1
|
||||||
|
activatable: true
|
||||||
|
onActivated: DateTime.setTimezone(modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: root.matchingZones.length === 0
|
||||||
|
label: "No timezone matches that"
|
||||||
|
detail: "Try a city or a region, such as \"Denver\" or \"Europe\""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: DateTime.lastError !== ""
|
||||||
|
title: "The system did not accept that"
|
||||||
|
subtitle: DateTime.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded so a blank search does not try to lay out six hundred rows. The
|
||||||
|
// current zone is always included, so the card never looks empty when the
|
||||||
|
// field is untouched.
|
||||||
|
readonly property var matchingZones: {
|
||||||
|
const needle = zoneSearch.text.trim().toLowerCase();
|
||||||
|
const all = DateTime.zones;
|
||||||
|
if (needle === "") {
|
||||||
|
// The zone in effect goes first. Listing a region alphabetically
|
||||||
|
// means the current setting is usually off the bottom of the card,
|
||||||
|
// which makes the list look like it does not know what is set.
|
||||||
|
const current = DateTime.timezone;
|
||||||
|
const nearby = all
|
||||||
|
.filter(zone => zone !== current && DateTime.regionOf(zone) === DateTime.regionOf(current))
|
||||||
|
.slice(0, 11);
|
||||||
|
return current === "" ? nearby : [current].concat(nearby);
|
||||||
|
}
|
||||||
|
return all.filter(zone => zone.toLowerCase().replace(/_/g, " ").indexOf(needle) >= 0).slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemClock {
|
||||||
|
id: clock
|
||||||
|
precision: SystemClock.Minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +1,124 @@
|
|||||||
|
// Desktop & Dock.
|
||||||
|
//
|
||||||
|
// The dock timings used to be shown here as text -- "Instant", "250 ms" -- even
|
||||||
|
// though they were already stored, mutable integers. They are controls now.
|
||||||
|
// The window-layout card keeps text rows because those really are facts about
|
||||||
|
// how Panama tiles rather than settings: the adjustable parts of window
|
||||||
|
// appearance live on the Appearance page, next to the preview that explains
|
||||||
|
// them.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
Flickable {
|
id: root
|
||||||
anchors.fill: parent
|
|
||||||
clip: true
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
title: "Desktop & Dock"
|
||||||
id: content
|
lede: "Keep the shell instant, spatial, and out of your way."
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text { text: "Desktop & Dock"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
|
||||||
Text { text: "Keep the shell instant, spatial, and out of your way."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Dock"
|
title: "Dock"
|
||||||
SettingRow {
|
|
||||||
label: "Automatically hide the Dock"
|
ToggleRow { setting: "dockAutohide" }
|
||||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||||
controlWidth: 48
|
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.dockAutohide; onToggled: value => DesktopPreferences.dockAutohide = value }
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pinned applications"
|
||||||
|
subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
|
||||||
|
|
||||||
|
DockPinsEditor {
|
||||||
|
id: pins
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pin another application"
|
||||||
|
|
||||||
|
DockAppPicker {
|
||||||
|
width: parent.width
|
||||||
|
pinned: pins.pinned
|
||||||
|
onPicked: id => pins.add(id)
|
||||||
}
|
}
|
||||||
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.dockRevealDelayMs === 0 ? "Instant" : `${DesktopPreferences.dockRevealDelayMs} ms` }
|
|
||||||
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.dockHideDelayMs} ms`; divider: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Window layout"
|
title: "Window layout"
|
||||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
||||||
SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" }
|
|
||||||
SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" }
|
TextRow {
|
||||||
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
|
label: "Layout"
|
||||||
|
detail: "Dwindle with preserved split direction"
|
||||||
|
value: "Tiling"
|
||||||
|
}
|
||||||
|
TextRow {
|
||||||
|
label: "Workspace movement"
|
||||||
|
detail: "Alt+H / Alt+L, add Shift to move a window"
|
||||||
|
value: "Dynamic"
|
||||||
|
}
|
||||||
|
ActionRow {
|
||||||
|
label: "Gaps, corners, and effects"
|
||||||
|
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||||
|
action: "Open Appearance"
|
||||||
|
divider: false
|
||||||
|
onTriggered: ShellState.openSettings("appearance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Focus"
|
||||||
|
|
||||||
|
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Snapshots"
|
||||||
|
subtitle: SettingsBackup.lastError !== ""
|
||||||
|
? SettingsBackup.lastError
|
||||||
|
: "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Back up current settings"
|
||||||
|
detail: SettingsBackup.snapshots.length === 0
|
||||||
|
? "No snapshots yet"
|
||||||
|
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first"
|
||||||
|
action: "Back up now"
|
||||||
|
enabled: !SettingsBackup.busy
|
||||||
|
divider: SettingsBackup.snapshots.length > 0
|
||||||
|
onTriggered: SettingsBackup.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: snapshotRows
|
||||||
|
model: SettingsBackup.snapshots
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: modelData.when
|
||||||
|
detail: modelData.keys + " settings"
|
||||||
|
action: "Restore"
|
||||||
|
enabled: !SettingsBackup.busy
|
||||||
|
divider: index < snapshotRows.count - 1
|
||||||
|
onTriggered: SettingsBackup.restore(modelData.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Reset"
|
title: "Reset"
|
||||||
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults."
|
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||||
SettingRow {
|
|
||||||
label: "Desktop preferences"
|
ActionRow {
|
||||||
detail: "Your pinned applications and files are not changed"
|
label: "Restore Panama defaults"
|
||||||
|
detail: "Applies immediately, including to the compositor"
|
||||||
|
action: "Restore defaults"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 122
|
onTriggered: SystemSettings.restoreDefaults()
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// A miniature of the real desktop that redraws as you change Appearance.
|
||||||
|
//
|
||||||
|
// The point is that "outer gaps 24" and "radius 9" mean nothing as numbers. This
|
||||||
|
// shows two tiled windows at the settings currently in effect: the focused one
|
||||||
|
// wearing the prism border and its glow, the unfocused one carrying the
|
||||||
|
// inactive-opacity setting, both inside real gaps at a real corner radius.
|
||||||
|
//
|
||||||
|
// Geometry is scaled by the ratio between this preview's width and the actual
|
||||||
|
// monitor's, so proportions are honest rather than decorative -- a 10px gap on
|
||||||
|
// a 4500px display genuinely is almost invisible, and the preview says so.
|
||||||
|
//
|
||||||
|
// Everything here is driven by bindings on DesktopPreferences, so the preview
|
||||||
|
// shows what is actually stored and applied. It has no state of its own and
|
||||||
|
// nothing animates while idle.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Effects
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
implicitHeight: 232
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
clip: true
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.09)
|
||||||
|
|
||||||
|
// Falls back to a sane width if the monitor has not been read yet, so the
|
||||||
|
// preview is never wrong by a factor of ten on first paint.
|
||||||
|
readonly property real monitorWidth: SystemSettings.monitorWidth > 0 ? SystemSettings.monitorWidth : 3000
|
||||||
|
readonly property real scale: width > 0 ? width / root.monitorWidth : 0.25
|
||||||
|
|
||||||
|
readonly property int gapsIn: DesktopPreferences.get("gapsIn")
|
||||||
|
readonly property int gapsOut: DesktopPreferences.get("gapsOut")
|
||||||
|
readonly property int borderSize: DesktopPreferences.get("borderSize")
|
||||||
|
readonly property int rounding: DesktopPreferences.get("windowRounding")
|
||||||
|
readonly property real inactiveOpacity: DesktopPreferences.get("inactiveOpacity")
|
||||||
|
readonly property bool shadowOn: DesktopPreferences.get("shadowEnabled")
|
||||||
|
readonly property int shadowRange: DesktopPreferences.get("shadowRange")
|
||||||
|
readonly property bool glowOn: DesktopPreferences.get("glowEnabled")
|
||||||
|
readonly property int glowRange: DesktopPreferences.get("glowRange")
|
||||||
|
|
||||||
|
// Scaled geometry, floored so a small-but-nonzero setting stays visible
|
||||||
|
// rather than rounding away to nothing.
|
||||||
|
function px(value: real): real {
|
||||||
|
return value <= 0 ? 0 : Math.max(1, value * root.scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stands in for the wallpaper. Static: an animated gradient here would
|
||||||
|
// repaint forever behind a settings page.
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Vertical
|
||||||
|
GradientStop { position: 0.0; color: "#2b3050" }
|
||||||
|
GradientStop { position: 1.0; color: "#241f33" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bar, so the preview reads as this desktop and not a generic one.
|
||||||
|
Rectangle {
|
||||||
|
id: miniBar
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: 20
|
||||||
|
color: Theme.alpha(Theme.bgDark, 0.4)
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 9
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: 3
|
||||||
|
Rectangle {
|
||||||
|
required property int index
|
||||||
|
width: index === 0 ? 12 : 5
|
||||||
|
height: 5
|
||||||
|
radius: 2.5
|
||||||
|
border.width: 0
|
||||||
|
color: index === 0 ? Theme.accent : Theme.alpha(Theme.fg, 0.3)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Panama"
|
||||||
|
color: Theme.alpha(Theme.fg, 0.45)
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: tiles
|
||||||
|
anchors.top: miniBar.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: root.px(root.gapsOut)
|
||||||
|
spacing: root.px(root.gapsIn) * 2
|
||||||
|
|
||||||
|
MiniWindow {
|
||||||
|
width: (tiles.width - tiles.spacing) / 2
|
||||||
|
height: tiles.height
|
||||||
|
focused: true
|
||||||
|
}
|
||||||
|
|
||||||
|
MiniWindow {
|
||||||
|
width: (tiles.width - tiles.spacing) / 2
|
||||||
|
height: tiles.height
|
||||||
|
focused: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component MiniWindow: Item {
|
||||||
|
id: win
|
||||||
|
|
||||||
|
required property bool focused
|
||||||
|
|
||||||
|
// The gradient border is drawn as a filled rounded rect with the window
|
||||||
|
// body inset on top of it: QML's Rectangle border takes a colour, not a
|
||||||
|
// gradient, and the prism border is the whole signature here.
|
||||||
|
Rectangle {
|
||||||
|
id: frame
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: root.px(root.rounding)
|
||||||
|
border.width: 0
|
||||||
|
visible: win.focused && root.borderSize > 0
|
||||||
|
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.accent }
|
||||||
|
GradientStop { position: 1.0; color: Theme.accentSecondary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: body
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: win.focused ? root.px(root.borderSize) : 0
|
||||||
|
radius: Math.max(0, root.px(root.rounding) - (win.focused ? root.px(root.borderSize) : 0))
|
||||||
|
color: Theme.alpha(Theme.bgDark, 0.92)
|
||||||
|
opacity: win.focused ? 1.0 : root.inactiveOpacity
|
||||||
|
border.width: win.focused ? 0 : 1
|
||||||
|
border.color: Theme.alpha(Theme.gutter, 0.6)
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: 13
|
||||||
|
color: Theme.alpha(Theme.fg, 0.05)
|
||||||
|
border.width: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 22
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 5
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: [1.0, 0.74, 0.52]
|
||||||
|
Rectangle {
|
||||||
|
required property real modelData
|
||||||
|
width: parent.width * modelData
|
||||||
|
height: 4
|
||||||
|
radius: 2
|
||||||
|
border.width: 0
|
||||||
|
color: Theme.alpha(Theme.fg, win.focused ? 0.2 : 0.13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: 8
|
||||||
|
text: win.focused ? "focused" : "unfocused"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shadow and glow both come from MultiEffect. Only the focused window
|
||||||
|
// gets the glow, matching looks.lua where glow.color_inactive is fully
|
||||||
|
// transparent.
|
||||||
|
MultiEffect {
|
||||||
|
anchors.fill: body
|
||||||
|
source: body
|
||||||
|
visible: root.shadowOn || (win.focused && root.glowOn)
|
||||||
|
z: -1
|
||||||
|
|
||||||
|
shadowEnabled: true
|
||||||
|
shadowColor: win.focused && root.glowOn
|
||||||
|
? Theme.alpha(Theme.accent, 0.5)
|
||||||
|
: Theme.alpha("#15161e", 0.85)
|
||||||
|
shadowBlur: win.focused && root.glowOn
|
||||||
|
? Math.min(1.0, root.px(root.glowRange) / 12)
|
||||||
|
: Math.min(1.0, root.px(root.shadowRange) / 24)
|
||||||
|
shadowVerticalOffset: win.focused && root.glowOn ? 0 : root.px(4)
|
||||||
|
shadowHorizontalOffset: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// The resolution list for one display.
|
||||||
|
//
|
||||||
|
// Grouped by resolution with refresh rates beside it, rather than a flat list
|
||||||
|
// of "[email protected]" strings: this panel reports 35 modes, many of which
|
||||||
|
// differ only in refresh-rate rounding, and a flat list of those is a wall of
|
||||||
|
// near-identical text rather than a choice.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var monitor: null
|
||||||
|
property bool enabled: true
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
readonly property var grouped: {
|
||||||
|
if (!root.monitor)
|
||||||
|
return [];
|
||||||
|
const buckets = {};
|
||||||
|
const order = [];
|
||||||
|
for (const mode of root.monitor.modes) {
|
||||||
|
const key = mode.label;
|
||||||
|
if (!buckets[key]) {
|
||||||
|
buckets[key] = { label: key, width: mode.width, height: mode.height, rates: [] };
|
||||||
|
order.push(key);
|
||||||
|
}
|
||||||
|
buckets[key].rates.push(mode);
|
||||||
|
}
|
||||||
|
return order.map(key => buckets[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.grouped
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: resolution
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool isCurrent: root.monitor
|
||||||
|
&& root.monitor.width === resolution.modelData.width
|
||||||
|
&& root.monitor.height === resolution.modelData.height
|
||||||
|
|
||||||
|
label: resolution.modelData.label
|
||||||
|
detail: resolution.isCurrent ? "Current resolution" : ""
|
||||||
|
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
|
||||||
|
divider: resolution.index < root.grouped.length - 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: resolution.modelData.rates
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: rate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: resolution.isCurrent
|
||||||
|
&& Displays.modeIsCurrent(root.monitor, rate.modelData)
|
||||||
|
|
||||||
|
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
|
||||||
|
implicitHeight: 30
|
||||||
|
radius: 9
|
||||||
|
opacity: root.enabled ? 1 : 0.45
|
||||||
|
color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06)
|
||||||
|
border.width: rate.selected ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: rate.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: rateCaption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: rate.modelData.refreshLabel
|
||||||
|
color: rate.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: rate.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: rateHover
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.enabled && !rate.selected
|
||||||
|
onTapped: Displays.apply(
|
||||||
|
root.monitor.name,
|
||||||
|
rate.modelData.mode,
|
||||||
|
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||||
|
root.monitor.transform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,97 +1,219 @@
|
|||||||
|
// Displays.
|
||||||
|
//
|
||||||
|
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
||||||
|
// policy that was already here.
|
||||||
|
//
|
||||||
|
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||||
|
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||||
|
// and no other control in the app can undo it once that happens. Confirming is
|
||||||
|
// what writes the choice to the settings store; letting the countdown run
|
||||||
|
// leaves nothing behind.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
Flickable {
|
id: root
|
||||||
anchors.fill: parent
|
|
||||||
clip: true
|
title: "Displays"
|
||||||
contentWidth: width
|
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
property string selectedOutput: ""
|
||||||
|
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||||
|
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
|
||||||
|
readonly property string currentMode: root.monitor
|
||||||
|
? root.monitor.mode
|
||||||
|
: ""
|
||||||
|
|
||||||
|
function syncSelectedOutput(): void {
|
||||||
|
if (!Displays.monitorNamed(root.selectedOutput))
|
||||||
|
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.syncSelectedOutput()
|
||||||
|
Connections {
|
||||||
|
target: Displays
|
||||||
|
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confirmation sits above everything, because while it is counting down
|
||||||
|
// it is the only thing that matters on this page.
|
||||||
|
header: Component {
|
||||||
|
Rectangle {
|
||||||
|
visible: Displays.awaitingConfirmation
|
||||||
|
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.4)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: confirmRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 14
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: content
|
width: parent.width - keepButton.width - revertButton.width - 28
|
||||||
width: parent.width - 68
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
x: 34
|
spacing: 3
|
||||||
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 }
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Gaming display policy"
|
|
||||||
subtitle: "These values apply immediately and are restored when Panama starts."
|
|
||||||
SettingRow {
|
|
||||||
label: "Game-aware HDR"
|
|
||||||
detail: "Enter HDR only for fullscreen content that requests it"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Content-aware VRR"
|
|
||||||
detail: "Enable variable refresh only for game and video content"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Direct scanout for games"
|
|
||||||
detail: "Bypass compositing only for windows classified as games"
|
|
||||||
divider: false
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Night Light"
|
|
||||||
SettingRow {
|
|
||||||
label: "Warm display colors"
|
|
||||||
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Color temperature"
|
|
||||||
detail: `${NightLight.temperature} K`
|
|
||||||
divider: false
|
|
||||||
controlWidth: 230
|
|
||||||
ValueSlider {
|
|
||||||
anchors.fill: parent
|
|
||||||
value: (6500 - NightLight.temperature) / 4000
|
|
||||||
icon: "weather-clear-night-symbolic"
|
|
||||||
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: warningText.implicitHeight + 30
|
|
||||||
radius: Theme.cardRadius
|
|
||||||
color: Theme.alpha(Theme.warn, 0.085)
|
|
||||||
border.width: 1
|
|
||||||
border.color: Theme.alpha(Theme.warn, 0.22)
|
|
||||||
Text {
|
Text {
|
||||||
id: warningText
|
width: parent.width
|
||||||
anchors.fill: parent
|
text: "Keep this display setting?"
|
||||||
anchors.margins: 15
|
color: Theme.fg
|
||||||
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
|
|
||||||
color: Theme.mix(Theme.fg, Theme.warn, 0.25)
|
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
|
||||||
|
+ " if you do nothing. If you cannot read this, just wait."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
wrapMode: Text.WordWrap
|
wrapMode: Text.WordWrap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: revertButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Revert now"
|
||||||
|
onClicked: Displays.revert()
|
||||||
}
|
}
|
||||||
|
SettingsButton {
|
||||||
|
id: keepButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Keep"
|
||||||
|
enabled: Displays.canConfirm
|
||||||
|
onClicked: Displays.confirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Displays.monitors.length > 1
|
||||||
|
title: "Connected display"
|
||||||
|
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Display"
|
||||||
|
options: Displays.monitors.map(monitor => ({
|
||||||
|
value: monitor.name,
|
||||||
|
label: monitor.description || monitor.name
|
||||||
|
}))
|
||||||
|
current: root.monitor ? root.monitor.name : ""
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
divider: false
|
||||||
|
onPicked: value => root.selectedOutput = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display")
|
||||||
|
subtitle: root.monitor
|
||||||
|
? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale`
|
||||||
|
: "Reading the active display…"
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Color mode"
|
||||||
|
detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background."
|
||||||
|
value: root.monitor
|
||||||
|
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
|
||||||
|
: "Detecting"
|
||||||
|
}
|
||||||
|
TextRow {
|
||||||
|
label: "Variable refresh"
|
||||||
|
detail: root.monitor && root.monitor.vrr
|
||||||
|
? "Active on this output for current fullscreen content"
|
||||||
|
: "This output is ready when game or video content requests it"
|
||||||
|
value: root.monitor && root.monitor.vrr ? "Active" : "Standby"
|
||||||
|
divider: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||||
|
}
|
||||||
|
ActionRow {
|
||||||
|
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||||
|
label: "Using a custom display setting"
|
||||||
|
detail: "Forget it to go back to the resolution and scale Panama ships"
|
||||||
|
action: "Forget"
|
||||||
|
divider: false
|
||||||
|
onTriggered: Displays.forget(root.monitor.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.monitor !== null
|
||||||
|
title: "Resolution"
|
||||||
|
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||||||
|
|
||||||
|
DisplayModePicker {
|
||||||
|
width: parent.width
|
||||||
|
monitor: root.monitor
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.monitor !== null
|
||||||
|
title: "Scale and rotation"
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Scale"
|
||||||
|
detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered."
|
||||||
|
options: Displays.scalesForMode(root.currentMode)
|
||||||
|
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
|
||||||
|
current: root.monitor ? root.monitor.scale : 1
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
onPicked: value => root.applyWith({ scale: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Rotation"
|
||||||
|
options: Displays.transforms
|
||||||
|
current: root.monitor ? root.monitor.transform : 0
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
divider: false
|
||||||
|
onPicked: value => root.applyWith({ transform: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Gaming display policy"
|
||||||
|
subtitle: "Applied immediately and restored when Panama starts."
|
||||||
|
|
||||||
|
ToggleRow { setting: "autoHdr" }
|
||||||
|
ChoiceRow { setting: "vrrPolicy" }
|
||||||
|
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Displays.lastError !== ""
|
||||||
|
title: "Display problem"
|
||||||
|
subtitle: Displays.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies a change to one field, keeping the others at what is in effect.
|
||||||
|
function applyWith(change: var): void {
|
||||||
|
if (!root.monitor)
|
||||||
|
return;
|
||||||
|
const mode = change.mode ?? root.currentMode;
|
||||||
|
const requestedScale = change.scale ?? root.monitor.scale;
|
||||||
|
Displays.apply(
|
||||||
|
root.monitor.name,
|
||||||
|
mode,
|
||||||
|
Displays.isScaleClean(mode, requestedScale)
|
||||||
|
? requestedScale
|
||||||
|
: Displays.nearestCleanScale(mode, requestedScale),
|
||||||
|
change.transform ?? root.monitor.transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Adds an installed application to the Dock.
|
||||||
|
//
|
||||||
|
// Filtered rather than a full list: there are several hundred desktop entries
|
||||||
|
// on a normal system, and rendering them all into a page that is already
|
||||||
|
// scrolling is both slow and useless. Typing narrows; nothing shows until you
|
||||||
|
// do, which also keeps the card short when you are not using it.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.config
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
required property var pinned
|
||||||
|
|
||||||
|
signal picked(string id)
|
||||||
|
|
||||||
|
readonly property var matches: {
|
||||||
|
const needle = search.text.trim().toLowerCase();
|
||||||
|
if (needle === "")
|
||||||
|
return [];
|
||||||
|
const out = [];
|
||||||
|
for (const entry of DesktopEntries.applications.values) {
|
||||||
|
if (entry.noDisplay)
|
||||||
|
continue;
|
||||||
|
if (root.pinned.indexOf(entry.id) >= 0)
|
||||||
|
continue;
|
||||||
|
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
|
||||||
|
out.push(entry);
|
||||||
|
if (out.length >= 8)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: search
|
||||||
|
width: parent.width
|
||||||
|
placeholder: "Search installed applications"
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.matches
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: candidate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: candidate.modelData.name
|
||||||
|
detail: candidate.modelData.id
|
||||||
|
divider: candidate.index < root.matches.length - 1
|
||||||
|
controlWidth: 86
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Pin"
|
||||||
|
onClicked: {
|
||||||
|
root.picked(candidate.modelData.id);
|
||||||
|
search.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// The Dock's pinned applications: reorder, remove, and add.
|
||||||
|
//
|
||||||
|
// The list was a sixteen-entry literal in Settings.qml, so changing what sits
|
||||||
|
// in the Dock meant editing a QML file and reloading the shell. It is a plain
|
||||||
|
// ordered list of desktop entry ids, stored in the shared settings file, which
|
||||||
|
// means it is covered by Restore defaults like everything else.
|
||||||
|
//
|
||||||
|
// Move up / move down rather than drag-and-drop. Dragging inside a Flickable
|
||||||
|
// that is itself inside a scrolling page is a genuinely hard interaction to get
|
||||||
|
// right, and it fails in a way the user reads as the app being broken; two
|
||||||
|
// buttons are unambiguous and keyboard-reachable.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
readonly property var pinned: {
|
||||||
|
const stored = DesktopPreferences.get("dockPinned");
|
||||||
|
return Array.isArray(stored) ? stored : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// DesktopEntries populates asynchronously, so this must be read as a
|
||||||
|
// binding rather than looked up inside one -- byId() called during
|
||||||
|
// evaluation registers no dependency and answers from an empty list.
|
||||||
|
readonly property var entriesById: {
|
||||||
|
const index = {};
|
||||||
|
for (const entry of DesktopEntries.applications.values)
|
||||||
|
index[entry.id] = entry;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameFor(id: string): string {
|
||||||
|
const entry = root.entriesById[id];
|
||||||
|
return entry ? entry.name : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(next: var): void {
|
||||||
|
DesktopPreferences.set("dockPinned", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(from: int, to: int): void {
|
||||||
|
if (to < 0 || to >= root.pinned.length)
|
||||||
|
return;
|
||||||
|
const next = root.pinned.slice();
|
||||||
|
const moved = next.splice(from, 1)[0];
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
root.commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: int): void {
|
||||||
|
const next = root.pinned.slice();
|
||||||
|
next.splice(index, 1);
|
||||||
|
root.commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(id: string): void {
|
||||||
|
if (root.pinned.indexOf(id) >= 0)
|
||||||
|
return;
|
||||||
|
root.commit(root.pinned.concat([id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.pinned
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: pin
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: root.nameFor(pin.modelData)
|
||||||
|
detail: pin.modelData
|
||||||
|
divider: pin.index < root.pinned.length - 1
|
||||||
|
controlWidth: 132
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "↑"
|
||||||
|
enabled: pin.index > 0
|
||||||
|
onClicked: root.move(pin.index, pin.index - 1)
|
||||||
|
}
|
||||||
|
SettingsButton {
|
||||||
|
text: "↓"
|
||||||
|
enabled: pin.index < root.pinned.length - 1
|
||||||
|
onClicked: root.move(pin.index, pin.index + 1)
|
||||||
|
}
|
||||||
|
SettingsButton {
|
||||||
|
text: "Unpin"
|
||||||
|
onClicked: root.remove(pin.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: root.pinned.length === 0
|
||||||
|
label: "Nothing is pinned"
|
||||||
|
detail: "The Dock will only show running applications"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var favorite
|
||||||
|
required property string sourceName
|
||||||
|
required property int index
|
||||||
|
required property bool featured
|
||||||
|
|
||||||
|
signal aliasCommitted(string id, string alias)
|
||||||
|
signal removeRequested(string id)
|
||||||
|
signal moveRequested(string id, int targetIndex)
|
||||||
|
|
||||||
|
readonly property bool dragging: dragHandler.active
|
||||||
|
|
||||||
|
implicitHeight: 108
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: root.dragging
|
||||||
|
? Theme.mix(Theme.bgDark, Theme.accent, 0.09)
|
||||||
|
: Theme.alpha(Theme.bgDark, 0.7)
|
||||||
|
border.width: root.dragging ? 2 : 1
|
||||||
|
border.color: root.dragging
|
||||||
|
? Theme.alpha(Theme.accent, 0.82)
|
||||||
|
: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
z: root.dragging ? 10 : 0
|
||||||
|
|
||||||
|
transform: Translate {
|
||||||
|
x: root.dragging ? dragHandler.translation.x : 0
|
||||||
|
y: root.dragging ? dragHandler.translation.y : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
PrismEdge {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
inset: root.radius
|
||||||
|
opacity: root.dragging ? 0.82 : 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: dragHandle
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 30
|
||||||
|
height: 42
|
||||||
|
radius: 9
|
||||||
|
activeFocusOnTab: true
|
||||||
|
color: root.dragging || activeFocus
|
||||||
|
? Theme.alpha(Theme.accent, 0.14)
|
||||||
|
: (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045))
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "⠿"
|
||||||
|
color: root.dragging ? Theme.accent : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 16
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: handleMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
cursorShape: Qt.SizeAllCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
DragHandler {
|
||||||
|
id: dragHandler
|
||||||
|
target: null
|
||||||
|
onActiveChanged: {
|
||||||
|
if (!active)
|
||||||
|
root.commitDrag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
|
||||||
|
root.moveRequested(root.favorite.id, Math.max(0, root.index - 1));
|
||||||
|
event.accepted = true;
|
||||||
|
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
|
||||||
|
const grid = root.GridView.view;
|
||||||
|
const lastIndex = grid ? grid.count - 1 : root.index;
|
||||||
|
root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1));
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: aliasFrame
|
||||||
|
anchors.left: dragHandle.right
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.right: removeButton.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 12
|
||||||
|
height: 34
|
||||||
|
radius: 8
|
||||||
|
color: Theme.alpha(Theme.fg, aliasInput.activeFocus ? 0.075 : 0.045)
|
||||||
|
border.width: aliasInput.activeFocus ? 2 : 1
|
||||||
|
border.color: aliasInput.activeFocus
|
||||||
|
? Theme.alpha(Theme.accent, 0.78)
|
||||||
|
: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: aliasInput
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
activeFocusOnTab: true
|
||||||
|
text: String(root.favorite.alias || "")
|
||||||
|
color: Theme.fg
|
||||||
|
selectionColor: Theme.accent
|
||||||
|
selectedTextColor: Theme.bgDark
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
selectByMouse: true
|
||||||
|
clip: true
|
||||||
|
onEditingFinished: root.aliasCommitted(root.favorite.id, text)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: aliasInput.text === "" && !aliasInput.activeFocus
|
||||||
|
text: root.sourceName
|
||||||
|
color: Theme.fgDim
|
||||||
|
font: aliasInput.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: aliasFrame.left
|
||||||
|
anchors.right: removeButton.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.top: aliasFrame.bottom
|
||||||
|
anchors.topMargin: 7
|
||||||
|
text: root.sourceName
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: aliasFrame.left
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 10
|
||||||
|
width: badgeCopy.implicitWidth + 14
|
||||||
|
height: 21
|
||||||
|
radius: Theme.pillRadius
|
||||||
|
visible: root.featured
|
||||||
|
color: Theme.alpha(Theme.accent, 0.1)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.2)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: badgeCopy
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Control Center"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: removeButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Remove"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: root.removeRequested(root.favorite.id)
|
||||||
|
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
|
||||||
|
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDrag(): void {
|
||||||
|
const grid = root.GridView.view;
|
||||||
|
if (!grid || grid.count <= 0)
|
||||||
|
return;
|
||||||
|
const centerX = root.x + dragHandler.translation.x + root.width / 2;
|
||||||
|
const centerY = root.y + dragHandler.translation.y + root.height / 2;
|
||||||
|
const modelCount = grid.count;
|
||||||
|
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
|
||||||
|
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
|
||||||
|
root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,54 +2,32 @@ import QtQuick
|
|||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
readonly property int openedHour: new Date().getHours()
|
readonly property int openedHour: new Date().getHours()
|
||||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||||
|
|
||||||
Flickable {
|
title: `${root.greeting}, Gabriel`
|
||||||
anchors.fill: parent
|
lede: "Your Panama desktop is configured and ready."
|
||||||
clip: true
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: SystemSettings.monitorDescription || "Active display"
|
title: SystemSettings.monitorDescription || "Active display"
|
||||||
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
||||||
|
|
||||||
Row {
|
Grid {
|
||||||
|
id: monitorLayout
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 164
|
columns: width >= 620 ? 2 : 1
|
||||||
spacing: 28
|
columnSpacing: 28
|
||||||
|
rowSpacing: 16
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
width: parent.width * 0.47
|
width: monitorLayout.columns === 2
|
||||||
height: parent.height
|
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||||
|
: monitorLayout.width
|
||||||
|
height: 164
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: Math.min(parent.width - 24, 260)
|
width: Math.min(parent.width - 24, 260)
|
||||||
@@ -85,8 +63,10 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
width: parent.width * 0.47
|
width: monitorLayout.columns === 2
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||||
|
: monitorLayout.width
|
||||||
|
height: monitorLayout.columns === 2 ? 164 : implicitHeight
|
||||||
spacing: 13
|
spacing: 13
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
@@ -120,12 +100,31 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
SettingsCard {
|
||||||
width: parent.width
|
title: "Weather"
|
||||||
spacing: 16
|
subtitle: "Local conditions in the date menu"
|
||||||
|
ChoiceRow { setting: "temperatureUnit" }
|
||||||
|
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
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"
|
title: "Quiet focus"
|
||||||
subtitle: "Notifications and focused work"
|
subtitle: "Notifications and focused work"
|
||||||
|
|
||||||
@@ -144,11 +143,13 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
width: (parent.width - parent.spacing) / 2
|
width: summaryCards.columns === 2
|
||||||
|
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||||
|
: summaryCards.width
|
||||||
title: "Desktop services"
|
title: "Desktop services"
|
||||||
subtitle: "The essentials are running"
|
subtitle: "The essentials are running"
|
||||||
|
|
||||||
SettingRow {
|
TextRow {
|
||||||
label: "Sync & remote access"
|
label: "Sync & remote access"
|
||||||
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
||||||
divider: false
|
divider: false
|
||||||
@@ -156,6 +157,4 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
readonly property var availableLights: HomeAssistant.catalog.filter(entity => {
|
||||||
|
if (HomeAssistant.selectedEntities.some(selected => selected.id === entity.id))
|
||||||
|
return false;
|
||||||
|
const haystack = (entity.sourceName + " " + entity.id).toLowerCase();
|
||||||
|
return root.lightQuery === "" || haystack.includes(root.lightQuery);
|
||||||
|
})
|
||||||
|
readonly property string availableEmptyText: root.availableLights.length > 0
|
||||||
|
? ""
|
||||||
|
: (root.lightQuery !== ""
|
||||||
|
? "No lights match that search"
|
||||||
|
: (HomeAssistant.catalog.length === 0
|
||||||
|
? "No lights discovered"
|
||||||
|
: "All discovered lights are already selected"))
|
||||||
|
readonly property var pageDiagnostics: ({
|
||||||
|
availableLightIds: root.availableLights.map(entity => entity.id),
|
||||||
|
availableEmptyText: root.availableEmptyText,
|
||||||
|
homeStatus: root.homeStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
function homeStatus(): string {
|
||||||
|
if (HomeAssistant.lastError === "authentication-required")
|
||||||
|
return "Authentication required";
|
||||||
|
if (HomeAssistant.lastError === "not-configured")
|
||||||
|
return "Home Assistant is not configured";
|
||||||
|
if (HomeAssistant.phase === "ready")
|
||||||
|
return `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
|
||||||
|
if (HomeAssistant.phase === "degraded")
|
||||||
|
return "Last update unavailable · showing saved controls";
|
||||||
|
if (HomeAssistant.phase === "loading")
|
||||||
|
return "Connecting to Home Assistant";
|
||||||
|
return "Home Assistant is unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Home Assistant"
|
||||||
|
subtitle: root.homeStatus()
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Light catalog"
|
||||||
|
detail: "Panama reads light state through the Home Assistant helper."
|
||||||
|
divider: false
|
||||||
|
controlWidth: 176
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: refreshButton
|
||||||
|
text: "Refresh"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: HomeAssistant.refresh()
|
||||||
|
Keys.onReturnPressed: HomeAssistant.refresh()
|
||||||
|
Keys.onSpacePressed: HomeAssistant.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: openHomeButton
|
||||||
|
text: "Open"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: HomeAssistant.open()
|
||||||
|
Keys.onReturnPressed: HomeAssistant.open()
|
||||||
|
Keys.onSpacePressed: HomeAssistant.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Control Center lights"
|
||||||
|
subtitle: HomeAssistant.selectedEntities.length === 0
|
||||||
|
? "Select the lights that belong on your shelf."
|
||||||
|
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: HomeAssistant.selectedEntities.length === 0
|
||||||
|
text: "Choose lights below to build your Control Center shelf."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
topPadding: 3
|
||||||
|
bottomPadding: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
GridView {
|
||||||
|
id: favoritesGrid
|
||||||
|
width: parent.width
|
||||||
|
height: Math.ceil(count / 2) * cellHeight
|
||||||
|
visible: count > 0
|
||||||
|
interactive: false
|
||||||
|
clip: false
|
||||||
|
cellWidth: width / 2
|
||||||
|
cellHeight: 116
|
||||||
|
model: HomeAssistant.selectedEntities
|
||||||
|
|
||||||
|
delegate: HomeFavoriteCard {
|
||||||
|
required property var modelData
|
||||||
|
width: GridView.view.cellWidth - 6
|
||||||
|
height: 108
|
||||||
|
favorite: ({
|
||||||
|
id: modelData.id,
|
||||||
|
alias: modelData.name === modelData.sourceName ? "" : modelData.name
|
||||||
|
})
|
||||||
|
sourceName: modelData.sourceName
|
||||||
|
featured: index < 4
|
||||||
|
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
|
||||||
|
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
|
||||||
|
onRemoveRequested: id => HomePreferences.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 46
|
||||||
|
visible: HomePreferences.saveError !== ""
|
||||||
|
radius: 9
|
||||||
|
color: Theme.alpha(Theme.warn, 0.09)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.24)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.right: retryButton.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: HomePreferences.saveError
|
||||||
|
color: Theme.warn
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: retryButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Retry"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: HomePreferences.retrySave()
|
||||||
|
Keys.onReturnPressed: HomePreferences.retrySave()
|
||||||
|
Keys.onSpacePressed: HomePreferences.retrySave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Available lights"
|
||||||
|
subtitle: "Search the Home Assistant catalog by source name or entity ID."
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 38
|
||||||
|
radius: 10
|
||||||
|
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
|
||||||
|
border.width: searchInput.activeFocus ? 2 : 1
|
||||||
|
border.color: searchInput.activeFocus
|
||||||
|
? Theme.alpha(Theme.accent, 0.72)
|
||||||
|
: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "\u{F0349}"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: searchInput
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 36
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
activeFocusOnTab: true
|
||||||
|
color: Theme.fg
|
||||||
|
selectionColor: Theme.accent
|
||||||
|
selectedTextColor: Theme.bgDark
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: searchInput.text === "" && !searchInput.activeFocus
|
||||||
|
text: "Search available lights"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font: searchInput.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.availableLights.length > 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.availableLights
|
||||||
|
|
||||||
|
AvailableLightRow {
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
entity: modelData
|
||||||
|
onAddRequested: id => HomePreferences.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.availableLights.length === 0
|
||||||
|
text: root.availableEmptyText
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
topPadding: 18
|
||||||
|
bottomPadding: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Phone continuity"
|
||||||
|
subtitle: "Keep the Messages handoff independent from phone connectivity."
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Messages"
|
||||||
|
detail: "Opens BlueBubbles"
|
||||||
|
divider: false
|
||||||
|
controlWidth: 204
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
|
||||||
|
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: openBlueBubblesButton
|
||||||
|
text: "Open"
|
||||||
|
enabled: SystemSettings.bluebubblesAvailable
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: SystemSettings.openApplication("bluebubbles")
|
||||||
|
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
|
||||||
|
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,69 +2,90 @@ import QtQuick
|
|||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
Flickable {
|
title: "Notifications & Focus"
|
||||||
anchors.fill: parent
|
lede: "Control interruptions without losing useful history."
|
||||||
clip: true
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
|
||||||
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 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Notifications"
|
title: "Notifications"
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Do Not Disturb"
|
label: "Do Not Disturb"
|
||||||
detail: "Keep notifications in the center but suppress banners"
|
detail: "Keep notifications in the center but suppress banners"
|
||||||
controlWidth: 48
|
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"
|
label: "Clear notification history"
|
||||||
detail: "Dismiss every item currently in the notification center"
|
detail: "Dismiss every item currently in the notification center"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 94
|
action: "Clear all"
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
|
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 {
|
SettingsCard {
|
||||||
title: "Focus sessions"
|
title: "Focus sessions"
|
||||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
|
id: durationRow
|
||||||
|
|
||||||
label: "Default duration"
|
label: "Default duration"
|
||||||
detail: "Used by Super+Shift+F and Quick Settings"
|
detail: "Used by Super+Shift+F and Quick Settings"
|
||||||
controlWidth: 264
|
controlWidth: 264
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: [25, 45, 60, 90]
|
model: [25, 45, 60, 90]
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
required property int modelData
|
required property int modelData
|
||||||
|
|
||||||
text: `${modelData}m`
|
text: `${modelData}m`
|
||||||
tone: DesktopPreferences.focusDurationMinutes === modelData ? "accent" : "normal"
|
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
||||||
onClicked: DesktopPreferences.focusDurationMinutes = modelData
|
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
||||||
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 120
|
controlWidth: 120
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -74,6 +95,4 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Power & Lock.
|
||||||
|
//
|
||||||
|
// hypridle has no IPC for reconfiguration, so these values reach it by
|
||||||
|
// regenerating its config and restarting the daemon (services/IdleLock.qml).
|
||||||
|
// That only happens when Panama manages the daemon, and the card below says
|
||||||
|
// plainly which state you are in rather than presenting sliders that silently
|
||||||
|
// do nothing.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Power & Lock"
|
||||||
|
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Idle behaviour"
|
||||||
|
subtitle: IdleLock.managed
|
||||||
|
? "Panama is managing hypridle. Changes take effect immediately."
|
||||||
|
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable."
|
||||||
|
|
||||||
|
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
|
||||||
|
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||||
|
SliderRow { setting: "suspendMinutes"; zeroLabel: "Never" }
|
||||||
|
ToggleRow { setting: "lockOnSleep"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only shown when the numbers are actually contradictory, rather than as a
|
||||||
|
// permanent warning nobody reads.
|
||||||
|
SettingsCard {
|
||||||
|
visible: IdleLock.lockBeforeBlank
|
||||||
|
title: "Lock happens before the screen turns off"
|
||||||
|
subtitle: "The session will lock at " + IdleLock.lockMinutes
|
||||||
|
+ " minutes and the display will not blank until " + IdleLock.blankMinutes
|
||||||
|
+ ". That works, but the screen stays lit on the lock screen for the difference."
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Management"
|
||||||
|
subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place."
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Let Panama manage idle timings"
|
||||||
|
detail: IdleLock.serviceState === "active"
|
||||||
|
? "hypridle is running"
|
||||||
|
: "hypridle is " + IdleLock.serviceState
|
||||||
|
controlWidth: 48
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: IdleLock.managed
|
||||||
|
enabled: !IdleLock.busy
|
||||||
|
onToggled: value => IdleLock.setManaged(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Lock the screen now"
|
||||||
|
detail: "Same as the Super+L shortcut"
|
||||||
|
action: "Lock"
|
||||||
|
divider: false
|
||||||
|
onTriggered: Quickshell.execDetached(["loginctl", "lock-session"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: IdleLock.lastError !== ""
|
||||||
|
title: "Idle configuration problem"
|
||||||
|
subtitle: IdleLock.lastError
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Read the idle configuration again"
|
||||||
|
action: "Retry"
|
||||||
|
divider: false
|
||||||
|
onTriggered: IdleLock.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Panama Settings
|
||||||
|
|
||||||
|
The control centre for everything Panama owns. Anything the system owns —
|
||||||
|
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
||||||
|
such rather than half-reimplemented.
|
||||||
|
|
||||||
|
## Adding a setting
|
||||||
|
|
||||||
|
One schema entry. That is the whole job.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
// config/PreferenceSchema.qml
|
||||||
|
{
|
||||||
|
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
|
||||||
|
unit: "px", group: "effects",
|
||||||
|
label: "Blur radius",
|
||||||
|
detail: "Larger is softer and costs more frame time",
|
||||||
|
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```qml
|
||||||
|
// the page
|
||||||
|
SliderRow { setting: "blurSize" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Persistence, validation, clamping, reset, search indexing, and — with a `hypr`
|
||||||
|
block — live application to the compositor and the startup replay all derive
|
||||||
|
from that entry. There is nothing else to register.
|
||||||
|
|
||||||
|
If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
|
||||||
|
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
|
||||||
|
file.
|
||||||
|
|
||||||
|
## The rows
|
||||||
|
|
||||||
|
| Component | For |
|
||||||
|
|---|---|
|
||||||
|
| `SettingsPage` | The page scaffold: title, lede, optional pinned `header` |
|
||||||
|
| `ToggleRow { setting }` | A boolean |
|
||||||
|
| `SliderRow { setting }` | A number; `zeroLabel` renders 0 as "Never"/"Instant"/"None" |
|
||||||
|
| `ChoiceRow { setting }` | An enum, as a segmented control |
|
||||||
|
| `ActionRow` | A button: opens a GNOME panel, runs a one-shot |
|
||||||
|
| `TextRow` | A genuinely read-only fact |
|
||||||
|
|
||||||
|
`TextRow` is for facts, not for settings that were merely expensive to wire.
|
||||||
|
Before Stage 3 more than half of all rows were static text standing in for
|
||||||
|
controls; that is the failure this vocabulary exists to prevent.
|
||||||
|
|
||||||
|
Rows write through `SystemSettings.commitPreference(key, value)`, which routes
|
||||||
|
compositor-backed keys through apply-and-verify and local keys straight to the
|
||||||
|
store. A row never needs to know which kind it holds.
|
||||||
|
|
||||||
|
## Things that will bite you
|
||||||
|
|
||||||
|
**`readAs` describes the answer, not the setting.** `hyprctl getoption` returns
|
||||||
|
the value in a different JSON field per type — `int`, `bool`, `float`, `str`,
|
||||||
|
and `css` for gaps (a four-value box). Declaring the wrong one does not fail
|
||||||
|
loudly: it makes every write to that key look *rejected*, and the user sees an
|
||||||
|
error for a change that worked. `tests/quickshell/schema-hypr-shape-contract.sh`
|
||||||
|
asks the compositor for the real shape of every mapped option.
|
||||||
|
|
||||||
|
**Never trust an exit code from `hyprctl`.** `keyword` refuses to work on a
|
||||||
|
Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
|
||||||
|
0 on syntax and runtime errors too. The only trustworthy signal that a write
|
||||||
|
landed is reading the value back.
|
||||||
|
|
||||||
|
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
|
||||||
|
and it ranges from a half-screen split to the full display. `SliderRow` stacks
|
||||||
|
its control under the label below 520px. Test narrow.
|
||||||
|
|
||||||
|
**Binding an anchor to `undefined` does not reliably release it.** Switching
|
||||||
|
layouts that way left a slider anchored to both edges with the label squeezed
|
||||||
|
into what was left. Position explicitly instead.
|
||||||
|
|
||||||
|
**Inside a `SettingsCard`, `parent` is the card's internal Column.** So
|
||||||
|
`parent.modelData` in a nested `Repeater` is undefined and the rows silently
|
||||||
|
never appear — you get a card with a heading and nothing under it. Address the
|
||||||
|
outer model through an explicit `id`.
|
||||||
|
|
||||||
|
**A `TapHandler` declared as a child of `SettingRow` lands in the trailing
|
||||||
|
slot**, because that is the row's default property, so only the right-hand edge
|
||||||
|
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
|
||||||
|
target.
|
||||||
|
|
||||||
|
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
|
||||||
|
derives the Shell ID from config *content*, not path, so
|
||||||
|
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
|
||||||
|
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
|
||||||
|
single distinct `.qml` file are safe; copying the whole directory is not.
|
||||||
|
|
||||||
|
## Where state lives
|
||||||
|
|
||||||
|
| File | Holds |
|
||||||
|
|---|---|
|
||||||
|
| `~/.config/panama/settings.json` | Everything in the schema. Read by the shell *and* by `hypr/prefs.lua` |
|
||||||
|
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
|
||||||
|
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
|
||||||
|
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
|
||||||
|
|
||||||
|
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
|
||||||
|
skipped one would be worse than having no reset, because nothing would say so.
|
||||||
|
|
||||||
|
## Not stored by Panama
|
||||||
|
|
||||||
|
Timezone and network time are read from and written to `timedatectl` directly.
|
||||||
|
They belong to the machine and are shared with sessions that never see Panama's
|
||||||
|
file; storing a copy would create a second answer to a question the system
|
||||||
|
already answers.
|
||||||
@@ -2,9 +2,12 @@ import QtQuick
|
|||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
title: "Screen Intelligence"
|
||||||
|
lede: "Turn text and codes on screen into content you can use."
|
||||||
|
|
||||||
Component.onCompleted: ScreenIntelligence.refresh()
|
Component.onCompleted: ScreenIntelligence.refresh()
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
@@ -13,35 +16,6 @@ Item {
|
|||||||
onTriggered: Capture.openIntelligence()
|
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 {
|
SettingsCard {
|
||||||
title: "Read anything on screen"
|
title: "Read anything on screen"
|
||||||
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
|
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"
|
detail: "Copy, search, translate, or open detected links"
|
||||||
controlWidth: 160
|
controlWidth: 160
|
||||||
divider: false
|
divider: false
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
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 {
|
SettingsCard {
|
||||||
title: "Local recognition"
|
title: "Local recognition"
|
||||||
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
|
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
|
||||||
|
|
||||||
SettingRow {
|
TextRow {
|
||||||
label: "Text recognition"
|
label: "Text recognition"
|
||||||
detail: "Tesseract with the English language model"
|
detail: "Tesseract with the English language model"
|
||||||
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
|
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
|
||||||
}
|
}
|
||||||
SettingRow {
|
TextRow {
|
||||||
label: "QR & barcodes"
|
label: "QR & barcodes"
|
||||||
detail: "ZBar recognizes codes alongside ordinary text"
|
detail: "ZBar recognizes codes alongside ordinary text"
|
||||||
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
|
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
|
||||||
}
|
}
|
||||||
SettingRow {
|
TextRow {
|
||||||
label: "Privacy"
|
label: "Privacy"
|
||||||
detail: "Only Search, Translate, and Open send the selected result to another application or service"
|
detail: "Only Search, Translate, and Open send the selected result to another application or service"
|
||||||
value: "Local first"
|
value: "Local first"
|
||||||
@@ -90,14 +74,15 @@ Item {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Shortcut"
|
title: "Shortcut"
|
||||||
SettingRow {
|
|
||||||
|
TextRow {
|
||||||
label: "Read a screen selection"
|
label: "Read a screen selection"
|
||||||
detail: "Also available as Read in the Print-screen picker"
|
detail: "Also available as Read in the Print-screen picker"
|
||||||
value: "Super + Shift + S"
|
value: "Super + Shift + S"
|
||||||
controlWidth: 190
|
controlWidth: 190
|
||||||
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
|
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
|
||||||
}
|
}
|
||||||
SettingRow {
|
TextRow {
|
||||||
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
|
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
|
||||||
label: "Install recognition engines"
|
label: "Install recognition engines"
|
||||||
detail: "sudo dnf install -y tesseract zbar"
|
detail: "sudo dnf install -y tesseract zbar"
|
||||||
@@ -105,6 +90,4 @@ Item {
|
|||||||
divider: false
|
divider: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,89 +2,132 @@ import QtQuick
|
|||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
function status(active: bool): string { return active ? "Running" : "Stopped"; }
|
title: "Startup & Services"
|
||||||
|
lede: "A clear view of the background tools that make the desktop feel complete."
|
||||||
|
|
||||||
Flickable {
|
function status(active: bool): string {
|
||||||
anchors.fill: parent
|
return active ? "Running" : "Stopped";
|
||||||
clip: true
|
}
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
Item {
|
||||||
id: content
|
width: parent.width
|
||||||
width: parent.width - 68
|
implicitHeight: refresh.implicitHeight
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Row {
|
SettingsButton {
|
||||||
width: parent.width
|
id: refresh
|
||||||
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
anchors.right: parent.right
|
||||||
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() }
|
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 {
|
SettingsCard {
|
||||||
title: "Your services"
|
title: "Your services"
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Nextcloud"
|
label: "Nextcloud"
|
||||||
detail: "File synchronization and tray status"
|
detail: "File synchronization and tray status"
|
||||||
controlWidth: 190
|
controlWidth: 190
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 10
|
spacing: 10
|
||||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
Text {
|
||||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") }
|
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 {
|
SettingRow {
|
||||||
label: "RustDesk"
|
label: "RustDesk"
|
||||||
detail: "Remote access through the enabled system service"
|
detail: "Remote access through the enabled system service"
|
||||||
controlWidth: 190
|
controlWidth: 190
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 10
|
spacing: 10
|
||||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
Text {
|
||||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") }
|
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 {
|
SettingRow {
|
||||||
label: "KDE Connect"
|
label: "KDE Connect"
|
||||||
detail: "Phone pairing, clipboard, files, and remote controls"
|
detail: "Phone pairing, clipboard, files, and remote controls"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 190
|
controlWidth: 190
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 10
|
spacing: 10
|
||||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
Text {
|
||||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") }
|
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 {
|
SettingsCard {
|
||||||
title: "Desktop foundation"
|
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) }
|
TextRow {
|
||||||
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
|
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 {
|
SettingsCard {
|
||||||
title: "Fedora system settings"
|
title: "Fedora system settings"
|
||||||
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
||||||
SettingRow {
|
|
||||||
|
ActionRow {
|
||||||
label: "Network, Bluetooth, printers, users, and accounts"
|
label: "Network, Bluetooth, printers, users, and accounts"
|
||||||
detail: "GNOME Settings remains searchable from the launcher too"
|
detail: "GNOME Settings remains searchable from the launcher too"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 122
|
action: "Open network"
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") }
|
onTriggered: SystemSettings.openGnomePanel("network")
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ Item {
|
|||||||
property bool divider: true
|
property bool divider: true
|
||||||
property int controlWidth: 150
|
property int controlWidth: 150
|
||||||
|
|
||||||
|
// Rows are inert by default. A row that represents a choice -- picking a
|
||||||
|
// timezone from a list, say -- opts into being clickable across its whole
|
||||||
|
// width, which is a much larger target than the trailing control alone.
|
||||||
|
property bool activatable: false
|
||||||
|
signal activated
|
||||||
|
|
||||||
width: parent ? parent.width : 620
|
width: parent ? parent.width : 620
|
||||||
implicitHeight: Math.max(56, copy.implicitHeight + 20)
|
implicitHeight: Math.max(56, copy.implicitHeight + 20)
|
||||||
|
|
||||||
@@ -85,4 +91,27 @@ Item {
|
|||||||
visible: root.divider
|
visible: root.divider
|
||||||
color: Theme.alpha(Theme.fg, 0.065)
|
color: Theme.alpha(Theme.fg, 0.065)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Declared in this component's own body, so it is a child of the row rather
|
||||||
|
// than of the trailing slot that `trailingData` routes external children to.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.bottomMargin: 1
|
||||||
|
radius: 8
|
||||||
|
z: -1
|
||||||
|
visible: root.activatable && rowHover.hovered
|
||||||
|
color: Theme.alpha(Theme.fg, 0.05)
|
||||||
|
border.width: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: rowHover
|
||||||
|
enabled: root.activatable
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.activatable
|
||||||
|
onTapped: root.activated()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// The scaffold every settings page shares: a scrolling column with a title, a
|
||||||
|
// one-line lede, and consistent margins.
|
||||||
|
//
|
||||||
|
// This existed eleven times as copy-pasted Flickable/Column/x:34/y:30 blocks,
|
||||||
|
// which is a large part of why several pages settled for read-only text instead
|
||||||
|
// of real controls -- adding a page was expensive enough that the cheap thing
|
||||||
|
// won. Pages are now just their content:
|
||||||
|
//
|
||||||
|
// SettingsPage {
|
||||||
|
// title: "Appearance"
|
||||||
|
// lede: "Tokyo Night Moon, tuned for clarity and quiet motion."
|
||||||
|
//
|
||||||
|
// SettingsCard { title: "Windows"; ToggleRow { setting: "blurEnabled" } }
|
||||||
|
// }
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
default property alias content: column.data
|
||||||
|
|
||||||
|
property string title: ""
|
||||||
|
property string lede: ""
|
||||||
|
|
||||||
|
// Anything that should sit above the title and stay put while the rest
|
||||||
|
// scrolls -- the Appearance page pins its live preview here.
|
||||||
|
property Component header: null
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: pinnedHeader
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.leftMargin: 34
|
||||||
|
anchors.rightMargin: 34
|
||||||
|
anchors.topMargin: 30
|
||||||
|
active: root.header !== null
|
||||||
|
sourceComponent: root.header
|
||||||
|
z: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: pageScroll
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
|
||||||
|
clip: true
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: layout
|
||||||
|
|
||||||
|
width: parent.width - 68
|
||||||
|
x: 34
|
||||||
|
y: pinnedHeader.implicitHeight > 0 ? 0 : 30
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.title !== ""
|
||||||
|
text: root.title
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 27
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.lede !== ""
|
||||||
|
text: root.lede
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
bottomPadding: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
width: parent.width
|
||||||
|
spacing: 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ Rectangle {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var hostWindow: null
|
property var hostWindow: null
|
||||||
|
readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready
|
||||||
|
&& pageLoader.item
|
||||||
|
&& pageLoader.item.objectName === "home-phone-page"
|
||||||
|
? pageLoader.item.pageDiagnostics
|
||||||
|
: ({})
|
||||||
|
|
||||||
color: Theme.bg
|
color: Theme.bg
|
||||||
radius: 18
|
radius: 18
|
||||||
@@ -88,11 +93,16 @@ Rectangle {
|
|||||||
case "appearance": return appearancePage;
|
case "appearance": return appearancePage;
|
||||||
case "displays": return displaysPage;
|
case "displays": return displaysPage;
|
||||||
case "connectivity": return connectivityPage;
|
case "connectivity": return connectivityPage;
|
||||||
|
case "home-phone": return homePhonePage;
|
||||||
case "desktop": return desktopPage;
|
case "desktop": return desktopPage;
|
||||||
case "sound": return soundPage;
|
case "sound": return soundPage;
|
||||||
case "notifications": return notificationsPage;
|
case "notifications": return notificationsPage;
|
||||||
case "screen-intelligence": return screenIntelligencePage;
|
case "screen-intelligence": return screenIntelligencePage;
|
||||||
case "shortcuts": return shortcutsPage;
|
case "shortcuts": return shortcutsPage;
|
||||||
|
case "accessibility": return accessibilityPage;
|
||||||
|
case "power": return powerPage;
|
||||||
|
case "datetime": return dateTimePage;
|
||||||
|
case "applications": return applicationsPage;
|
||||||
case "services": return servicesPage;
|
case "services": return servicesPage;
|
||||||
case "about": return aboutPage;
|
case "about": return aboutPage;
|
||||||
default: return homePage;
|
default: return homePage;
|
||||||
@@ -130,9 +140,14 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Component { id: homePage; HomePage {} }
|
Component { id: homePage; HomePage {} }
|
||||||
|
Component { id: applicationsPage; ApplicationsPage {} }
|
||||||
|
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||||
|
Component { id: powerPage; PowerPage {} }
|
||||||
|
Component { id: dateTimePage; DateTimePage {} }
|
||||||
Component { id: appearancePage; AppearancePage {} }
|
Component { id: appearancePage; AppearancePage {} }
|
||||||
Component { id: displaysPage; DisplaysPage {} }
|
Component { id: displaysPage; DisplaysPage {} }
|
||||||
Component { id: connectivityPage; ConnectivityPage {} }
|
Component { id: connectivityPage; ConnectivityPage {} }
|
||||||
|
Component { id: homePhonePage; HomePhonePage {} }
|
||||||
Component { id: desktopPage; DesktopPage {} }
|
Component { id: desktopPage; DesktopPage {} }
|
||||||
Component { id: soundPage; SoundPage {} }
|
Component { id: soundPage; SoundPage {} }
|
||||||
Component { id: notificationsPage; NotificationsPage {} }
|
Component { id: notificationsPage; NotificationsPage {} }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
@@ -8,16 +9,33 @@ Rectangle {
|
|||||||
property string query: searchInput.text.trim().toLowerCase()
|
property string query: searchInput.text.trim().toLowerCase()
|
||||||
signal pageRequested(string page)
|
signal pageRequested(string page)
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
readonly property var destinations: [
|
readonly property var destinations: [
|
||||||
{ page: "home", label: "Home", icon: "\u{F02DC}" },
|
{ page: "home", label: "Home", icon: "\u{F02DC}" },
|
||||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
||||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
||||||
|
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||||
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
|
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
|
||||||
|
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
||||||
|
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||||
|
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||||
|
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||||
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
|
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
|
||||||
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
|
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
|
||||||
]
|
]
|
||||||
@@ -27,8 +45,15 @@ Rectangle {
|
|||||||
border.width: 0
|
border.width: 0
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.fill: parent
|
id: sidebarHeader
|
||||||
anchors.margins: 18
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.leftMargin: 18
|
||||||
|
anchors.rightMargin: 18
|
||||||
|
anchors.topMargin: 18
|
||||||
|
height: implicitHeight
|
||||||
spacing: 12
|
spacing: 12
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
@@ -90,13 +115,117 @@ Rectangle {
|
|||||||
onClicked: searchInput.forceActiveFocus()
|
onClicked: searchInput.forceActiveFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: sidebarScroll
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: sidebarHeader.bottom
|
||||||
|
anchors.bottom: healthFooter.top
|
||||||
|
anchors.leftMargin: 18
|
||||||
|
anchors.rightMargin: 18
|
||||||
|
anchors.topMargin: 12
|
||||||
|
anchors.bottomMargin: 12
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: scrollContent.implicitHeight
|
||||||
|
flickableDirection: Flickable.VerticalFlick
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
clip: true
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
id: scrollContent
|
||||||
|
|
||||||
|
width: sidebarScroll.width
|
||||||
|
|
||||||
|
// ── Search results ──────────────────────────────────────────────
|
||||||
|
// Typing searches the settings themselves, not page names.
|
||||||
|
Column {
|
||||||
|
id: searchResults
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 4
|
spacing: 3
|
||||||
|
visible: root.query !== ""
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
leftPadding: 4
|
||||||
|
bottomPadding: 4
|
||||||
|
text: root.results.length === 0
|
||||||
|
? "Nothing matches"
|
||||||
|
: root.results.length + (root.results.length === 1 ? " result" : " results")
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.destinations.filter(item => root.query === "" || item.label.toLowerCase().includes(root.query))
|
model: root.results
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: hit
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: 44
|
||||||
|
radius: 10
|
||||||
|
color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent"
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: hit.modelData.label
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: hit.modelData.kind === "shortcut"
|
||||||
|
? hit.modelData.detail
|
||||||
|
: root.pageLabel(hit.modelData.page)
|
||||||
|
color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hitMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
root.pageRequested(hit.modelData.page);
|
||||||
|
searchInput.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: navigationList
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
visible: root.query === ""
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.destinations
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: navItem
|
id: navItem
|
||||||
@@ -159,8 +288,11 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
id: healthFooter
|
||||||
|
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import qs.services
|
|||||||
FloatingWindow {
|
FloatingWindow {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
||||||
|
|
||||||
title: "Panama Settings"
|
title: "Panama Settings"
|
||||||
visible: ShellState.settingsOpen
|
visible: ShellState.settingsOpen
|
||||||
implicitWidth: 1120
|
implicitWidth: 1120
|
||||||
@@ -23,6 +25,7 @@ FloatingWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsShell {
|
SettingsShell {
|
||||||
|
id: settingsShell
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hostWindow: root
|
hostWindow: root
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Captures a key chord for rebinding.
|
||||||
|
//
|
||||||
|
// Shown in place of a shortcut's chord while it is being changed. It takes
|
||||||
|
// keyboard focus, waits for a non-modifier key, and reports the chord in the
|
||||||
|
// form hypr/keybinds.lua uses.
|
||||||
|
//
|
||||||
|
// Modifier-only presses are ignored rather than accepted, because every press
|
||||||
|
// of a chord passes through them: holding Super to type Super+K would otherwise
|
||||||
|
// be captured as "SUPER" the moment the modifier went down.
|
||||||
|
//
|
||||||
|
// Escape cancels. Not every Qt key has a keysym name Hyprland would accept, so
|
||||||
|
// an unmapped key is refused with a message rather than written as something
|
||||||
|
// that would silently fail to bind.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
FocusScope {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
signal captured(string chord)
|
||||||
|
signal cancelled
|
||||||
|
|
||||||
|
property string message: ""
|
||||||
|
|
||||||
|
implicitWidth: 230
|
||||||
|
implicitHeight: 30
|
||||||
|
|
||||||
|
// Qt key codes to the keysym names Hyprland expects. Letters and digits
|
||||||
|
// fall out of the ASCII range; these are the rest that come up in practice.
|
||||||
|
readonly property var namedKeys: ({
|
||||||
|
0x01000000: "Escape",
|
||||||
|
0x01000001: "Tab",
|
||||||
|
0x01000004: "Return",
|
||||||
|
0x01000005: "Return",
|
||||||
|
0x01000003: "BackSpace",
|
||||||
|
0x01000006: "Insert",
|
||||||
|
0x01000007: "Delete",
|
||||||
|
0x01000010: "Home",
|
||||||
|
0x01000011: "End",
|
||||||
|
0x01000016: "Page_Up",
|
||||||
|
0x01000017: "Page_Down",
|
||||||
|
0x01000012: "left",
|
||||||
|
0x01000013: "up",
|
||||||
|
0x01000014: "right",
|
||||||
|
0x01000015: "down",
|
||||||
|
0x20: "space",
|
||||||
|
0x2c: "comma",
|
||||||
|
0x2e: "period",
|
||||||
|
0x2f: "slash",
|
||||||
|
0x3b: "semicolon",
|
||||||
|
0x27: "apostrophe",
|
||||||
|
0x5b: "bracketleft",
|
||||||
|
0x5d: "bracketright",
|
||||||
|
0x5c: "backslash",
|
||||||
|
0x60: "grave",
|
||||||
|
0x2d: "minus",
|
||||||
|
0x3d: "equal",
|
||||||
|
0x01000009: "Print"
|
||||||
|
})
|
||||||
|
|
||||||
|
function keysymFor(key: int): string {
|
||||||
|
if (key >= 0x41 && key <= 0x5a) // A-Z
|
||||||
|
return String.fromCharCode(key);
|
||||||
|
if (key >= 0x30 && key <= 0x39) // 0-9
|
||||||
|
return String.fromCharCode(key);
|
||||||
|
if (key >= 0x01000030 && key <= 0x0100003b) // F1-F12
|
||||||
|
return "F" + (key - 0x01000030 + 1);
|
||||||
|
return root.namedKeys[key] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModifierOnly(key: int): bool {
|
||||||
|
return key === 0x01000020 || key === 0x01000021 || key === 0x01000022
|
||||||
|
|| key === 0x01000023 || key === 0x01000024;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 9
|
||||||
|
color: Theme.alpha(Theme.accent, 0.14)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - 16
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
text: root.message !== "" ? root.message : "Press a shortcut… Esc to cancel"
|
||||||
|
color: root.message !== "" ? Theme.warn : Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
event.accepted = true;
|
||||||
|
|
||||||
|
if (event.key === 0x01000000) { // Escape
|
||||||
|
root.cancelled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.isModifierOnly(event.key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const keysym = root.keysymFor(event.key);
|
||||||
|
if (keysym === "") {
|
||||||
|
root.message = "That key cannot be used";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (event.modifiers & Qt.MetaModifier) parts.push("SUPER");
|
||||||
|
if (event.modifiers & Qt.ControlModifier) parts.push("CTRL");
|
||||||
|
if (event.modifiers & Qt.AltModifier) parts.push("ALT");
|
||||||
|
if (event.modifiers & Qt.ShiftModifier) parts.push("SHIFT");
|
||||||
|
|
||||||
|
if (parts.length === 0 && keysym.length === 1) {
|
||||||
|
// A bare letter or digit would swallow ordinary typing.
|
||||||
|
root.message = "Add a modifier";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(keysym);
|
||||||
|
root.captured(parts.join(" + "));
|
||||||
|
}
|
||||||
|
|
||||||
|
onActiveFocusChanged: {
|
||||||
|
if (!activeFocus)
|
||||||
|
root.cancelled();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,73 +1,179 @@
|
|||||||
|
// Input & Shortcuts.
|
||||||
|
//
|
||||||
|
// The shortcut list is generated from `hyprctl binds -j` rather than typed out
|
||||||
|
// here. The previous version was a hand-maintained array of nineteen entries
|
||||||
|
// against a real keymap of a hundred and thirteen: it could not show the rest,
|
||||||
|
// and it went stale the moment a bind changed. Every bind now carries its own
|
||||||
|
// description in hypr/keybinds.lua, and this page just groups and renders them.
|
||||||
|
//
|
||||||
|
// The hardware settings above the list are real controls. Keyboard layout,
|
||||||
|
// repeat behaviour, and pointer response are Hyprland's, so Panama owns them;
|
||||||
|
// device-specific configuration stays with GNOME.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
readonly property var shortcuts: [
|
|
||||||
{ key: "Super + T", action: "Terminal" },
|
|
||||||
{ key: "Super + N", action: "Neovim in the current directory" },
|
|
||||||
{ key: "Super + W", action: "Web browser" },
|
|
||||||
{ key: "Super + F", action: "Files" },
|
|
||||||
{ key: "Super + I", action: "Panama Settings" },
|
|
||||||
{ key: "Super + Space", action: "Launcher" },
|
|
||||||
{ key: "Super + Grave", action: "Continuum overview" },
|
|
||||||
{ key: "Super + V", action: "Clipboard history" },
|
|
||||||
{ key: "Super + S", action: "Quick Settings" },
|
|
||||||
{ key: "Super + B", action: "Notifications" },
|
|
||||||
{ key: "Super + Shift + S", action: "Screen Intelligence" },
|
|
||||||
{ key: "Super + Shift + F", action: "Focus session" },
|
|
||||||
{ key: "Alt + H / L", action: "Previous / next workspace" },
|
|
||||||
{ key: "Alt + Shift + H / L", action: "Move window between workspaces" },
|
|
||||||
{ key: "Super + H / J / K / L", action: "Focus a tiled window" },
|
|
||||||
{ key: "Super + Shift + H / J / K / L", action: "Move a tiled window" },
|
|
||||||
{ key: "Super + Shift + X", action: "Send window to scratchpad" },
|
|
||||||
{ key: "Super + X", action: "Toggle scratchpad" },
|
|
||||||
{ key: "Print", action: "Capture, record, or read" }
|
|
||||||
]
|
|
||||||
|
|
||||||
Flickable {
|
// The chord of the bind currently being re-recorded, in the Lua form; empty
|
||||||
anchors.fill: parent
|
// when nothing is being captured. Held here rather than per row so that
|
||||||
clip: true
|
// starting a new capture cancels any other.
|
||||||
contentWidth: width
|
property string capturingChord: ""
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
title: "Input & Shortcuts"
|
||||||
id: content
|
lede: "The Forge mental model, carried forward into native tiling."
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text { text: "Input & Shortcuts"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
|
||||||
Text { text: "The Forge mental model, carried forward into native tiling."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Panama shortcuts"
|
title: "Keyboard"
|
||||||
Repeater {
|
|
||||||
model: root.shortcuts
|
SliderRow { setting: "keyRepeatDelay" }
|
||||||
SettingRow {
|
SliderRow { setting: "keyRepeatRate" }
|
||||||
required property var modelData
|
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||||
required property int index
|
|
||||||
label: modelData.action
|
|
||||||
value: modelData.key
|
|
||||||
controlWidth: 210
|
|
||||||
divider: index < root.shortcuts.length - 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pointer"
|
||||||
|
|
||||||
|
ChoiceRow { setting: "followMouse" }
|
||||||
|
SliderRow { setting: "pointerSensitivity" }
|
||||||
|
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Hardware input"
|
title: "Hardware input"
|
||||||
SettingRow {
|
|
||||||
|
ActionRow {
|
||||||
label: "Mouse, touchpad, and keyboard devices"
|
label: "Mouse, touchpad, and keyboard devices"
|
||||||
detail: "Use Fedora's hardware-backed input panels"
|
detail: "Device-specific settings stay with Fedora's hardware-backed panels"
|
||||||
|
action: "Open keyboard"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 126
|
onTriggered: SystemSettings.openGnomePanel("keyboard")
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One card per group, built from what the compositor actually has bound.
|
||||||
|
//
|
||||||
|
// Both Repeaters address their model through an explicit id. Inside a
|
||||||
|
// SettingsCard the surrounding `parent` is the card's internal Column, not
|
||||||
|
// the card, so `parent.modelData` is undefined there and the rows silently
|
||||||
|
// never appear -- the cards render with their heading and nothing under it.
|
||||||
|
Repeater {
|
||||||
|
model: Keybinds.grouped()
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
id: groupCard
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
title: groupCard.modelData.name
|
||||||
|
subtitle: groupCard.modelData.binds.length === 1
|
||||||
|
? "1 shortcut"
|
||||||
|
: `${groupCard.modelData.binds.length} shortcuts`
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: bindRows
|
||||||
|
|
||||||
|
model: groupCard.modelData.binds
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: bindRow
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
|
||||||
|
readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
|
||||||
|
|
||||||
|
label: bindRow.modelData.description
|
||||||
|
detail: bindRow.overridden
|
||||||
|
? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
|
||||||
|
: ""
|
||||||
|
controlWidth: 300
|
||||||
|
divider: bindRow.index < bindRows.count - 1
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 300
|
||||||
|
height: 30
|
||||||
|
|
||||||
|
ShortcutCapture {
|
||||||
|
anchors.right: parent.right
|
||||||
|
width: 230
|
||||||
|
height: 30
|
||||||
|
visible: bindRow.capturing
|
||||||
|
focus: bindRow.capturing
|
||||||
|
onCaptured: chord => {
|
||||||
|
Keybinds.rebind(bindRow.modelData.luaChord, chord);
|
||||||
|
root.capturingChord = "";
|
||||||
|
}
|
||||||
|
onCancelled: root.capturingChord = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: !bindRow.capturing
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: bindRow.modelData.chord
|
||||||
|
color: bindRow.overridden ? Theme.accent : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Change"
|
||||||
|
enabled: !Keybinds.reloading && !bindRow.modelData.mouse
|
||||||
|
onClicked: root.capturingChord = bindRow.modelData.luaChord
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: bindRow.overridden
|
||||||
|
text: "Reset"
|
||||||
|
enabled: !Keybinds.reloading
|
||||||
|
onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Object.keys(Keybinds.overrides).length > 0
|
||||||
|
title: "Changed shortcuts"
|
||||||
|
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Restore every shipped shortcut"
|
||||||
|
detail: Object.keys(Keybinds.overrides).length
|
||||||
|
+ (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
|
||||||
|
action: "Restore all"
|
||||||
|
divider: false
|
||||||
|
enabled: !Keybinds.reloading
|
||||||
|
onTriggered: Keybinds.resetAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Keybinds.lastError !== ""
|
||||||
|
title: "Shortcuts unavailable"
|
||||||
|
subtitle: Keybinds.lastError
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Read the keymap again"
|
||||||
|
detail: "Shortcuts are read from the running compositor"
|
||||||
|
action: "Retry"
|
||||||
|
divider: false
|
||||||
|
onTriggered: Keybinds.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// A numeric setting, bound to a schema key by name.
|
||||||
|
//
|
||||||
|
// SliderRow { setting: "windowRounding" }
|
||||||
|
//
|
||||||
|
// Range, step, label, explanation, and unit all come from PreferenceSchema.
|
||||||
|
//
|
||||||
|
// Three behaviours worth knowing:
|
||||||
|
//
|
||||||
|
// * The row is responsive. The Settings window is a normal tiled window, so
|
||||||
|
// its width is whatever the layout gives it -- anywhere from a half-screen
|
||||||
|
// split to the full 4500px display, and `implicitWidth` is only a hint.
|
||||||
|
// Below a usable inline width the slider moves onto its own line under the
|
||||||
|
// label instead of squeezing the explanation into a five-line column.
|
||||||
|
// * The readout follows the drag immediately, but the value is only committed
|
||||||
|
// after a short quiet period. Compositor-backed settings are applied and
|
||||||
|
// verified one batch at a time, and a slider fires dozens of changes per
|
||||||
|
// second -- committing each would spend the whole drag rejecting
|
||||||
|
// overlapping writes.
|
||||||
|
// * Between commits the row shows what you are dragging; once settled it
|
||||||
|
// shows what is actually stored. If the compositor refuses a value the row
|
||||||
|
// falls back to the stored one rather than displaying a value nothing
|
||||||
|
// accepted.
|
||||||
|
//
|
||||||
|
// This does not extend SettingRow: that component fixes the control to a
|
||||||
|
// trailing column of a set width, which is the layout this row needs to be able
|
||||||
|
// to abandon. The label, explanation, and divider match it exactly.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string setting
|
||||||
|
|
||||||
|
property bool divider: true
|
||||||
|
property string zeroLabel: ""
|
||||||
|
|
||||||
|
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||||
|
readonly property string label: root.spec ? root.spec.label : root.setting
|
||||||
|
readonly property string detail: root.spec ? root.spec.detail : ""
|
||||||
|
readonly property real minimum: root.spec && root.spec.min !== undefined ? root.spec.min : 0
|
||||||
|
readonly property real maximum: root.spec && root.spec.max !== undefined ? root.spec.max : 100
|
||||||
|
readonly property real step: root.spec && root.spec.step !== undefined ? root.spec.step : 1
|
||||||
|
readonly property string unit: root.spec && root.spec.unit !== undefined ? root.spec.unit : ""
|
||||||
|
|
||||||
|
readonly property real stored: {
|
||||||
|
const value = DesktopPreferences.get(root.setting);
|
||||||
|
return typeof value === "number" ? value : root.minimum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown while dragging; -1 means "nothing pending, show what is stored".
|
||||||
|
property real pending: -1
|
||||||
|
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
|
||||||
|
|
||||||
|
// Below this the label and a usable slider cannot share a line without one
|
||||||
|
// of them becoming useless.
|
||||||
|
readonly property bool inline: width >= 520
|
||||||
|
|
||||||
|
// A fixed trailing width rather than a share of the row. A proportional
|
||||||
|
// control looks reasonable at 900px and absurd at 4500px, where the slider
|
||||||
|
// would be a metre long next to a two-word label -- and this window is
|
||||||
|
// tiled, so it really can be that wide.
|
||||||
|
readonly property int controlSpan: 300
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
implicitHeight: root.inline
|
||||||
|
? Math.max(56, copy.implicitHeight + 20)
|
||||||
|
: copy.implicitHeight + 32 + 30
|
||||||
|
|
||||||
|
function quantise(ratio: real): real {
|
||||||
|
const raw = root.minimum + ratio * (root.maximum - root.minimum);
|
||||||
|
const snapped = Math.round(raw / root.step) * root.step;
|
||||||
|
const clamped = Math.max(root.minimum, Math.min(root.maximum, snapped));
|
||||||
|
// Steps below 1 are fractional (opacity, pointer speed); rounding to two
|
||||||
|
// places keeps 0.8500000000000001 out of the readout and the store.
|
||||||
|
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function display(value: real): string {
|
||||||
|
if (value === 0 && root.zeroLabel !== "")
|
||||||
|
return root.zeroLabel;
|
||||||
|
const text = root.step < 1 ? value.toFixed(2) : String(value);
|
||||||
|
return root.unit === "" ? text : `${text} ${root.unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both children are positioned explicitly rather than by anchors. Binding
|
||||||
|
// an anchor to `undefined` to switch layouts does not reliably release it,
|
||||||
|
// which left the slider anchored to both edges and the label squeezed into
|
||||||
|
// whatever was left.
|
||||||
|
Column {
|
||||||
|
id: copy
|
||||||
|
|
||||||
|
x: 0
|
||||||
|
y: root.inline ? (root.height - height) / 2 : 10
|
||||||
|
width: root.inline ? root.width - root.controlSpan - 20 : root.width
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.label
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.detail !== ""
|
||||||
|
text: root.detail
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: control
|
||||||
|
|
||||||
|
width: root.inline ? root.controlSpan : root.width
|
||||||
|
height: 32
|
||||||
|
x: root.inline ? root.width - width : 0
|
||||||
|
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
id: slider
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: readout.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
value: root.maximum > root.minimum
|
||||||
|
? (root.shown - root.minimum) / (root.maximum - root.minimum)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
onMoved: ratio => {
|
||||||
|
root.pending = root.quantise(ratio);
|
||||||
|
commitTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: readout
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 58
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
text: root.display(root.shown)
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
// The readout changes digit by digit under the pointer; tabular
|
||||||
|
// figures stop it twitching sideways as it does.
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 1
|
||||||
|
visible: root.divider
|
||||||
|
color: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: commitTimer
|
||||||
|
interval: 140
|
||||||
|
onTriggered: {
|
||||||
|
if (root.pending < 0)
|
||||||
|
return;
|
||||||
|
SystemSettings.commitPreference(root.setting, root.pending);
|
||||||
|
// Hand the display back to the stored value. If the write was
|
||||||
|
// refused, the row snaps back to what is really in effect.
|
||||||
|
releaseTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: releaseTimer
|
||||||
|
interval: 160
|
||||||
|
onTriggered: root.pending = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,50 +4,61 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.quicksettings
|
import qs.modules.quicksettings
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
Flickable {
|
title: "Sound"
|
||||||
anchors.fill: parent
|
lede: "Live PipeWire output, input, and device selection."
|
||||||
clip: true
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
|
||||||
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 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Output"
|
title: "Output"
|
||||||
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
|
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) }
|
AudioSlider {
|
||||||
AudioDeviceList { width: parent.width; output: true; maxHeight: 190 }
|
width: parent.width
|
||||||
|
node: Pipewire.defaultAudioSink
|
||||||
|
output: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Theme.alpha(Theme.fg, 0.06)
|
||||||
|
}
|
||||||
|
AudioDeviceList {
|
||||||
|
width: parent.width
|
||||||
|
output: true
|
||||||
|
maxHeight: 190
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Input"
|
title: "Input"
|
||||||
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
|
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) }
|
AudioSlider {
|
||||||
AudioDeviceList { width: parent.width; output: false; maxHeight: 160 }
|
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 {
|
SettingsCard {
|
||||||
title: "Advanced sound"
|
title: "Advanced sound"
|
||||||
SettingRow {
|
|
||||||
|
ActionRow {
|
||||||
label: "Application volumes and profiles"
|
label: "Application volumes and profiles"
|
||||||
detail: "Open Fedora's complete sound panel"
|
detail: "Open Fedora's complete sound panel"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 104
|
action: "Open panel"
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") }
|
onTriggered: SystemSettings.openGnomePanel("sound")
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// A row that only reports something.
|
||||||
|
//
|
||||||
|
// TextRow { label: "Graphics"; detail: "Rendering device"; value: "AMD Radeon" }
|
||||||
|
//
|
||||||
|
// Genuinely read-only facts -- the active display's identity, a version string,
|
||||||
|
// a detected capability -- belong here. A setting the user could reasonably
|
||||||
|
// change does NOT: before Stage 3 more than half of Panama's settings rows were
|
||||||
|
// static text standing in for a control that was simply expensive to add, and
|
||||||
|
// this component exists for the honest remainder rather than to make that easy
|
||||||
|
// to do again.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
controlWidth: 210
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// A boolean setting, bound to a schema key by name.
|
||||||
|
//
|
||||||
|
// ToggleRow { setting: "blurEnabled" }
|
||||||
|
//
|
||||||
|
// Label, explanation, and validation all come from PreferenceSchema, so a row
|
||||||
|
// cannot drift from the setting it edits, and the writing side does not care
|
||||||
|
// whether the key is stored locally or applied to the compositor first.
|
||||||
|
// Override `label` or `detail` only when a page needs different wording than
|
||||||
|
// the schema's default.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string setting
|
||||||
|
|
||||||
|
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||||
|
readonly property bool checked: DesktopPreferences.get(root.setting) === true
|
||||||
|
|
||||||
|
label: root.spec ? root.spec.label : root.setting
|
||||||
|
detail: root.spec ? root.spec.detail : ""
|
||||||
|
controlWidth: 48
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: root.checked
|
||||||
|
onToggled: value => SystemSettings.commitPreference(root.setting, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// The wallpaper grid.
|
||||||
|
//
|
||||||
|
// A thumbnail grid rather than a file path field: choosing a background is the
|
||||||
|
// one setting where the value IS the picture, and typing a path to something
|
||||||
|
// you cannot see is the worst possible version of it.
|
||||||
|
//
|
||||||
|
// Images are loaded asynchronously and at a fraction of their real size --
|
||||||
|
// several of the candidates here are 8-12 MB, and decoding them at full
|
||||||
|
// resolution to draw a 150px tile would cost more memory than the rest of the
|
||||||
|
// shell put together.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Effects
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
implicitHeight: grid.implicitHeight
|
||||||
|
|
||||||
|
readonly property int columns: Math.max(2, Math.floor(width / 190))
|
||||||
|
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: grid
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
columns: root.columns
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Wallpaper.available
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: tile
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool current: Wallpaper.active === tile.modelData
|
||||||
|
|
||||||
|
width: root.cellWidth
|
||||||
|
height: Math.round(root.cellWidth * 9 / 16)
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: Theme.alpha(Theme.bgDark, 0.6)
|
||||||
|
clip: true
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: thumbnail
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
source: "file://" + tile.modelData
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
cache: false
|
||||||
|
// Decode to roughly the size actually drawn. Without this a
|
||||||
|
// grid of 12MB photographs decodes at full resolution.
|
||||||
|
sourceSize.width: 400
|
||||||
|
sourceSize.height: 240
|
||||||
|
|
||||||
|
// Several of these are 8-12MB originals, so a tile can sit
|
||||||
|
// empty for a second or two. Fading in on ready makes that
|
||||||
|
// read as loading rather than as a broken image, and the
|
||||||
|
// fade runs once per tile rather than continuously.
|
||||||
|
opacity: status === Image.Ready ? 1 : 0
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutQuad }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: thumbnail.status === Image.Loading
|
||||||
|
text: "Loading…"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - 20
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
visible: thumbnail.status === Image.Error
|
||||||
|
text: "Could not read this image"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
// The prism marks the active wallpaper, the same way it marks
|
||||||
|
// the focused window and the selected sidebar entry.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: tile.current
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 2
|
||||||
|
border.color: Theme.accent
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 26
|
||||||
|
visible: tile.current || hover.hovered
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.bgDark, 0.0) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.bgDark, 0.88) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: 7
|
||||||
|
text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData)
|
||||||
|
color: tile.current ? Theme.accent : Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: tile.current ? Font.DemiBold : Font.Normal
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler { id: hover }
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: Wallpaper.set(tile.modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: Wallpaper.available.length === 0 && !Wallpaper.scanning
|
||||||
|
width: parent.width - 40
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ module qs.modules.settings
|
|||||||
AboutPage 1.0 AboutPage.qml
|
AboutPage 1.0 AboutPage.qml
|
||||||
AppearancePage 1.0 AppearancePage.qml
|
AppearancePage 1.0 AppearancePage.qml
|
||||||
ConnectivityPage 1.0 ConnectivityPage.qml
|
ConnectivityPage 1.0 ConnectivityPage.qml
|
||||||
|
HomePhonePage 1.0 HomePhonePage.qml
|
||||||
|
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
||||||
|
AvailableLightRow 1.0 AvailableLightRow.qml
|
||||||
DesktopPage 1.0 DesktopPage.qml
|
DesktopPage 1.0 DesktopPage.qml
|
||||||
DisplaysPage 1.0 DisplaysPage.qml
|
DisplaysPage 1.0 DisplaysPage.qml
|
||||||
HomePage 1.0 HomePage.qml
|
HomePage 1.0 HomePage.qml
|
||||||
@@ -17,3 +20,20 @@ SettingsToggle 1.0 SettingsToggle.qml
|
|||||||
SettingsWindow 1.0 SettingsWindow.qml
|
SettingsWindow 1.0 SettingsWindow.qml
|
||||||
ShortcutsPage 1.0 ShortcutsPage.qml
|
ShortcutsPage 1.0 ShortcutsPage.qml
|
||||||
SoundPage 1.0 SoundPage.qml
|
SoundPage 1.0 SoundPage.qml
|
||||||
|
SettingsPage 1.0 SettingsPage.qml
|
||||||
|
ToggleRow 1.0 ToggleRow.qml
|
||||||
|
SliderRow 1.0 SliderRow.qml
|
||||||
|
ChoiceRow 1.0 ChoiceRow.qml
|
||||||
|
ActionRow 1.0 ActionRow.qml
|
||||||
|
TextRow 1.0 TextRow.qml
|
||||||
|
DesktopPreview 1.0 DesktopPreview.qml
|
||||||
|
PowerPage 1.0 PowerPage.qml
|
||||||
|
DateTimePage 1.0 DateTimePage.qml
|
||||||
|
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||||
|
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||||
|
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||||
|
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||||
|
DockAppPicker 1.0 DockAppPicker.qml
|
||||||
|
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||||
|
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||||
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.modules.quicksettings
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
// Component-only diagnostic surface. The contract replaces the two services in
|
||||||
|
// its copied configuration with fixture singletons before this file is run.
|
||||||
|
ShellRoot {
|
||||||
|
PhoneActions {
|
||||||
|
id: phoneActions
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "phone-controls-test"
|
||||||
|
|
||||||
|
function fixture(name: string): void {
|
||||||
|
KdeConnect.available = false;
|
||||||
|
KdeConnect.transferActive = false;
|
||||||
|
KdeConnect.lastError = "";
|
||||||
|
|
||||||
|
if (name === "offline") {
|
||||||
|
KdeConnect.devices = [{
|
||||||
|
id: "fixture-phone",
|
||||||
|
name: "Fixture iPhone",
|
||||||
|
type: "phone",
|
||||||
|
paired: true,
|
||||||
|
reachable: false,
|
||||||
|
actions: []
|
||||||
|
}];
|
||||||
|
} else if (name === "unsupported") {
|
||||||
|
KdeConnect.available = true;
|
||||||
|
KdeConnect.devices = [{
|
||||||
|
id: "fixture-phone",
|
||||||
|
name: "Fixture iPhone",
|
||||||
|
type: "phone",
|
||||||
|
paired: true,
|
||||||
|
reachable: true,
|
||||||
|
actions: ["share"]
|
||||||
|
}];
|
||||||
|
} else if (name === "transfer") {
|
||||||
|
KdeConnect.available = true;
|
||||||
|
KdeConnect.transferActive = true;
|
||||||
|
KdeConnect.devices = [{
|
||||||
|
id: "fixture-phone",
|
||||||
|
name: "Fixture iPhone",
|
||||||
|
type: "phone",
|
||||||
|
paired: true,
|
||||||
|
reachable: true,
|
||||||
|
actions: ["share", "clipboard", "ring"]
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
actions: phoneActions.actionModels.map(action => ({
|
||||||
|
id: action.id,
|
||||||
|
enabled: phoneActions.actionEnabled(action.id)
|
||||||
|
})),
|
||||||
|
bluebubblesAvailable: SystemSettings.bluebubblesAvailable,
|
||||||
|
appLaunches: SystemSettings.launchCount,
|
||||||
|
phoneActions: KdeConnect.actionCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "preference-schema-test"
|
||||||
|
|
||||||
|
// Values arrive as a JSON object so the contract can exercise real
|
||||||
|
// types -- booleans, integers, and strings -- through one entry point.
|
||||||
|
// Returns the per-key result of set(), so a rejection is observable
|
||||||
|
// rather than inferred from the value not changing.
|
||||||
|
function applyJson(payload: string): string {
|
||||||
|
const requested = JSON.parse(payload);
|
||||||
|
const accepted = {};
|
||||||
|
for (const key in requested)
|
||||||
|
accepted[key] = DesktopPreferences.set(key, requested[key]);
|
||||||
|
return JSON.stringify(accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every schema key and its effective value.
|
||||||
|
function dump(): string {
|
||||||
|
const out = {};
|
||||||
|
for (const entry of PreferenceSchema.entries)
|
||||||
|
out[entry.key] = DesktopPreferences.get(entry.key);
|
||||||
|
return JSON.stringify(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The raw in-memory store, including keys this build does not know.
|
||||||
|
function raw(): string {
|
||||||
|
return JSON.stringify(DesktopPreferences.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaults(): string {
|
||||||
|
return JSON.stringify(PreferenceSchema.defaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
DesktopPreferences.resetDesktopDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyCount(): int {
|
||||||
|
return PreferenceSchema.entries.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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:]))
|
||||||
+487
@@ -0,0 +1,487 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Private Home Assistant REST boundary for the Panama Control Center."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
ENV_KEYS = (
|
||||||
|
"PANAMA_HOME_ASSISTANT_URL",
|
||||||
|
"PANAMA_HOME_ASSISTANT_TOKEN",
|
||||||
|
"PANAMA_HOME_ASSISTANT_ENTITIES",
|
||||||
|
)
|
||||||
|
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
|
||||||
|
GNOME_EXTENSION = (
|
||||||
|
pathlib.Path.home()
|
||||||
|
/ ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github"
|
||||||
|
)
|
||||||
|
GNOME_SCHEMA_DIR = GNOME_EXTENSION / "schemas"
|
||||||
|
GNOME_SCHEMA = "org.gnome.shell.extensions.hass-data"
|
||||||
|
PANAMA_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
|
||||||
|
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeError(RuntimeError):
|
||||||
|
"""An error code safe to pass across the shell boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
base_url: str
|
||||||
|
token: str
|
||||||
|
entity_ids: tuple[str, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.base_url and self.token)
|
||||||
|
|
||||||
|
|
||||||
|
def compact_json(value: dict[str, object]) -> str:
|
||||||
|
return json.dumps(value, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_panama_env(path: pathlib.Path = PANAMA_ENV) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return values
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
if stripped.startswith("export "):
|
||||||
|
stripped = stripped[7:].lstrip()
|
||||||
|
if "=" not in stripped:
|
||||||
|
continue
|
||||||
|
key, raw_value = stripped.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
if key not in ENV_KEYS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = shlex.split(raw_value, comments=True, posix=True)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if len(parsed) == 1:
|
||||||
|
values[key] = parsed[0]
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_url(value: str) -> str:
|
||||||
|
value = value.strip().rstrip("/")
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
parsed = urllib.parse.urlsplit(value)
|
||||||
|
if (
|
||||||
|
parsed.scheme not in {"http", "https"}
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.username
|
||||||
|
or parsed.password
|
||||||
|
):
|
||||||
|
raise ValueError("invalid-url")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_entity_ids(value: str | Sequence[str]) -> tuple[str, ...]:
|
||||||
|
candidates = value.split(",") if isinstance(value, str) else value
|
||||||
|
result: list[str] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
entity_id = str(candidate).strip()
|
||||||
|
if not entity_id:
|
||||||
|
continue
|
||||||
|
if not ENTITY_ID.fullmatch(entity_id):
|
||||||
|
raise ValueError("invalid-entity")
|
||||||
|
if entity_id not in result:
|
||||||
|
result.append(entity_id)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gsettings_string(output: str) -> str:
|
||||||
|
try:
|
||||||
|
value = ast.literal_eval(output.strip())
|
||||||
|
except (SyntaxError, ValueError):
|
||||||
|
return ""
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gsettings_array(output: str) -> tuple[str, ...]:
|
||||||
|
stripped = output.strip()
|
||||||
|
if stripped.startswith("@as "):
|
||||||
|
stripped = stripped[4:].strip()
|
||||||
|
try:
|
||||||
|
value = ast.literal_eval(stripped)
|
||||||
|
except (SyntaxError, ValueError):
|
||||||
|
return ()
|
||||||
|
if not isinstance(value, (list, tuple)):
|
||||||
|
return ()
|
||||||
|
return tuple(item for item in value if isinstance(item, str))
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
command: list[str],
|
||||||
|
*,
|
||||||
|
runner: Runner = subprocess.run,
|
||||||
|
timeout: float = 5,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return runner(
|
||||||
|
command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gsettings_value(key: str, runner: Runner = subprocess.run) -> str:
|
||||||
|
if not GNOME_SCHEMA_DIR.is_dir():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
result = run_command(
|
||||||
|
[
|
||||||
|
"gsettings",
|
||||||
|
"--schemadir",
|
||||||
|
str(GNOME_SCHEMA_DIR),
|
||||||
|
"get",
|
||||||
|
GNOME_SCHEMA,
|
||||||
|
key,
|
||||||
|
],
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def secret_service_token(runner: Runner = subprocess.run) -> str:
|
||||||
|
try:
|
||||||
|
result = run_command(
|
||||||
|
["secret-tool", "lookup", "token_string", "user_token"],
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def load_legacy_config(runner: Runner = subprocess.run) -> Config:
|
||||||
|
base_url = parse_gsettings_string(gsettings_value("hass-url", runner))
|
||||||
|
entity_ids = parse_gsettings_array(
|
||||||
|
gsettings_value("hass-enabled-entities", runner)
|
||||||
|
)
|
||||||
|
token = secret_service_token(runner)
|
||||||
|
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config(
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
legacy: Callable[[], Config] = load_legacy_config,
|
||||||
|
) -> Config:
|
||||||
|
private_env = read_panama_env()
|
||||||
|
private_env.update(dict(os.environ if env is None else env))
|
||||||
|
|
||||||
|
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
||||||
|
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
|
||||||
|
entities_value = private_env.get("PANAMA_HOME_ASSISTANT_ENTITIES", "").strip()
|
||||||
|
|
||||||
|
legacy_config = Config("", "", ())
|
||||||
|
if not (url_value and token_value and entities_value):
|
||||||
|
try:
|
||||||
|
legacy_config = legacy()
|
||||||
|
except (OSError, subprocess.SubprocessError, ValueError):
|
||||||
|
legacy_config = Config("", "", ())
|
||||||
|
|
||||||
|
base_url = normalize_url(url_value or legacy_config.base_url)
|
||||||
|
token = token_value or legacy_config.token
|
||||||
|
entity_ids = (
|
||||||
|
parse_entity_ids(entities_value)
|
||||||
|
if entities_value
|
||||||
|
else parse_entity_ids(legacy_config.entity_ids)
|
||||||
|
)
|
||||||
|
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def public_http_error(status: int, body: str) -> str:
|
||||||
|
del body
|
||||||
|
return "authentication-required" if status in {401, 403} else "request-failed"
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
config: Config,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
payload: Mapping[str, object] | None = None,
|
||||||
|
) -> object:
|
||||||
|
if not config.base_url or not config.token:
|
||||||
|
raise BridgeError("not-configured")
|
||||||
|
body = None
|
||||||
|
if payload is not None:
|
||||||
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
config.base_url + path,
|
||||||
|
data=body,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {config.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=5) as response:
|
||||||
|
response_body = response.read()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
try:
|
||||||
|
error_body = error.read().decode(errors="replace")
|
||||||
|
except OSError:
|
||||||
|
error_body = ""
|
||||||
|
raise BridgeError(public_http_error(error.code, error_body)) from None
|
||||||
|
except (TimeoutError, urllib.error.URLError, OSError):
|
||||||
|
raise BridgeError("unreachable") from None
|
||||||
|
|
||||||
|
if not response_body:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(response_body)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
raise BridgeError("invalid-response") from None
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_name(entity_id: str) -> str:
|
||||||
|
return entity_id.split(".", 1)[1].replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
entity_id = item.get("entity_id")
|
||||||
|
attributes = item.get("attributes")
|
||||||
|
if not isinstance(entity_id, str) or not entity_id.startswith("light."):
|
||||||
|
continue
|
||||||
|
if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
|
||||||
|
continue
|
||||||
|
state = str(item.get("state", "unavailable"))
|
||||||
|
available = state not in {"unknown", "unavailable"}
|
||||||
|
active = available and state == "on"
|
||||||
|
raw_brightness = attributes.get("brightness")
|
||||||
|
brightness_pct = (
|
||||||
|
round(max(0, min(255, raw_brightness)) * 100 / 255)
|
||||||
|
if active
|
||||||
|
and isinstance(raw_brightness, (int, float))
|
||||||
|
and not isinstance(raw_brightness, bool)
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
modes = attributes.get("supported_color_modes", [])
|
||||||
|
dimmable = (
|
||||||
|
isinstance(modes, list) and any(mode != "onoff" for mode in modes)
|
||||||
|
) or isinstance(raw_brightness, (int, float))
|
||||||
|
source_name = attributes.get("friendly_name")
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": entity_id,
|
||||||
|
"sourceName": (
|
||||||
|
source_name.strip()
|
||||||
|
if isinstance(source_name, str) and source_name.strip()
|
||||||
|
else fallback_name(entity_id)
|
||||||
|
),
|
||||||
|
"state": state,
|
||||||
|
"available": available,
|
||||||
|
"active": active,
|
||||||
|
"dimmable": dimmable,
|
||||||
|
"brightnessPct": brightness_pct,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def collect_catalog(config: Config) -> dict[str, object]:
|
||||||
|
legacy_entity_ids = list(config.entity_ids)
|
||||||
|
if not config.configured:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"configured": False,
|
||||||
|
"generatedAt": int(time.time()),
|
||||||
|
"entities": [],
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
|
"error": "not-configured",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
raw = request_json(config, "GET", "/api/states")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise BridgeError("invalid-response")
|
||||||
|
entities = normalize_catalog(raw)
|
||||||
|
except BridgeError as error:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"configured": True,
|
||||||
|
"generatedAt": int(time.time()),
|
||||||
|
"entities": [],
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
|
"error": str(error),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"configured": True,
|
||||||
|
"generatedAt": int(time.time()),
|
||||||
|
"entities": entities,
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_snapshot(config: Config) -> dict[str, object]:
|
||||||
|
return collect_catalog(config)
|
||||||
|
|
||||||
|
|
||||||
|
def discovered_light_ids(config: Config) -> set[str]:
|
||||||
|
raw = request_json(config, "GET", "/api/states")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise BridgeError("invalid-response")
|
||||||
|
return {item["id"] for item in normalize_catalog(raw) if isinstance(item["id"], str)}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_discovered(config: Config, entity_id: str) -> str:
|
||||||
|
if entity_id not in discovered_light_ids(config):
|
||||||
|
raise ValueError("entity-not-discovered")
|
||||||
|
return entity_id
|
||||||
|
|
||||||
|
|
||||||
|
def probe(config: Config) -> dict[str, object]:
|
||||||
|
if not config.configured:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"reachable": False,
|
||||||
|
"entityCount": len(config.entity_ids),
|
||||||
|
"error": "not-configured",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
request_json(config, "GET", "/api/")
|
||||||
|
except BridgeError as error:
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"reachable": False,
|
||||||
|
"entityCount": len(config.entity_ids),
|
||||||
|
"error": str(error),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"reachable": True,
|
||||||
|
"entityCount": len(config.entity_ids),
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
||||||
|
entity_id = ensure_discovered(config, entity_id)
|
||||||
|
request_json(
|
||||||
|
config,
|
||||||
|
"POST",
|
||||||
|
"/api/services/homeassistant/toggle",
|
||||||
|
{"entity_id": entity_id},
|
||||||
|
)
|
||||||
|
return {"ok": True, "entityId": entity_id, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def set_brightness(
|
||||||
|
config: Config, entity_id: str, percent: int
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
ensure_discovered(config, entity_id)
|
||||||
|
if percent == 0:
|
||||||
|
path = "/api/services/light/turn_off"
|
||||||
|
payload = {"entity_id": entity_id}
|
||||||
|
else:
|
||||||
|
path = "/api/services/light/turn_on"
|
||||||
|
payload = {"entity_id": entity_id, "brightness_pct": percent}
|
||||||
|
request_json(config, "POST", path, payload)
|
||||||
|
return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_brightness(value: str) -> int:
|
||||||
|
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
percent = int(value)
|
||||||
|
if percent > 100:
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
return percent
|
||||||
|
|
||||||
|
|
||||||
|
def open_home(config: Config) -> dict[str, object]:
|
||||||
|
if not config.base_url:
|
||||||
|
return {"ok": False, "error": "not-configured"}
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["xdg-open", config.base_url],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return {"ok": False, "error": "open-failed"}
|
||||||
|
return {"ok": True, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(value: dict[str, object]) -> None:
|
||||||
|
sys.stdout.write(compact_json(value) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
try:
|
||||||
|
config = resolve_config()
|
||||||
|
except ValueError as error:
|
||||||
|
print_result({"ok": False, "configured": False, "error": str(error)})
|
||||||
|
return 2
|
||||||
|
|
||||||
|
command = argv[0] if argv else "probe"
|
||||||
|
if command == "probe" and len(argv) <= 1:
|
||||||
|
result = probe(config)
|
||||||
|
success = bool(result["reachable"])
|
||||||
|
elif command in {"catalog", "snapshot"} and len(argv) == 1:
|
||||||
|
result = collect_catalog(config)
|
||||||
|
success = bool(result["ok"])
|
||||||
|
elif command == "toggle" and len(argv) == 2:
|
||||||
|
try:
|
||||||
|
result = toggle(config, argv[1])
|
||||||
|
except ValueError as error:
|
||||||
|
result = {"ok": False, "error": str(error)}
|
||||||
|
except BridgeError as error:
|
||||||
|
result = {"ok": False, "error": str(error)}
|
||||||
|
success = bool(result["ok"])
|
||||||
|
elif command == "brightness" and len(argv) == 3:
|
||||||
|
try:
|
||||||
|
result = set_brightness(config, argv[1], parse_brightness(argv[2]))
|
||||||
|
except ValueError as error:
|
||||||
|
result = {"ok": False, "error": str(error)}
|
||||||
|
except BridgeError as error:
|
||||||
|
result = {"ok": False, "error": str(error)}
|
||||||
|
success = bool(result["ok"])
|
||||||
|
elif command == "open" and len(argv) == 1:
|
||||||
|
result = open_home(config)
|
||||||
|
success = bool(result["ok"])
|
||||||
|
else:
|
||||||
|
result = {"ok": False, "error": "invalid-command"}
|
||||||
|
success = False
|
||||||
|
print_result(result)
|
||||||
|
return 0 if success else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Generates hypridle's configuration from Panama's shared settings.
|
||||||
|
#
|
||||||
|
# Why this exists rather than editing hypridle.conf directly: ~/.config/hypr is
|
||||||
|
# a symlink into the Panama repository, so writing hypridle.conf at runtime
|
||||||
|
# would dirty a tracked file with machine state. The generated config therefore
|
||||||
|
# lives under XDG_STATE_HOME, and a systemd drop-in points hypridle at it with
|
||||||
|
# `-c`. The repository's hypridle.conf remains the shipped default and is what
|
||||||
|
# runs if this has never been set up.
|
||||||
|
#
|
||||||
|
# panama-idle apply regenerate and restart hypridle
|
||||||
|
# panama-idle status report as JSON what is in effect
|
||||||
|
# panama-idle install write the systemd drop-in (idempotent)
|
||||||
|
# panama-idle remove remove the drop-in and fall back to the shipped config
|
||||||
|
#
|
||||||
|
# All values are read from the settings store and clamped here as well as in the
|
||||||
|
# schema, because this script is also reachable from a shell.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||||
|
state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
|
||||||
|
generated="$state_dir/hypridle.conf"
|
||||||
|
dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d"
|
||||||
|
dropin="$dropin_dir/panama.conf"
|
||||||
|
|
||||||
|
read_setting() {
|
||||||
|
local key="$1" fallback="$2"
|
||||||
|
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
|
||||||
|
jq -r --arg k "$key" --arg d "$fallback" \
|
||||||
|
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
|
||||||
|
"$settings" 2>/dev/null || printf '%s' "$fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
clamp_int() {
|
||||||
|
local value="$1" low="$2" high="$3" fallback="$4"
|
||||||
|
[[ "$value" =~ ^-?[0-9]+$ ]] || { printf '%s' "$fallback"; return; }
|
||||||
|
(( value < low )) && value="$low"
|
||||||
|
(( value > high )) && value="$high"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
blank_min="$(clamp_int "$(read_setting screenBlankMinutes 5)" 0 120 5)"
|
||||||
|
lock_min="$(clamp_int "$(read_setting lockMinutes 10)" 0 240 10)"
|
||||||
|
suspend_min="$(clamp_int "$(read_setting suspendMinutes 0)" 0 480 0)"
|
||||||
|
lock_on_sleep="$(read_setting lockOnSleep true)"
|
||||||
|
[[ "$lock_on_sleep" == "true" || "$lock_on_sleep" == "false" ]] || lock_on_sleep=true
|
||||||
|
}
|
||||||
|
|
||||||
|
generate() {
|
||||||
|
load
|
||||||
|
mkdir -p "$state_dir"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '# Generated by panama-idle from %s\n' "$settings"
|
||||||
|
printf '# Do not edit: it is rewritten whenever the idle settings change.\n'
|
||||||
|
printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n'
|
||||||
|
|
||||||
|
printf 'general {\n'
|
||||||
|
printf ' lock_cmd = pidof hyprlock || hyprlock\n'
|
||||||
|
if [[ "$lock_on_sleep" == "true" ]]; then
|
||||||
|
printf ' before_sleep_cmd = loginctl lock-session\n'
|
||||||
|
fi
|
||||||
|
printf " after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'\n"
|
||||||
|
printf ' inhibit_sleep = 2\n'
|
||||||
|
printf '}\n'
|
||||||
|
|
||||||
|
if (( blank_min > 0 )); then
|
||||||
|
printf '\n# %s minutes -> screen off.\n' "$blank_min"
|
||||||
|
printf 'listener {\n'
|
||||||
|
printf ' timeout = %s\n' "$(( blank_min * 60 ))"
|
||||||
|
printf " on-timeout = hyprctl dispatch 'hl.dsp.dpms({ action = \"off\" })'\n"
|
||||||
|
printf " on-resume = hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'\n"
|
||||||
|
printf '}\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( lock_min > 0 )); then
|
||||||
|
printf '\n# %s minutes -> lock.\n' "$lock_min"
|
||||||
|
printf 'listener {\n'
|
||||||
|
printf ' timeout = %s\n' "$(( lock_min * 60 ))"
|
||||||
|
printf ' on-timeout = loginctl lock-session\n'
|
||||||
|
printf '}\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( suspend_min > 0 )); then
|
||||||
|
printf '\n# %s minutes -> suspend.\n' "$suspend_min"
|
||||||
|
printf 'listener {\n'
|
||||||
|
printf ' timeout = %s\n' "$(( suspend_min * 60 ))"
|
||||||
|
printf ' on-timeout = systemctl suspend\n'
|
||||||
|
printf '}\n'
|
||||||
|
fi
|
||||||
|
} >"$generated.tmp"
|
||||||
|
|
||||||
|
mv "$generated.tmp" "$generated"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dropin() {
|
||||||
|
mkdir -p "$dropin_dir"
|
||||||
|
cat >"$dropin" <<EOF
|
||||||
|
# Installed by panama-idle. Points hypridle at the configuration Panama
|
||||||
|
# generates from its settings store, so idle timings are adjustable from
|
||||||
|
# Panama Settings rather than by editing a file in the Panama repository.
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/bin/hypridle -c $generated
|
||||||
|
EOF
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-apply}" in
|
||||||
|
apply)
|
||||||
|
generate
|
||||||
|
if [[ -f "$dropin" ]]; then
|
||||||
|
systemctl --user restart hypridle.service
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
generate
|
||||||
|
install_dropin
|
||||||
|
systemctl --user restart hypridle.service
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
rm -f "$dropin"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user restart hypridle.service
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
load
|
||||||
|
managed=false
|
||||||
|
[[ -f "$dropin" ]] && managed=true
|
||||||
|
printf '{"managed":%s,"active":"%s","blankMinutes":%s,"lockMinutes":%s,"suspendMinutes":%s,"lockOnSleep":%s,"generated":"%s"}\n' \
|
||||||
|
"$managed" \
|
||||||
|
"$(systemctl --user is-active hypridle.service 2>/dev/null || printf unknown)" \
|
||||||
|
"$blank_min" "$lock_min" "$suspend_min" "$lock_on_sleep" "$generated"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'usage: panama-idle [apply|install|remove|status]\n' >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+306
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Capability-aware KDE Connect boundary for the Panama shell."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_ID = re.compile(r"^[A-Fa-f0-9]{32,64}$")
|
||||||
|
DEVICE_LINE = re.compile(
|
||||||
|
r"^-\s+(?P<name>.+):\s+(?P<id>[A-Fa-f0-9]{32,64})"
|
||||||
|
r"(?:\s+on\s+.+?)?\s+\((?P<state>[^)]+)\)\s*$"
|
||||||
|
)
|
||||||
|
PLUGIN_ACTIONS = {
|
||||||
|
"kdeconnect_clipboard": "clipboard",
|
||||||
|
"kdeconnect_findmyphone": "ring",
|
||||||
|
"kdeconnect_ping": "ping",
|
||||||
|
"kdeconnect_share": "share",
|
||||||
|
}
|
||||||
|
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
|
||||||
|
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||||
|
|
||||||
|
|
||||||
|
def compact_json(value: dict[str, object]) -> str:
|
||||||
|
return json.dumps(value, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_device_id(value: str) -> str:
|
||||||
|
if not DEVICE_ID.fullmatch(value):
|
||||||
|
raise ValueError("invalid-device")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file(value: pathlib.Path) -> pathlib.Path:
|
||||||
|
try:
|
||||||
|
resolved = value.expanduser().resolve(strict=True)
|
||||||
|
except OSError as error:
|
||||||
|
raise ValueError("invalid-file") from error
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise ValueError("invalid-file")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def inferred_type(name: str) -> str:
|
||||||
|
normalized = name.casefold()
|
||||||
|
if "iphone" in normalized or "phone" in normalized:
|
||||||
|
return "phone"
|
||||||
|
if "tablet" in normalized or "ipad" in normalized:
|
||||||
|
return "tablet"
|
||||||
|
return "device"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_device_line(
|
||||||
|
line: str,
|
||||||
|
plugins: list[str],
|
||||||
|
device_type: str = "",
|
||||||
|
) -> dict[str, object]:
|
||||||
|
match = DEVICE_LINE.fullmatch(line.strip())
|
||||||
|
if match is None:
|
||||||
|
raise ValueError("invalid-device-line")
|
||||||
|
|
||||||
|
state = match.group("state").casefold()
|
||||||
|
actions = sorted(
|
||||||
|
{
|
||||||
|
action
|
||||||
|
for plugin, action in PLUGIN_ACTIONS.items()
|
||||||
|
if plugin in plugins
|
||||||
|
}
|
||||||
|
)
|
||||||
|
name = match.group("name").strip()
|
||||||
|
return {
|
||||||
|
"id": match.group("id"),
|
||||||
|
"name": name,
|
||||||
|
"type": device_type or inferred_type(name),
|
||||||
|
"paired": "paired" in state,
|
||||||
|
"reachable": "reachable" in state,
|
||||||
|
"actions": actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_loaded_plugins(output: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
parts = shlex.split(output)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
if len(parts) < 2 or parts[0] != "as":
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
count = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
return parts[2 : 2 + max(0, count)]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_string_property(output: str) -> str:
|
||||||
|
try:
|
||||||
|
parts = shlex.split(output)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
command: list[str],
|
||||||
|
*,
|
||||||
|
runner: Runner = subprocess.run,
|
||||||
|
timeout: float = 8,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return runner(
|
||||||
|
command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def device_object(device_id: str) -> str:
|
||||||
|
return f"{DEVICE_OBJECT_PREFIX}/{validate_device_id(device_id)}"
|
||||||
|
|
||||||
|
|
||||||
|
def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
|
||||||
|
result = run_command(
|
||||||
|
[
|
||||||
|
"busctl",
|
||||||
|
"--user",
|
||||||
|
"call",
|
||||||
|
"org.kde.kdeconnect",
|
||||||
|
device_object(device_id),
|
||||||
|
"org.kde.kdeconnect.device",
|
||||||
|
"loadedPlugins",
|
||||||
|
],
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
return parse_loaded_plugins(result.stdout) if result.returncode == 0 else []
|
||||||
|
|
||||||
|
|
||||||
|
def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
|
||||||
|
result = run_command(
|
||||||
|
[
|
||||||
|
"busctl",
|
||||||
|
"--user",
|
||||||
|
"get-property",
|
||||||
|
"org.kde.kdeconnect",
|
||||||
|
device_object(device_id),
|
||||||
|
"org.kde.kdeconnect.device",
|
||||||
|
"supportedPlugins",
|
||||||
|
],
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
return parse_loaded_plugins(result.stdout) if result.returncode == 0 else []
|
||||||
|
|
||||||
|
|
||||||
|
def device_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
|
||||||
|
active = loaded_plugins(device_id, runner)
|
||||||
|
return active if active else supported_plugins(device_id, runner)
|
||||||
|
|
||||||
|
|
||||||
|
def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
|
||||||
|
result = run_command(
|
||||||
|
[
|
||||||
|
"busctl",
|
||||||
|
"--user",
|
||||||
|
"get-property",
|
||||||
|
"org.kde.kdeconnect",
|
||||||
|
device_object(device_id),
|
||||||
|
"org.kde.kdeconnect.device",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
return parse_string_property(result.stdout) if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
listing = run_command(
|
||||||
|
["kdeconnect-cli", "--list-devices"],
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return {"available": False, "devices": [], "error": "unavailable"}
|
||||||
|
|
||||||
|
if listing.returncode != 0:
|
||||||
|
return {"available": False, "devices": [], "error": "unavailable"}
|
||||||
|
|
||||||
|
devices: list[dict[str, object]] = []
|
||||||
|
for line in listing.stdout.splitlines():
|
||||||
|
if not line.lstrip().startswith("-"):
|
||||||
|
continue
|
||||||
|
match = DEVICE_LINE.fullmatch(line.strip())
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
device_id = match.group("id")
|
||||||
|
try:
|
||||||
|
device = normalize_device_line(
|
||||||
|
line,
|
||||||
|
device_plugins(device_id, runner),
|
||||||
|
reported_type(device_id, runner),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
devices.sort(
|
||||||
|
key=lambda device: (
|
||||||
|
not bool(device["reachable"]),
|
||||||
|
not bool(device["paired"]),
|
||||||
|
str(device["name"]).casefold(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"available": True, "devices": devices, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def action_command(
|
||||||
|
action: str,
|
||||||
|
device_id: str,
|
||||||
|
file_path: pathlib.Path | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
device_id = validate_device_id(device_id)
|
||||||
|
options = {
|
||||||
|
"ring": ["--ring"],
|
||||||
|
"clipboard": ["--send-clipboard"],
|
||||||
|
}
|
||||||
|
if action == "share" and file_path is not None:
|
||||||
|
return [
|
||||||
|
"kdeconnect-cli",
|
||||||
|
"-d",
|
||||||
|
device_id,
|
||||||
|
"--share",
|
||||||
|
str(validate_file(file_path)),
|
||||||
|
]
|
||||||
|
if action not in options:
|
||||||
|
raise ValueError("unsupported-action")
|
||||||
|
return ["kdeconnect-cli", "-d", device_id, *options[action]]
|
||||||
|
|
||||||
|
|
||||||
|
def invoke_action(
|
||||||
|
action: str,
|
||||||
|
device_id: str,
|
||||||
|
file_path: pathlib.Path | None = None,
|
||||||
|
runner: Runner = subprocess.run,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
device_id = validate_device_id(device_id)
|
||||||
|
status = collect_status(runner)
|
||||||
|
device = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in status["devices"]
|
||||||
|
if isinstance(item, dict) and item.get("id") == device_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not device or not device.get("paired") or not device.get("reachable"):
|
||||||
|
return {"ok": False, "action": action, "error": "device-offline"}
|
||||||
|
if action not in device.get("actions", []):
|
||||||
|
return {"ok": False, "action": action, "error": "unsupported-action"}
|
||||||
|
|
||||||
|
command = action_command(action, device_id, file_path)
|
||||||
|
try:
|
||||||
|
result = run_command(command, runner=runner, timeout=3600)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return {"ok": False, "action": action, "error": "action-failed"}
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {"ok": False, "action": action, "error": "action-failed"}
|
||||||
|
|
||||||
|
response: dict[str, object] = {"ok": True, "action": action, "error": ""}
|
||||||
|
if action == "share" and file_path is not None:
|
||||||
|
response["fileName"] = validate_file(file_path).name
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(value: dict[str, object]) -> None:
|
||||||
|
sys.stdout.write(compact_json(value) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if not argv or argv[0] == "status":
|
||||||
|
print_result(collect_status())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
command = argv[0]
|
||||||
|
try:
|
||||||
|
if command == "ring" and len(argv) == 2:
|
||||||
|
result = invoke_action("ring", argv[1])
|
||||||
|
elif command == "send-clipboard" and len(argv) == 2:
|
||||||
|
result = invoke_action("clipboard", argv[1])
|
||||||
|
elif command == "send-file" and len(argv) == 3:
|
||||||
|
result = invoke_action("share", argv[1], pathlib.Path(argv[2]))
|
||||||
|
else:
|
||||||
|
result = {"ok": False, "action": command, "error": "invalid-command"}
|
||||||
|
except ValueError as error:
|
||||||
|
result = {"ok": False, "action": command, "error": str(error)}
|
||||||
|
print_result(result)
|
||||||
|
return 0 if bool(result.get("ok")) else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
+625
@@ -0,0 +1,625 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Crash-safe snapshots of Panama's desktop and Home preference stores.
|
||||||
|
|
||||||
|
A restore is a two-file transaction. Its fixed journal and artifacts live at
|
||||||
|
`$XDG_STATE_HOME/panama/transactions/settings-restore`; they contain no
|
||||||
|
caller-provided paths. The journal is fsynced before either destination changes
|
||||||
|
and is removed only after both replacements are durable. Every invocation
|
||||||
|
recovers an incomplete transaction before doing any other work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import fcntl
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator, NoReturn
|
||||||
|
|
||||||
|
|
||||||
|
HOME = Path(os.environ.get("HOME", str(Path.home())))
|
||||||
|
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
|
||||||
|
STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
|
||||||
|
SETTINGS = CONFIG_ROOT / "panama/settings.json"
|
||||||
|
HOME_STATE = STATE_ROOT / "panama/panama-home.json"
|
||||||
|
BACKUP_DIR = STATE_ROOT / "panama/backups"
|
||||||
|
TRANSACTION_PARENT = STATE_ROOT / "panama/transactions"
|
||||||
|
TRANSACTION_DIR = TRANSACTION_PARENT / "settings-restore"
|
||||||
|
JOURNAL = TRANSACTION_DIR / "journal.json"
|
||||||
|
LOCK_FILE = TRANSACTION_PARENT / "settings-backup.lock"
|
||||||
|
KEEP = 15
|
||||||
|
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
|
||||||
|
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
|
||||||
|
|
||||||
|
|
||||||
|
class BackupError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> NoReturn:
|
||||||
|
raise BackupError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def fsync_directory(path: Path) -> None:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_directory(path: Path) -> None:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
if path.is_symlink() or not path.is_dir():
|
||||||
|
fail(f"{path} is not a safe directory.")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def process_lock() -> Iterator[None]:
|
||||||
|
ensure_directory(TRANSACTION_PARENT)
|
||||||
|
if LOCK_FILE.is_symlink():
|
||||||
|
fail("The settings transaction lock is a symbolic link.")
|
||||||
|
descriptor = os.open(
|
||||||
|
LOCK_FILE,
|
||||||
|
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def is_present(path: Path) -> bool:
|
||||||
|
return path.exists() or path.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
def require_regular(path: Path, label: str) -> None:
|
||||||
|
if path.is_symlink():
|
||||||
|
fail(f"{label} is a symbolic link and cannot be used safely.")
|
||||||
|
try:
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
except FileNotFoundError:
|
||||||
|
fail(f"{label} is missing.")
|
||||||
|
if not stat.S_ISREG(mode):
|
||||||
|
fail(f"{label} is not a regular file.")
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
require_regular(path, label)
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||||
|
raise BackupError(f"{label} is not valid JSON.") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
fail(f"{label} is not a JSON object.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def valid_home(value: Any) -> bool:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
initialized = value.get("initialized")
|
||||||
|
favorites = value.get("favorites")
|
||||||
|
if not isinstance(initialized, bool) or not isinstance(favorites, list):
|
||||||
|
return False
|
||||||
|
if not initialized and favorites:
|
||||||
|
return False
|
||||||
|
seen: set[str] = set()
|
||||||
|
for favorite in favorites:
|
||||||
|
if not isinstance(favorite, dict):
|
||||||
|
return False
|
||||||
|
entity_id = favorite.get("id")
|
||||||
|
alias = favorite.get("alias")
|
||||||
|
if (
|
||||||
|
not isinstance(entity_id, str)
|
||||||
|
or ENTITY_RE.fullmatch(entity_id) is None
|
||||||
|
or not isinstance(alias, str)
|
||||||
|
or entity_id in seen
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
seen.add(entity_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_home(value: Any, label: str) -> dict[str, Any]:
|
||||||
|
if not valid_home(value):
|
||||||
|
fail(f"{label} does not contain valid Home favourites.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def json_bytes(value: Any) -> bytes:
|
||||||
|
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_bytes(path: Path, content: bytes) -> None:
|
||||||
|
ensure_directory(path.parent)
|
||||||
|
if path.is_symlink():
|
||||||
|
fail(f"{path} is a symbolic link and cannot be replaced safely.")
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||||
|
temporary = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
stream.write(content)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
fsync_directory(path.parent)
|
||||||
|
finally:
|
||||||
|
if temporary.exists() or temporary.is_symlink():
|
||||||
|
temporary.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: Path, value: Any) -> None:
|
||||||
|
atomic_write_bytes(path, json_bytes(value))
|
||||||
|
|
||||||
|
|
||||||
|
def durable_remove(path: Path) -> None:
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
path.unlink()
|
||||||
|
fsync_directory(path.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def transaction_path(name: str) -> Path:
|
||||||
|
if name not in {
|
||||||
|
"journal.json",
|
||||||
|
"desktop.old",
|
||||||
|
"desktop.new",
|
||||||
|
"home.old",
|
||||||
|
"home.new",
|
||||||
|
}:
|
||||||
|
fail("The restore transaction contains an unknown artifact name.")
|
||||||
|
path = TRANSACTION_DIR / name
|
||||||
|
resolved_parent = path.parent.resolve(strict=False)
|
||||||
|
if resolved_parent != TRANSACTION_DIR.resolve(strict=False):
|
||||||
|
fail("The restore transaction escaped its contained state directory.")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def clean_transaction_artifacts() -> None:
|
||||||
|
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
|
||||||
|
return
|
||||||
|
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
|
||||||
|
fail("The restore transaction path is not a safe directory.")
|
||||||
|
for child in list(TRANSACTION_DIR.iterdir()):
|
||||||
|
if child.name not in {
|
||||||
|
"journal.json",
|
||||||
|
"desktop.old",
|
||||||
|
"desktop.new",
|
||||||
|
"home.old",
|
||||||
|
"home.new",
|
||||||
|
} and not child.name.startswith(".journal.json."):
|
||||||
|
fail("The restore transaction directory contains an unknown artifact.")
|
||||||
|
if child.is_dir() and not child.is_symlink():
|
||||||
|
fail("The restore transaction contains an unexpected directory.")
|
||||||
|
child.unlink()
|
||||||
|
fsync_directory(TRANSACTION_DIR)
|
||||||
|
TRANSACTION_DIR.rmdir()
|
||||||
|
fsync_directory(TRANSACTION_PARENT)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_stale_atomic_files() -> None:
|
||||||
|
locations = (
|
||||||
|
(SETTINGS.parent, (".settings.json.",)),
|
||||||
|
(HOME_STATE.parent, (".panama-home.json.",)),
|
||||||
|
(BACKUP_DIR, (".settings-",)),
|
||||||
|
)
|
||||||
|
for directory, prefixes in locations:
|
||||||
|
if not directory.exists():
|
||||||
|
continue
|
||||||
|
if directory.is_symlink() or not directory.is_dir():
|
||||||
|
fail(f"{directory} is not a safe directory.")
|
||||||
|
changed = False
|
||||||
|
for child in directory.iterdir():
|
||||||
|
if not any(child.name.startswith(prefix) for prefix in prefixes):
|
||||||
|
continue
|
||||||
|
# Only Panama's hidden atomic-write names are eligible. A matching
|
||||||
|
# directory is unexpected and is never recursively removed.
|
||||||
|
if child.is_dir() and not child.is_symlink():
|
||||||
|
fail("A stale settings temporary path is an unexpected directory.")
|
||||||
|
child.unlink()
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
fsync_directory(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_journal_side(value: Any) -> dict[str, bool]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
fail("The restore journal is malformed.")
|
||||||
|
if set(value) != {"touch", "oldPresent", "newPresent"}:
|
||||||
|
fail("The restore journal is malformed.")
|
||||||
|
if not all(isinstance(value[key], bool) for key in value):
|
||||||
|
fail("The restore journal is malformed.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def read_journal() -> dict[str, Any]:
|
||||||
|
value = read_json(JOURNAL, "The restore journal")
|
||||||
|
if set(value) != {"version", "desktop", "home"} or value.get("version") != 1:
|
||||||
|
fail("The restore journal uses an unsupported format.")
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"desktop": validate_journal_side(value.get("desktop")),
|
||||||
|
"home": validate_journal_side(value.get("home")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def target_for(store: str) -> Path:
|
||||||
|
if store == "desktop":
|
||||||
|
return SETTINGS
|
||||||
|
if store == "home":
|
||||||
|
return HOME_STATE
|
||||||
|
fail("The restore journal names an unknown store.")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_artifact(store: str, generation: str, present: bool) -> None:
|
||||||
|
target = target_for(store)
|
||||||
|
if present:
|
||||||
|
artifact = transaction_path(f"{store}.{generation}")
|
||||||
|
require_regular(artifact, "A restore transaction artifact")
|
||||||
|
atomic_write_bytes(target, artifact.read_bytes())
|
||||||
|
else:
|
||||||
|
ensure_directory(target.parent)
|
||||||
|
if target.is_symlink():
|
||||||
|
fail(f"{target} is a symbolic link and cannot be replaced safely.")
|
||||||
|
durable_remove(target)
|
||||||
|
|
||||||
|
|
||||||
|
def recover_transaction() -> None:
|
||||||
|
ensure_directory(TRANSACTION_PARENT)
|
||||||
|
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
|
||||||
|
return
|
||||||
|
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
|
||||||
|
fail("The restore transaction path is not a safe directory.")
|
||||||
|
if not JOURNAL.exists() and not JOURNAL.is_symlink():
|
||||||
|
clean_transaction_artifacts()
|
||||||
|
return
|
||||||
|
|
||||||
|
journal = read_journal()
|
||||||
|
for store in ("desktop", "home"):
|
||||||
|
side = journal[store]
|
||||||
|
if side["touch"]:
|
||||||
|
apply_artifact(store, "old", side["oldPresent"])
|
||||||
|
|
||||||
|
# Journal absence is the durable commit marker for recovery too. If a
|
||||||
|
# second power loss occurs above, the journal remains and recovery retries.
|
||||||
|
durable_remove(JOURNAL)
|
||||||
|
clean_transaction_artifacts()
|
||||||
|
|
||||||
|
|
||||||
|
def is_v2_side(value: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(value, dict)
|
||||||
|
and isinstance(value.get("present"), bool)
|
||||||
|
and (not value["present"] or isinstance(value.get("data"), dict))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_v2_envelope(value: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(value, dict)
|
||||||
|
and value.get("version") == 2
|
||||||
|
and is_v2_side(value.get("desktop"))
|
||||||
|
and is_v2_side(value.get("home"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_snapshot(value: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||||
|
if not is_v2_envelope(value):
|
||||||
|
return "legacy", value
|
||||||
|
if value["home"]["present"]:
|
||||||
|
validate_home(value["home"]["data"], "That snapshot")
|
||||||
|
return "versioned", value
|
||||||
|
|
||||||
|
|
||||||
|
def current_store(path: Path, label: str, *, home_store: bool = False) -> tuple[bool, Any]:
|
||||||
|
if not is_present(path):
|
||||||
|
return False, None
|
||||||
|
value = read_json(path, label)
|
||||||
|
if home_store:
|
||||||
|
validate_home(value, label)
|
||||||
|
return True, value
|
||||||
|
|
||||||
|
|
||||||
|
def next_snapshot_path() -> Path:
|
||||||
|
ensure_directory(BACKUP_DIR)
|
||||||
|
while True:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S%f")[:18]
|
||||||
|
candidate = BACKUP_DIR / f"settings-{stamp}.json"
|
||||||
|
if not is_present(candidate):
|
||||||
|
return candidate
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_snapshots() -> None:
|
||||||
|
snapshots = sorted(
|
||||||
|
(
|
||||||
|
path
|
||||||
|
for path in BACKUP_DIR.iterdir()
|
||||||
|
if SNAPSHOT_RE.fullmatch(path.name)
|
||||||
|
and path.is_file()
|
||||||
|
and not path.is_symlink()
|
||||||
|
),
|
||||||
|
key=lambda path: path.stat().st_mtime_ns,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for old in snapshots[KEEP:]:
|
||||||
|
durable_remove(old)
|
||||||
|
|
||||||
|
|
||||||
|
def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
|
||||||
|
try:
|
||||||
|
desktop_present, desktop = current_store(
|
||||||
|
SETTINGS, "The current settings file"
|
||||||
|
)
|
||||||
|
home_present, home = current_store(
|
||||||
|
HOME_STATE, "The current Home state file", home_store=True
|
||||||
|
)
|
||||||
|
except BackupError:
|
||||||
|
if validate:
|
||||||
|
raise
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not desktop_present and not home_present:
|
||||||
|
if require_any:
|
||||||
|
fail("No Panama settings exist to back up.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
envelope: dict[str, Any] = {
|
||||||
|
"version": 2,
|
||||||
|
"desktop": {"present": desktop_present},
|
||||||
|
"home": {"present": home_present},
|
||||||
|
}
|
||||||
|
if desktop_present:
|
||||||
|
envelope["desktop"]["data"] = desktop
|
||||||
|
if home_present:
|
||||||
|
envelope["home"]["data"] = home
|
||||||
|
|
||||||
|
destination = next_snapshot_path()
|
||||||
|
atomic_write_json(destination, envelope)
|
||||||
|
prune_snapshots()
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_source(name: str) -> Path:
|
||||||
|
if SNAPSHOT_RE.fullmatch(name) is None:
|
||||||
|
fail("Not a snapshot name.")
|
||||||
|
ensure_directory(BACKUP_DIR)
|
||||||
|
candidate = BACKUP_DIR / name
|
||||||
|
require_regular(candidate, "That snapshot")
|
||||||
|
if candidate.resolve(strict=True).parent != BACKUP_DIR.resolve(strict=True):
|
||||||
|
fail("That snapshot is outside the backup directory.")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def stage_artifact(name: str, content: bytes) -> None:
|
||||||
|
path = transaction_path(name)
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
fail("A stale restore transaction artifact was not recovered.")
|
||||||
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
stream.write(content)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
finally:
|
||||||
|
# fdopen owns the descriptor after construction.
|
||||||
|
pass
|
||||||
|
fsync_directory(TRANSACTION_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_old(store: str, target: Path) -> bool:
|
||||||
|
if not is_present(target):
|
||||||
|
return False
|
||||||
|
require_regular(target, f"The current {store} settings file")
|
||||||
|
stage_artifact(f"{store}.old", target.read_bytes())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_transaction(
|
||||||
|
desktop_present: bool,
|
||||||
|
desktop_data: dict[str, Any] | None,
|
||||||
|
home_touch: bool,
|
||||||
|
home_present: bool,
|
||||||
|
home_data: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# Cleanup is active before the first artifact is created. A pre-journal
|
||||||
|
# error removes every staged/rollback file; a process death is recovered as
|
||||||
|
# stale preparation by the next invocation.
|
||||||
|
ensure_directory(TRANSACTION_PARENT)
|
||||||
|
clean_transaction_artifacts()
|
||||||
|
ensure_directory(TRANSACTION_DIR)
|
||||||
|
fsync_directory(TRANSACTION_PARENT)
|
||||||
|
try:
|
||||||
|
desktop_old = capture_old("desktop", SETTINGS)
|
||||||
|
if desktop_present:
|
||||||
|
stage_artifact("desktop.new", json_bytes(desktop_data))
|
||||||
|
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_FAIL") == "after-desktop-stage":
|
||||||
|
fail("Injected failure after desktop staging.")
|
||||||
|
|
||||||
|
home_old = capture_old("home", HOME_STATE) if home_touch else False
|
||||||
|
if home_touch and home_present:
|
||||||
|
stage_artifact("home.new", json_bytes(home_data))
|
||||||
|
|
||||||
|
journal = {
|
||||||
|
"version": 1,
|
||||||
|
"desktop": {
|
||||||
|
"touch": True,
|
||||||
|
"oldPresent": desktop_old,
|
||||||
|
"newPresent": desktop_present,
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"touch": home_touch,
|
||||||
|
"oldPresent": home_old,
|
||||||
|
"newPresent": home_present,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
atomic_write_json(JOURNAL, journal)
|
||||||
|
return journal
|
||||||
|
except BaseException:
|
||||||
|
# SIGKILL/os._exit bypass this block by design; the next invocation
|
||||||
|
# cleans a pre-journal directory or recovers a journalled transaction.
|
||||||
|
if not JOURNAL.exists() and not JOURNAL.is_symlink():
|
||||||
|
clean_transaction_artifacts()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def commit_restore(journal: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
desktop = journal["desktop"]
|
||||||
|
apply_artifact("desktop", "new", desktop["newPresent"])
|
||||||
|
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_CRASH") == "after-desktop":
|
||||||
|
os._exit(86)
|
||||||
|
|
||||||
|
home = journal["home"]
|
||||||
|
if home["touch"]:
|
||||||
|
apply_artifact("home", "new", home["newPresent"])
|
||||||
|
|
||||||
|
# Both targets and their parent directories are durable. Removing and
|
||||||
|
# fsyncing the journal is the transaction's commit record.
|
||||||
|
durable_remove(JOURNAL)
|
||||||
|
clean_transaction_artifacts()
|
||||||
|
except BaseException:
|
||||||
|
# Ordinary failures roll back immediately. Process death leaves the
|
||||||
|
# journal in place and takes this same path on the next invocation.
|
||||||
|
recover_transaction()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def write_live_home(text: str) -> None:
|
||||||
|
try:
|
||||||
|
value = json.loads(text)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise BackupError("The live Home state is not valid JSON.") from error
|
||||||
|
validate_home(value, "The live Home state")
|
||||||
|
atomic_write_json(HOME_STATE, value)
|
||||||
|
|
||||||
|
|
||||||
|
def command_save(arguments: list[str]) -> None:
|
||||||
|
if arguments:
|
||||||
|
write_live_home(arguments[0])
|
||||||
|
destination = save_snapshot(require_any=True, validate=True)
|
||||||
|
assert destination is not None
|
||||||
|
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_files() -> list[Path]:
|
||||||
|
ensure_directory(BACKUP_DIR)
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
path
|
||||||
|
for path in BACKUP_DIR.iterdir()
|
||||||
|
if SNAPSHOT_RE.fullmatch(path.name)
|
||||||
|
and path.is_file()
|
||||||
|
and not path.is_symlink()
|
||||||
|
),
|
||||||
|
key=lambda path: path.stat().st_mtime_ns,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command_list() -> None:
|
||||||
|
output: list[dict[str, Any]] = []
|
||||||
|
for path in snapshot_files():
|
||||||
|
try:
|
||||||
|
value = read_json(path, "A snapshot")
|
||||||
|
if is_v2_envelope(value):
|
||||||
|
desktop = value["desktop"]
|
||||||
|
keys = len(desktop["data"]) if desktop["present"] else 0
|
||||||
|
else:
|
||||||
|
keys = len(value)
|
||||||
|
except BackupError:
|
||||||
|
keys = 0
|
||||||
|
raw = path.name.removeprefix("settings-").removesuffix(".json")
|
||||||
|
pretty = (
|
||||||
|
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
|
||||||
|
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
|
||||||
|
)
|
||||||
|
output.append({"name": path.name, "when": pretty, "keys": keys})
|
||||||
|
print(json.dumps(output, separators=(",", ":")))
|
||||||
|
|
||||||
|
|
||||||
|
def command_restore(arguments: list[str]) -> None:
|
||||||
|
if not arguments:
|
||||||
|
fail("Which snapshot?")
|
||||||
|
name = arguments[0]
|
||||||
|
source = snapshot_source(name)
|
||||||
|
snapshot = read_json(source, "That snapshot")
|
||||||
|
snapshot_format, value = validate_snapshot(snapshot)
|
||||||
|
|
||||||
|
if snapshot_format == "versioned":
|
||||||
|
desktop_present = value["desktop"]["present"]
|
||||||
|
desktop_data = value["desktop"].get("data")
|
||||||
|
home_touch = True
|
||||||
|
home_present = value["home"]["present"]
|
||||||
|
home_data = value["home"].get("data")
|
||||||
|
else:
|
||||||
|
desktop_present = True
|
||||||
|
desktop_data = value
|
||||||
|
home_touch = False
|
||||||
|
home_present = False
|
||||||
|
home_data = None
|
||||||
|
|
||||||
|
# Restoring remains undoable, but a corrupt current file must not prevent a
|
||||||
|
# known-good snapshot from recovering the desktop.
|
||||||
|
save_snapshot(require_any=False, validate=False)
|
||||||
|
journal = prepare_transaction(
|
||||||
|
desktop_present,
|
||||||
|
desktop_data,
|
||||||
|
home_touch,
|
||||||
|
home_present,
|
||||||
|
home_data,
|
||||||
|
)
|
||||||
|
commit_restore(journal)
|
||||||
|
|
||||||
|
if not home_touch:
|
||||||
|
home_result: dict[str, Any] = {"preserve": True}
|
||||||
|
elif home_present:
|
||||||
|
home_result = {"present": True, "data": home_data}
|
||||||
|
else:
|
||||||
|
home_result = {"present": False}
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"restored": name, "home": home_result},
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
with process_lock():
|
||||||
|
clean_stale_atomic_files()
|
||||||
|
recover_transaction()
|
||||||
|
command = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||||
|
arguments = sys.argv[2:]
|
||||||
|
if command == "save":
|
||||||
|
command_save(arguments)
|
||||||
|
elif command == "list":
|
||||||
|
command_list()
|
||||||
|
elif command == "restore":
|
||||||
|
command_restore(arguments)
|
||||||
|
else:
|
||||||
|
fail("usage: panama-settings-backup [save|list|restore <name>]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except BackupError as error:
|
||||||
|
print(str(error), file=sys.stderr)
|
||||||
|
raise SystemExit(1) from error
|
||||||
|
except OSError as error:
|
||||||
|
print("The settings backup could not access its state files.", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from error
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Pointer size and text scale.
|
||||||
|
//
|
||||||
|
// These are the two settings that must agree across three consumers that do not
|
||||||
|
// share a configuration system: the compositor draws the cursor, GTK
|
||||||
|
// applications read gsettings, and the shell renders its own text. Panama's
|
||||||
|
// store is the source of truth, and this pushes the value out to the other two
|
||||||
|
// so they cannot disagree.
|
||||||
|
//
|
||||||
|
// pointer size -> gsettings (GTK) + `hyprctl setcursor` (compositor)
|
||||||
|
// text scale -> gsettings (GTK)
|
||||||
|
//
|
||||||
|
// The shell's own font size is not scaled here. Theme.qml's sizes are part of
|
||||||
|
// the design rather than a user preference, and scaling them at runtime would
|
||||||
|
// reflow every panel against layouts that were tuned at the design size. Text
|
||||||
|
// scale therefore affects applications, which is where it matters, and the
|
||||||
|
// page says so rather than implying it does more.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string cursorTheme: ""
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0
|
||||||
|
|
||||||
|
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
||||||
|
readonly property real textScale: DesktopPreferences.get("textScale")
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: themeQuery
|
||||||
|
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
// gsettings quotes strings: 'oreo_blue_cursors'
|
||||||
|
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short queue, because applying one setting takes several commands and
|
||||||
|
// Process runs one at a time.
|
||||||
|
property var pending: []
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: runner
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "That accessibility setting could not be applied.";
|
||||||
|
root.drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain(): void {
|
||||||
|
if (runner.running || root.pending.length === 0)
|
||||||
|
return;
|
||||||
|
const next = root.pending[0];
|
||||||
|
root.pending = root.pending.slice(1);
|
||||||
|
runner.exec(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(commands: var): void {
|
||||||
|
root.pending = root.pending.concat(commands);
|
||||||
|
root.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
themeQuery.running = true;
|
||||||
|
settle.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the stored values outward once at startup, so a value changed in a
|
||||||
|
// previous session is in effect in this one even though gsettings and the
|
||||||
|
// compositor do not read Panama's store.
|
||||||
|
Timer {
|
||||||
|
id: settle
|
||||||
|
interval: 1200
|
||||||
|
onTriggered: root.applyAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: DesktopPreferences
|
||||||
|
function onRevisionChanged(): void { coalesce.restart(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: coalesce
|
||||||
|
interval: 250
|
||||||
|
onTriggered: root.applyAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAll(): void {
|
||||||
|
root.lastError = "";
|
||||||
|
const size = String(root.cursorSize);
|
||||||
|
const commands = [
|
||||||
|
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||||||
|
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
||||||
|
];
|
||||||
|
// setcursor needs a theme name; skip it rather than guess if gsettings
|
||||||
|
// has not answered yet. The next change will catch up.
|
||||||
|
if (root.cursorTheme !== "")
|
||||||
|
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||||
|
root.enqueue(commands);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// System date, time, and timezone.
|
||||||
|
//
|
||||||
|
// Deliberately NOT backed by the Panama settings store. The timezone and the
|
||||||
|
// network-time setting belong to the machine, not to this desktop: they are
|
||||||
|
// shared with every other session and with services that never see Panama's
|
||||||
|
// JSON. Storing a copy would create a second answer to a question the system
|
||||||
|
// already answers, which is the exact failure this settings rewrite exists to
|
||||||
|
// remove. So this reads and writes `timedatectl` directly and holds no state of
|
||||||
|
// its own beyond what it last observed.
|
||||||
|
//
|
||||||
|
// Setting the timezone or toggling NTP needs privilege. timedatectl asks
|
||||||
|
// polkit, which shows the usual authentication dialog; on refusal the command
|
||||||
|
// fails and the error is surfaced rather than the UI pretending it worked.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string timezone: ""
|
||||||
|
property bool ntpEnabled: false
|
||||||
|
property bool ntpSynchronised: false
|
||||||
|
property string localTime: ""
|
||||||
|
property string universalTime: ""
|
||||||
|
property string rtcTime: ""
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
property var zones: []
|
||||||
|
|
||||||
|
readonly property bool busy: statusQuery.running || zonesQuery.running || writeRun.running
|
||||||
|
|
||||||
|
// "America/New_York" -> "New York" for display, keeping the region as a
|
||||||
|
// separate field so the list can be grouped and searched sensibly.
|
||||||
|
function regionOf(zone: string): string {
|
||||||
|
const slash = zone.indexOf("/");
|
||||||
|
return slash < 0 ? zone : zone.slice(0, slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityOf(zone: string): string {
|
||||||
|
const slash = zone.indexOf("/");
|
||||||
|
return (slash < 0 ? zone : zone.slice(slash + 1)).replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusQuery
|
||||||
|
command: ["timedatectl", "show",
|
||||||
|
"-p", "Timezone", "-p", "NTP", "-p", "NTPSynchronized",
|
||||||
|
"-p", "TimeUSec", "-p", "RTCTimeUSec"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.parseStatus(this.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "Could not read the system clock settings.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: zonesQuery
|
||||||
|
command: ["timedatectl", "list-timezones"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
root.zones = this.text.split("\n")
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: writeRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
// A polkit refusal and a bad value both land here. Neither should
|
||||||
|
// leave the UI showing a value the system did not take, so the
|
||||||
|
// status is re-read either way.
|
||||||
|
root.lastError = exitCode === 0
|
||||||
|
? ""
|
||||||
|
: "The system rejected that change, or authentication was cancelled.";
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.refresh();
|
||||||
|
zonesQuery.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatus(text: string): void {
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const split = line.indexOf("=");
|
||||||
|
if (split < 0)
|
||||||
|
continue;
|
||||||
|
const key = line.slice(0, split);
|
||||||
|
const value = line.slice(split + 1);
|
||||||
|
if (key === "Timezone")
|
||||||
|
root.timezone = value;
|
||||||
|
else if (key === "NTP")
|
||||||
|
root.ntpEnabled = value === "yes";
|
||||||
|
else if (key === "NTPSynchronized")
|
||||||
|
root.ntpSynchronised = value === "yes";
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!statusQuery.running)
|
||||||
|
statusQuery.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a timezone the system itself listed is ever passed on, so no
|
||||||
|
// caller-supplied text reaches the command.
|
||||||
|
function setTimezone(zone: string): bool {
|
||||||
|
if (root.zones.indexOf(zone) < 0) {
|
||||||
|
root.lastError = "That is not a timezone this system recognises.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (writeRun.running)
|
||||||
|
return false;
|
||||||
|
writeRun.exec(["timedatectl", "set-timezone", zone]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNtp(enabled: bool): bool {
|
||||||
|
if (writeRun.running)
|
||||||
|
return false;
|
||||||
|
writeRun.exec(["timedatectl", "set-ntp", enabled ? "true" : "false"]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ Singleton {
|
|||||||
property bool kdeInitialized: false
|
property bool kdeInitialized: false
|
||||||
property string previousOutput: ""
|
property string previousOutput: ""
|
||||||
property string previousBluetooth: ""
|
property string previousBluetooth: ""
|
||||||
property string previousKde: ""
|
property bool previousKdeReachable: false
|
||||||
|
property string previousKdeName: ""
|
||||||
|
|
||||||
PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
|
PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ Singleton {
|
|||||||
.join("\u001f");
|
.join("\u001f");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly property string kdeName: KdeConnect.preferredPhone?.name ?? ""
|
||||||
|
|
||||||
onOutputNameChanged: {
|
onOutputNameChanged: {
|
||||||
if (!root.outputInitialized) {
|
if (!root.outputInitialized) {
|
||||||
root.previousOutput = root.outputName;
|
root.previousOutput = root.outputName;
|
||||||
@@ -78,55 +81,41 @@ Singleton {
|
|||||||
onTriggered: {
|
onTriggered: {
|
||||||
root.previousOutput = root.outputName;
|
root.previousOutput = root.outputName;
|
||||||
root.previousBluetooth = root.bluetoothNames;
|
root.previousBluetooth = root.bluetoothNames;
|
||||||
|
root.previousKdeReachable = KdeConnect.phoneReachable;
|
||||||
|
root.previousKdeName = root.kdeName;
|
||||||
root.outputInitialized = true;
|
root.outputInitialized = true;
|
||||||
root.bluetoothInitialized = true;
|
root.bluetoothInitialized = true;
|
||||||
|
root.kdeInitialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Connections {
|
||||||
interval: 15000
|
target: KdeConnect
|
||||||
repeat: true
|
|
||||||
running: true
|
|
||||||
triggeredOnStart: true
|
|
||||||
onTriggered: {
|
|
||||||
if (!kdeProbe.running)
|
|
||||||
kdeProbe.running = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
function onPhoneReachableChanged(): void {
|
||||||
id: kdeProbe
|
if (!root.kdeInitialized)
|
||||||
command: ["kdeconnect-cli", "-a", "--name-only"]
|
return;
|
||||||
stdout: StdioCollector {
|
const reachable = KdeConnect.phoneReachable;
|
||||||
onStreamFinished: {
|
if (reachable === root.previousKdeReachable)
|
||||||
const names = this.text.split("\n")
|
return;
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(line => line && !/^\d+ devices? found$/.test(line))
|
|
||||||
.sort()
|
|
||||||
.join("\u001f");
|
|
||||||
if (root.kdeInitialized && names !== root.previousKde) {
|
|
||||||
const current = names ? names.split("\u001f") : [];
|
|
||||||
const previous = root.previousKde ? root.previousKde.split("\u001f") : [];
|
|
||||||
const connected = current.find(name => !previous.includes(name));
|
|
||||||
const disconnected = previous.find(name => !current.includes(name));
|
|
||||||
StatusEvents.publish({
|
StatusEvents.publish({
|
||||||
key: "device-kdeconnect",
|
key: "device-kdeconnect",
|
||||||
glyph: "\u{F03F2}",
|
glyph: "\u{F03F2}",
|
||||||
title: connected || disconnected || "Phone",
|
title: root.kdeName || root.previousKdeName || "Phone",
|
||||||
detail: connected ? "KDE Connect available" : "KDE Connect disconnected",
|
detail: reachable ? "KDE Connect available" : "KDE Connect disconnected",
|
||||||
tone: connected ? "accent" : "warn",
|
tone: reachable ? "accent" : "warn",
|
||||||
priority: StatusEvents.ambientPriority
|
priority: StatusEvents.ambientPriority
|
||||||
});
|
});
|
||||||
}
|
root.previousKdeReachable = reachable;
|
||||||
root.previousKde = names;
|
root.previousKdeName = root.kdeName || root.previousKdeName;
|
||||||
root.kdeInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
root.previousOutput = root.outputName;
|
root.previousOutput = root.outputName;
|
||||||
root.previousBluetooth = root.bluetoothNames;
|
root.previousBluetooth = root.bluetoothNames;
|
||||||
|
root.previousKdeReachable = KdeConnect.phoneReachable;
|
||||||
|
root.previousKdeName = root.kdeName;
|
||||||
discoverySettle.start();
|
discoverySettle.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Display configuration: resolution, refresh rate, scale, and rotation.
|
||||||
|
//
|
||||||
|
// This is the only page in Panama Settings where a wrong value can leave you
|
||||||
|
// unable to SEE the screen well enough to undo it. A mode the display cannot
|
||||||
|
// show, or a scale that makes everything unreadable, is not recoverable through
|
||||||
|
// the same UI that caused it.
|
||||||
|
//
|
||||||
|
// So a change is never applied irreversibly. It is applied, then reverted
|
||||||
|
// automatically after a countdown unless confirmed -- the same contract every
|
||||||
|
// desktop uses for this one setting, and for the same reason. Confirming is
|
||||||
|
// what writes it to the settings store; letting the countdown run leaves
|
||||||
|
// nothing behind.
|
||||||
|
//
|
||||||
|
// Applied with `hyprctl eval` and hl.monitor{}. As everywhere else in Panama,
|
||||||
|
// success means the value was read back from the compositor and matched, never
|
||||||
|
// that a command exited zero.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// [{ name, description, width, height, refreshRate, scale, transform,
|
||||||
|
// modes: [{ label, mode, width, height, refresh }] }]
|
||||||
|
property var monitors: []
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// Set while a change is applied but not yet confirmed.
|
||||||
|
property string pendingOutput: ""
|
||||||
|
property var pendingPrevious: null
|
||||||
|
property var pendingRequested: null
|
||||||
|
property bool pendingVerified: false
|
||||||
|
property bool revertQueued: false
|
||||||
|
property var revertExpected: null
|
||||||
|
property string revertReason: ""
|
||||||
|
property bool revertVerificationActive: false
|
||||||
|
property int operationGeneration: 0
|
||||||
|
property int revertGeneration: -1
|
||||||
|
property int secondsLeft: 0
|
||||||
|
|
||||||
|
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
||||||
|
readonly property bool canConfirm: root.awaitingConfirmation
|
||||||
|
&& root.pendingVerified
|
||||||
|
&& !root.busy
|
||||||
|
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
||||||
|
|| root.revertExpected !== null
|
||||||
|
|
||||||
|
readonly property int confirmSeconds: 15
|
||||||
|
|
||||||
|
readonly property var transforms: [
|
||||||
|
{ value: 0, label: "Landscape" },
|
||||||
|
{ value: 1, label: "Portrait" },
|
||||||
|
{ value: 2, label: "Landscape (flipped)" },
|
||||||
|
{ value: 3, label: "Portrait (flipped)" }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Scales that divide this desktop's common resolutions into whole pixels.
|
||||||
|
// Hyprland rejects a fractional scale that does not, and the message it
|
||||||
|
// gives is not something to put in front of a user.
|
||||||
|
readonly property var scales: [1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
property int generation: 0
|
||||||
|
command: ["hyprctl", "-j", "monitors"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.parse(this.text, query.generation)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "Could not read the connected displays.";
|
||||||
|
if (root.revertQueued && !applyRun.running && root.awaitingConfirmation)
|
||||||
|
root.performRevert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: applyRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (!root.awaitingConfirmation)
|
||||||
|
return;
|
||||||
|
if (root.revertQueued) {
|
||||||
|
if (!query.running)
|
||||||
|
root.performRevert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyTimer.attempts = 0;
|
||||||
|
verifyTimer.ticks = 0;
|
||||||
|
verifyTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: revertRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
// Exit status is advisory only. Hyprland's Lua bridge can report
|
||||||
|
// success without applying a value, so exact readback decides.
|
||||||
|
root.revertVerificationActive = true;
|
||||||
|
revertVerifyTimer.attempts = 0;
|
||||||
|
revertVerifyTimer.ticks = 0;
|
||||||
|
revertVerifyTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
function refresh(): bool {
|
||||||
|
if (!query.running) {
|
||||||
|
query.generation = root.operationGeneration;
|
||||||
|
query.running = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(text: string, generation: int): void {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
root.monitors = raw.map(monitor => {
|
||||||
|
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
||||||
|
const width = monitor.width ?? 0;
|
||||||
|
const height = monitor.height ?? 0;
|
||||||
|
const refreshRate = monitor.refreshRate ?? 0;
|
||||||
|
const current = modes
|
||||||
|
.filter(mode => mode.width === width && mode.height === height)
|
||||||
|
.sort((left, right) =>
|
||||||
|
Math.abs(left.refresh - refreshRate) - Math.abs(right.refresh - refreshRate))[0];
|
||||||
|
return {
|
||||||
|
name: monitor.name ?? "",
|
||||||
|
description: monitor.description ?? monitor.model ?? "Display",
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
refreshRate: refreshRate,
|
||||||
|
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
||||||
|
scale: monitor.scale ?? 1,
|
||||||
|
transform: monitor.transform ?? 0,
|
||||||
|
currentFormat: monitor.currentFormat ?? "",
|
||||||
|
colorPreset: monitor.colorManagementPreset ?? "",
|
||||||
|
vrr: monitor.vrr === true,
|
||||||
|
modes: modes
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (root.awaitingConfirmation && root.pendingRequested
|
||||||
|
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||||
|
root.pendingVerified = true;
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.lastError = "";
|
||||||
|
} else if (root.revertVerificationActive
|
||||||
|
&& generation === root.revertGeneration
|
||||||
|
&& root.revertExpected
|
||||||
|
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
||||||
|
revertVerifyTimer.stop();
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.revertGeneration = -1;
|
||||||
|
root.revertExpected = null;
|
||||||
|
if (root.revertReason === "")
|
||||||
|
root.lastError = "";
|
||||||
|
else
|
||||||
|
root.lastError = root.revertReason;
|
||||||
|
root.revertReason = "";
|
||||||
|
} else if (!root.awaitingConfirmation && !root.revertExpected && (
|
||||||
|
root.lastError === "Could not read the connected displays."
|
||||||
|
|| root.lastError === "The display list could not be read.")) {
|
||||||
|
root.lastError = "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "The display list could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "[email protected]" -> a sortable record. The compositor reports the same
|
||||||
|
// resolution at distinct rates such as 60.00 and 59.94. Those identities
|
||||||
|
// remain separate because confirmation and recovery must read back the
|
||||||
|
// exact mode the user chose, even when their rounded labels look similar.
|
||||||
|
function normaliseModes(raw: var): var {
|
||||||
|
const seen = {};
|
||||||
|
const out = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const match = String(entry).match(/^(\d+)x(\d+)@([\d.]+)Hz$/);
|
||||||
|
if (!match)
|
||||||
|
continue;
|
||||||
|
const width = Number(match[1]);
|
||||||
|
const height = Number(match[2]);
|
||||||
|
const refreshText = match[3];
|
||||||
|
const refresh = Number(refreshText);
|
||||||
|
const roundedRefresh = Math.round(refresh);
|
||||||
|
const key = `${width}x${height}@${refreshText}`;
|
||||||
|
if (seen[key])
|
||||||
|
continue;
|
||||||
|
seen[key] = true;
|
||||||
|
out.push({
|
||||||
|
label: `${width} × ${height}`,
|
||||||
|
refreshLabel: Math.abs(refresh - roundedRefresh) < 0.005
|
||||||
|
? `${roundedRefresh} Hz`
|
||||||
|
: `${refresh.toFixed(2)} Hz`,
|
||||||
|
mode: `${width}x${height}@${refreshText}`,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
refresh: refresh
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.refresh - a.refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitorNamed(name: string): var {
|
||||||
|
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeParts(mode: string): var {
|
||||||
|
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
width: Number(match[1]),
|
||||||
|
height: Number(match[2]),
|
||||||
|
refresh: Number(match[3])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScaleClean(mode: string, scale: real): bool {
|
||||||
|
const parts = root.modeParts(mode);
|
||||||
|
if (!parts || root.scales.indexOf(scale) < 0 || !Number.isFinite(scale) || scale <= 0)
|
||||||
|
return false;
|
||||||
|
const logicalWidth = parts.width / scale;
|
||||||
|
const logicalHeight = parts.height / scale;
|
||||||
|
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.0001
|
||||||
|
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalesForMode(mode: string): var {
|
||||||
|
return root.scales.filter(scale => root.isScaleClean(mode, scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestCleanScale(mode: string, preferred: real): real {
|
||||||
|
const choices = root.scalesForMode(mode);
|
||||||
|
if (choices.length === 0)
|
||||||
|
return 1.0;
|
||||||
|
return choices.reduce((best, candidate) =>
|
||||||
|
Math.abs(candidate - preferred) < Math.abs(best - preferred) ? candidate : best,
|
||||||
|
choices[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRequest(monitor: var, requested: var): bool {
|
||||||
|
if (!monitor || !requested || monitor.name !== requested.output)
|
||||||
|
return false;
|
||||||
|
const parts = root.modeParts(requested.mode);
|
||||||
|
return !!parts
|
||||||
|
&& monitor.width === parts.width
|
||||||
|
&& monitor.height === parts.height
|
||||||
|
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
||||||
|
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
||||||
|
&& monitor.transform === requested.transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||||
|
return !!monitor && !!candidate
|
||||||
|
&& monitor.width === candidate.width
|
||||||
|
&& monitor.height === candidate.height
|
||||||
|
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||||
|
// settings file is only written by confirm().
|
||||||
|
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||||
|
if (root.busy) {
|
||||||
|
root.lastError = "Wait for the current display operation to finish.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (root.awaitingConfirmation) {
|
||||||
|
root.lastError = "Finish the current display change first.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const monitor = root.monitorNamed(output);
|
||||||
|
if (!monitor) {
|
||||||
|
root.lastError = "That display is not connected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
||||||
|
root.lastError = "That display does not offer that mode.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.isScaleClean(mode, scale)) {
|
||||||
|
root.lastError = "That scale does not divide this resolution cleanly.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
||||||
|
root.lastError = "That rotation is not one Panama offers.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.pendingPrevious = {
|
||||||
|
output: output,
|
||||||
|
mode: monitor.mode,
|
||||||
|
scale: monitor.scale,
|
||||||
|
transform: monitor.transform
|
||||||
|
};
|
||||||
|
root.operationGeneration++;
|
||||||
|
root.pendingRequested = {
|
||||||
|
output: output,
|
||||||
|
mode: mode,
|
||||||
|
scale: scale,
|
||||||
|
transform: transform
|
||||||
|
};
|
||||||
|
root.pendingOutput = output;
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertQueued = false;
|
||||||
|
root.secondsLeft = root.confirmSeconds;
|
||||||
|
root.lastError = "";
|
||||||
|
countdown.restart();
|
||||||
|
|
||||||
|
root.push(output, mode, scale, transform);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(output: string, mode: string, scale: real, transform: int): void {
|
||||||
|
// Values are validated above and the output name comes from the
|
||||||
|
// compositor's own list, so nothing user-authored reaches the payload.
|
||||||
|
applyRun.exec(["hyprctl", "eval",
|
||||||
|
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(): bool {
|
||||||
|
if (!root.canConfirm || !root.matchesRequest(
|
||||||
|
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||||
|
if (root.awaitingConfirmation)
|
||||||
|
root.lastError = "Wait for the display to finish applying before keeping it.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||||
|
next[root.pendingOutput] = {
|
||||||
|
mode: root.pendingRequested.mode,
|
||||||
|
scale: root.pendingRequested.scale,
|
||||||
|
transform: root.pendingRequested.transform
|
||||||
|
};
|
||||||
|
if (!DesktopPreferences.set("displays", next)) {
|
||||||
|
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.clearPending();
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPending(): void {
|
||||||
|
countdown.stop();
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.pendingOutput = "";
|
||||||
|
root.pendingPrevious = null;
|
||||||
|
root.pendingRequested = null;
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertQueued = false;
|
||||||
|
root.secondsLeft = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revert(): void {
|
||||||
|
root.revertWithMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertWithMessage(message: string): void {
|
||||||
|
if (!root.awaitingConfirmation)
|
||||||
|
return;
|
||||||
|
countdown.stop();
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertReason = message;
|
||||||
|
if (message !== "")
|
||||||
|
root.lastError = message;
|
||||||
|
if (applyRun.running || query.running) {
|
||||||
|
root.revertQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.performRevert();
|
||||||
|
}
|
||||||
|
|
||||||
|
function performRevert(): void {
|
||||||
|
const previous = root.pendingPrevious;
|
||||||
|
root.operationGeneration++;
|
||||||
|
root.revertGeneration = root.operationGeneration;
|
||||||
|
root.revertExpected = previous;
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.clearPending();
|
||||||
|
if (previous) {
|
||||||
|
revertRun.exec(["hyprctl", "eval",
|
||||||
|
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears any stored override for an output so it returns to the value
|
||||||
|
// shipped in hypr/monitors.lua on the next start.
|
||||||
|
function forget(output: string): void {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
if (!stored || typeof stored !== "object" || stored[output] === undefined)
|
||||||
|
return;
|
||||||
|
const next = Object.assign({}, stored);
|
||||||
|
delete next[output];
|
||||||
|
DesktopPreferences.set("displays", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverridden(output: string): bool {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: verifyTimer
|
||||||
|
property int attempts: 0
|
||||||
|
property int ticks: 0
|
||||||
|
interval: 120
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
ticks++;
|
||||||
|
if (ticks > 50) {
|
||||||
|
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.refresh())
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: revertVerifyTimer
|
||||||
|
property int attempts: 0
|
||||||
|
property int ticks: 0
|
||||||
|
interval: 120
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
ticks++;
|
||||||
|
if (ticks > 50) {
|
||||||
|
stop();
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.revertGeneration = -1;
|
||||||
|
root.revertExpected = null;
|
||||||
|
root.revertReason = "";
|
||||||
|
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.refresh())
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: countdown
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
root.secondsLeft -= 1;
|
||||||
|
if (root.secondsLeft <= 0)
|
||||||
|
root.revert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Home Assistant state for the Control Center. Credentials and REST details
|
||||||
|
// remain behind the helper; QML composes its catalog with Panama preferences.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant"
|
||||||
|
|
||||||
|
// "loading" | "ready" | "degraded" | "unavailable"
|
||||||
|
property string phase: "loading"
|
||||||
|
property var catalog: []
|
||||||
|
property var selectedEntities: []
|
||||||
|
property bool stale: false
|
||||||
|
property string lastError: ""
|
||||||
|
property bool fixtureMode: false
|
||||||
|
property var fixtureFavorites: []
|
||||||
|
property string fixtureProcessMode: ""
|
||||||
|
|
||||||
|
property var actionQueue: []
|
||||||
|
property var activeAction: null
|
||||||
|
property var busyEntityIds: []
|
||||||
|
property var pendingBrightness: ({})
|
||||||
|
property var entityErrors: ({})
|
||||||
|
property string actionResponseText: ""
|
||||||
|
property bool actionStreamFinished: false
|
||||||
|
property bool actionExited: false
|
||||||
|
property int actionExitCode: 0
|
||||||
|
property bool fixtureTransitionDraining: false
|
||||||
|
property bool fixtureTransitionStreamFinished: false
|
||||||
|
property bool fixtureTransitionExited: false
|
||||||
|
property var pendingFixtureTarget: null
|
||||||
|
property int delayedFixtureExitCode: 0
|
||||||
|
|
||||||
|
readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
|
||||||
|
readonly property int discoveredCount: root.catalog.length
|
||||||
|
readonly property int configuredCount: root.selectedEntities.length
|
||||||
|
readonly property bool actionProcessRunning: actionProc.running
|
||||||
|
|
||||||
|
// Temporary aliases keep the current Home controls usable until the shelf
|
||||||
|
// switches to the selected-entity and per-entity action interfaces.
|
||||||
|
readonly property var entities: root.selectedEntities
|
||||||
|
readonly property string busyEntityId: root.busyEntityIds.length > 0
|
||||||
|
? root.busyEntityIds[0]
|
||||||
|
: ""
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (root.fixtureMode || refreshProc.running)
|
||||||
|
return;
|
||||||
|
if (root.catalog.length === 0)
|
||||||
|
root.phase = "loading";
|
||||||
|
refreshProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeCatalog(text: string): void {
|
||||||
|
if (root.fixtureMode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
try {
|
||||||
|
result = JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
result = { ok: false, error: "invalid-response" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ok === true) {
|
||||||
|
root.catalog = Array.isArray(result.entities) ? result.entities : [];
|
||||||
|
HomePreferences.initialize(Array.isArray(result.legacyEntityIds)
|
||||||
|
? result.legacyEntityIds
|
||||||
|
: []);
|
||||||
|
root.rebuildSelection();
|
||||||
|
root.phase = "ready";
|
||||||
|
root.stale = false;
|
||||||
|
root.lastError = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.lastError = String(result.error || "unreachable");
|
||||||
|
if (root.catalog.length > 0) {
|
||||||
|
root.phase = "degraded";
|
||||||
|
root.stale = true;
|
||||||
|
} else {
|
||||||
|
root.phase = "unavailable";
|
||||||
|
root.stale = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildSelection(): void {
|
||||||
|
const favorites = root.fixtureMode
|
||||||
|
? root.fixtureFavorites
|
||||||
|
: HomePreferences.favorites;
|
||||||
|
const catalogById = {};
|
||||||
|
for (let index = 0; index < root.catalog.length; index++) {
|
||||||
|
const entity = root.catalog[index];
|
||||||
|
catalogById[entity.id] = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelection = [];
|
||||||
|
for (let index = 0; index < favorites.length; index++) {
|
||||||
|
const favorite = favorites[index];
|
||||||
|
const entity = catalogById[favorite.id];
|
||||||
|
const alias = String(favorite.alias || "").trim();
|
||||||
|
if (entity) {
|
||||||
|
nextSelection.push({
|
||||||
|
id: entity.id,
|
||||||
|
sourceName: entity.sourceName,
|
||||||
|
name: alias || entity.sourceName,
|
||||||
|
state: entity.state,
|
||||||
|
available: entity.available,
|
||||||
|
active: entity.active,
|
||||||
|
dimmable: entity.dimmable,
|
||||||
|
brightnessPct: entity.brightnessPct
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceName = favorite.id.split(".")[1].split("_").join(" ");
|
||||||
|
nextSelection.push({
|
||||||
|
id: favorite.id,
|
||||||
|
sourceName,
|
||||||
|
name: alias || sourceName,
|
||||||
|
state: "unavailable",
|
||||||
|
available: false,
|
||||||
|
active: false,
|
||||||
|
dimmable: false,
|
||||||
|
brightnessPct: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root.selectedEntities = nextSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: HomePreferences
|
||||||
|
function onFavoritesChanged(): void {
|
||||||
|
if (!root.fixtureMode)
|
||||||
|
root.rebuildSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBusy(entityId: string): bool {
|
||||||
|
return root.busyEntityIds.indexOf(entityId) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingFor(entityId: string): int {
|
||||||
|
return Object.prototype.hasOwnProperty.call(root.pendingBrightness, entityId)
|
||||||
|
? root.pendingBrightness[entityId]
|
||||||
|
: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorFor(entityId: string): string {
|
||||||
|
return String(root.entityErrors[entityId] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEntity(entityId: string): void {
|
||||||
|
root.enqueueAction({ kind: "toggle", entityId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBrightness(entityId: string, percent: int): void {
|
||||||
|
if (!Number.isInteger(percent) || percent < 0 || percent > 100)
|
||||||
|
return;
|
||||||
|
root.enqueueAction({ kind: "brightness", entityId, percent });
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueAction(action: var): void {
|
||||||
|
if (root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
if (!action || (action.kind !== "toggle" && action.kind !== "brightness"))
|
||||||
|
return;
|
||||||
|
if (typeof action.entityId !== "string" || action.entityId === "" || root.isBusy(action.entityId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const entity = root.catalog.find(candidate => candidate.id === action.entityId);
|
||||||
|
if (!entity || !entity.available || (action.kind === "brightness" && !entity.dimmable))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const nextErrors = Object.assign({}, root.entityErrors);
|
||||||
|
delete nextErrors[action.entityId];
|
||||||
|
root.entityErrors = nextErrors;
|
||||||
|
root.busyEntityIds = root.busyEntityIds.concat([action.entityId]);
|
||||||
|
|
||||||
|
if (action.kind === "brightness") {
|
||||||
|
const nextPending = Object.assign({}, root.pendingBrightness);
|
||||||
|
nextPending[action.entityId] = action.percent;
|
||||||
|
root.pendingBrightness = nextPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.fixtureMode && root.fixtureProcessMode === "") {
|
||||||
|
root.applyFixtureAction(action);
|
||||||
|
root.finishActionState(action.entityId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.actionQueue = root.actionQueue.concat([action]);
|
||||||
|
root.startNextAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNextAction(): void {
|
||||||
|
if (root.fixtureTransitionDraining
|
||||||
|
|| actionProc.running
|
||||||
|
|| root.activeAction !== null
|
||||||
|
|| root.actionQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.activeAction = root.actionQueue[0];
|
||||||
|
root.actionResponseText = "";
|
||||||
|
root.actionStreamFinished = false;
|
||||||
|
root.actionExited = false;
|
||||||
|
root.actionExitCode = 0;
|
||||||
|
if (root.fixtureProcessMode !== "") {
|
||||||
|
actionProc.command = root.fixtureActionCommand(root.activeAction);
|
||||||
|
} else {
|
||||||
|
actionProc.command = root.activeAction.kind === "brightness"
|
||||||
|
? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)]
|
||||||
|
: [root.helperPath, "toggle", root.activeAction.entityId];
|
||||||
|
}
|
||||||
|
actionProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixtureActionCommand(action: var): var {
|
||||||
|
if (root.fixtureProcessMode === "delayed-exit")
|
||||||
|
return ["/usr/bin/sh", "-c", "printf '%s' '{\"ok\":true}'; exit 7"];
|
||||||
|
if (root.fixtureProcessMode === "no-output"
|
||||||
|
&& action.entityId === "light.fixture_kitchen") {
|
||||||
|
return ["/usr/bin/sh", "-c", "sleep 0.5; exit 7"];
|
||||||
|
}
|
||||||
|
if (root.fixtureProcessMode === "slow-success")
|
||||||
|
return ["/usr/bin/sh", "-c", "sleep 2; printf '%s' '{\"ok\":true}'"];
|
||||||
|
return ["/usr/bin/sh", "-c", "sleep 0.5; printf '%s' '{\"ok\":true}'"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionStreamFinished(text: string): void {
|
||||||
|
if (root.fixtureTransitionDraining) {
|
||||||
|
root.fixtureTransitionStreamFinished = true;
|
||||||
|
fixtureTransitionTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.activeAction === null || root.actionStreamFinished)
|
||||||
|
return;
|
||||||
|
root.actionResponseText = text;
|
||||||
|
root.actionStreamFinished = true;
|
||||||
|
actionCompletionTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionExited(exitCode: int): void {
|
||||||
|
// This fixture-only delay gives the contract a deterministic window
|
||||||
|
// where the process stopped but its exit bookkeeping is still pending.
|
||||||
|
if (root.fixtureMode
|
||||||
|
&& root.fixtureProcessMode === "delayed-exit"
|
||||||
|
&& root.activeAction !== null
|
||||||
|
&& !root.fixtureTransitionDraining) {
|
||||||
|
root.delayedFixtureExitCode = exitCode;
|
||||||
|
delayedFixtureExitTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.recordActionExited(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordActionExited(exitCode: int): void {
|
||||||
|
if (root.fixtureTransitionDraining) {
|
||||||
|
root.fixtureTransitionExited = true;
|
||||||
|
fixtureTransitionTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.activeAction === null || root.actionExited)
|
||||||
|
return;
|
||||||
|
root.actionExited = true;
|
||||||
|
root.actionExitCode = exitCode;
|
||||||
|
actionCompletionTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCompleteAction(): void {
|
||||||
|
if (root.activeAction === null)
|
||||||
|
return;
|
||||||
|
if (actionProc.running) {
|
||||||
|
actionCompletionTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!root.actionExited || !root.actionStreamFinished)
|
||||||
|
return;
|
||||||
|
root.consumeAction(root.actionResponseText, root.actionExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeAction(text: string, exitCode: int): void {
|
||||||
|
if (root.activeAction === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const completedAction = root.activeAction;
|
||||||
|
let ok = false;
|
||||||
|
let errorCode = text === "" ? "action-failed" : "invalid-response";
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(text);
|
||||||
|
ok = exitCode === 0 && result.ok === true;
|
||||||
|
errorCode = String(result.error || "action-failed");
|
||||||
|
} catch (error) {
|
||||||
|
// Empty output from a failed process is an action failure, while
|
||||||
|
// malformed non-empty output remains an invalid response.
|
||||||
|
}
|
||||||
|
|
||||||
|
actionCompletionTimer.stop();
|
||||||
|
root.actionQueue = root.actionQueue.slice(1);
|
||||||
|
root.activeAction = null;
|
||||||
|
root.finishActionState(completedAction.entityId);
|
||||||
|
|
||||||
|
const nextErrors = Object.assign({}, root.entityErrors);
|
||||||
|
if (ok) {
|
||||||
|
delete nextErrors[completedAction.entityId];
|
||||||
|
if (root.fixtureMode && root.fixtureProcessMode !== "")
|
||||||
|
root.applyFixtureAction(completedAction);
|
||||||
|
else
|
||||||
|
refreshDelay.restart();
|
||||||
|
} else {
|
||||||
|
nextErrors[completedAction.entityId] = errorCode;
|
||||||
|
}
|
||||||
|
root.entityErrors = nextErrors;
|
||||||
|
queueAdvanceTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishActionState(entityId: string): void {
|
||||||
|
root.busyEntityIds = root.busyEntityIds.filter(id => id !== entityId);
|
||||||
|
const nextPending = Object.assign({}, root.pendingBrightness);
|
||||||
|
delete nextPending[entityId];
|
||||||
|
root.pendingBrightness = nextPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFixtureAction(action: var): void {
|
||||||
|
root.catalog = root.catalog.map(entity => {
|
||||||
|
if (entity.id !== action.entityId)
|
||||||
|
return entity;
|
||||||
|
if (action.kind === "brightness") {
|
||||||
|
return Object.assign({}, entity, {
|
||||||
|
state: action.percent === 0 ? "off" : "on",
|
||||||
|
active: action.percent > 0,
|
||||||
|
brightnessPct: action.percent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Object.assign({}, entity, {
|
||||||
|
active: !entity.active,
|
||||||
|
state: entity.active ? "off" : "on"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
root.rebuildSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(): void {
|
||||||
|
Quickshell.execDetached([root.helperPath, "open"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixtureEntities(): var {
|
||||||
|
return [
|
||||||
|
{ id: "light.fixture_all", sourceName: "All lights", state: "on", available: true, active: true, dimmable: true, brightnessPct: 82 },
|
||||||
|
{ id: "light.fixture_kitchen", sourceName: "Kitchen", state: "on", available: true, active: true, dimmable: true, brightnessPct: 71 },
|
||||||
|
{ id: "light.fixture_living", sourceName: "Living room", state: "off", available: true, active: false, dimmable: true, brightnessPct: 36 },
|
||||||
|
{ id: "light.fixture_bedroom", sourceName: "Bedroom", state: "on", available: true, active: true, dimmable: true, brightnessPct: 48 },
|
||||||
|
{ id: "light.fixture_hall", sourceName: "Hall", state: "off", available: true, active: false, dimmable: false, brightnessPct: 0 },
|
||||||
|
{ id: "light.fixture_desk", sourceName: "Desk", state: "on", available: true, active: true, dimmable: true, brightnessPct: 24 },
|
||||||
|
{ id: "light.fixture_corner", sourceName: "Corner lamp", state: "unavailable", available: false, active: false, dimmable: false, brightnessPct: 0 }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixturePreferenceRecords(): var {
|
||||||
|
return [
|
||||||
|
{ id: "light.fixture_all", alias: " Whole home " },
|
||||||
|
{ id: "light.fixture_kitchen", alias: "Kitchen island" },
|
||||||
|
{ id: "light.fixture_living", alias: "" },
|
||||||
|
{ id: "light.fixture_bedroom", alias: "" },
|
||||||
|
{ id: "light.fixture_hall", alias: "" },
|
||||||
|
{ id: "light.fixture_desk", alias: "Office" },
|
||||||
|
{ id: "light.fixture_corner", alias: "" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetActionState(): void {
|
||||||
|
actionCompletionTimer.stop();
|
||||||
|
queueAdvanceTimer.stop();
|
||||||
|
root.actionQueue = [];
|
||||||
|
root.activeAction = null;
|
||||||
|
root.busyEntityIds = [];
|
||||||
|
root.pendingBrightness = {};
|
||||||
|
root.entityErrors = {};
|
||||||
|
root.actionResponseText = "";
|
||||||
|
root.actionStreamFinished = false;
|
||||||
|
root.actionExited = false;
|
||||||
|
root.actionExitCode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginFixtureTransition(): void {
|
||||||
|
if (root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const shouldDrain = root.fixtureMode
|
||||||
|
&& root.fixtureProcessMode !== ""
|
||||||
|
&& (actionProc.running || root.activeAction !== null);
|
||||||
|
const streamAlreadyFinished = root.actionStreamFinished;
|
||||||
|
const exitAlreadyObserved = root.actionExited;
|
||||||
|
|
||||||
|
fixtureTransitionTimer.stop();
|
||||||
|
root.fixtureTransitionDraining = shouldDrain;
|
||||||
|
root.fixtureTransitionStreamFinished = shouldDrain && streamAlreadyFinished;
|
||||||
|
root.fixtureTransitionExited = shouldDrain && exitAlreadyObserved;
|
||||||
|
root.resetActionState();
|
||||||
|
|
||||||
|
if (!shouldDrain)
|
||||||
|
return;
|
||||||
|
if (actionProc.running)
|
||||||
|
actionProc.running = false;
|
||||||
|
fixtureTransitionTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryFinishFixtureTransition(): void {
|
||||||
|
if (!root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
if (actionProc.running) {
|
||||||
|
fixtureTransitionTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!root.fixtureTransitionStreamFinished || !root.fixtureTransitionExited)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const target = root.pendingFixtureTarget;
|
||||||
|
root.pendingFixtureTarget = null;
|
||||||
|
root.fixtureTransitionDraining = false;
|
||||||
|
root.fixtureTransitionStreamFinished = false;
|
||||||
|
root.fixtureTransitionExited = false;
|
||||||
|
if (target !== null)
|
||||||
|
root.installFixtureTarget(target);
|
||||||
|
queueAdvanceTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFixture(name: string): void {
|
||||||
|
if (["ready", "available-extra", "stale", "stale-authentication", "stale-not-configured",
|
||||||
|
"unavailable", "missing-selected", "action-error",
|
||||||
|
"process-actions", "process-no-output", "process-delayed-exit",
|
||||||
|
"process-slow-actions"].indexOf(name) < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
root.requestFixtureTarget({ fixture: true, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFixtureTarget(target: var): void {
|
||||||
|
if (root.fixtureTransitionDraining) {
|
||||||
|
// Preserve the old process's callback guard and keep only the
|
||||||
|
// latest replacement requested during that drain.
|
||||||
|
root.pendingFixtureTarget = target;
|
||||||
|
root.resetActionState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.pendingFixtureTarget = target;
|
||||||
|
root.beginFixtureTransition();
|
||||||
|
if (root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
|
||||||
|
root.pendingFixtureTarget = null;
|
||||||
|
root.installFixtureTarget(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFixtureTarget(target: var): void {
|
||||||
|
if (target.fixture)
|
||||||
|
root.installFixture(target.name);
|
||||||
|
else
|
||||||
|
root.installLiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFixture(name: string): void {
|
||||||
|
root.fixtureMode = true;
|
||||||
|
root.fixtureProcessMode = name === "process-actions"
|
||||||
|
? "success"
|
||||||
|
: (name === "process-no-output"
|
||||||
|
? "no-output"
|
||||||
|
: (name === "process-delayed-exit"
|
||||||
|
? "delayed-exit"
|
||||||
|
: (name === "process-slow-actions" ? "slow-success" : "")));
|
||||||
|
if (name === "unavailable") {
|
||||||
|
root.catalog = [];
|
||||||
|
root.fixtureFavorites = [];
|
||||||
|
root.rebuildSelection();
|
||||||
|
root.phase = "unavailable";
|
||||||
|
root.stale = false;
|
||||||
|
root.lastError = "not-configured";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.catalog = root.fixtureEntities();
|
||||||
|
root.fixtureFavorites = root.fixturePreferenceRecords();
|
||||||
|
if (name === "available-extra") {
|
||||||
|
root.catalog = root.catalog.concat([{
|
||||||
|
id: "light.fixture_guest",
|
||||||
|
sourceName: "Guest lamp",
|
||||||
|
state: "off",
|
||||||
|
available: true,
|
||||||
|
active: false,
|
||||||
|
dimmable: true,
|
||||||
|
brightnessPct: 0
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
if (name === "missing-selected") {
|
||||||
|
root.fixtureFavorites = root.fixtureFavorites.concat([
|
||||||
|
{ id: "light.fixture_missing", alias: "Porch" }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
root.rebuildSelection();
|
||||||
|
const isStale = ["stale", "stale-authentication", "stale-not-configured"].indexOf(name) >= 0;
|
||||||
|
root.phase = isStale ? "degraded" : "ready";
|
||||||
|
root.stale = isStale;
|
||||||
|
root.lastError = name === "stale-authentication"
|
||||||
|
? "authentication-required"
|
||||||
|
: (name === "stale-not-configured"
|
||||||
|
? "not-configured"
|
||||||
|
: (name === "stale" ? "unreachable" : ""));
|
||||||
|
if (name === "action-error") {
|
||||||
|
root.entityErrors = {
|
||||||
|
"light.fixture_kitchen": "request-failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFixture(): void {
|
||||||
|
root.requestFixtureTarget({ fixture: false, name: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function installLiveState(): void {
|
||||||
|
root.fixtureMode = false;
|
||||||
|
root.fixtureProcessMode = "";
|
||||||
|
root.phase = "loading";
|
||||||
|
root.catalog = [];
|
||||||
|
root.selectedEntities = [];
|
||||||
|
root.fixtureFavorites = [];
|
||||||
|
root.stale = false;
|
||||||
|
root.lastError = "";
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: refreshProc
|
||||||
|
command: [root.helperPath, "catalog"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.consumeCatalog(this.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: actionProc
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.handleActionStreamFinished(this.text)
|
||||||
|
}
|
||||||
|
onExited: (code, status) => root.handleActionExited(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: actionCompletionTimer
|
||||||
|
interval: 0
|
||||||
|
onTriggered: root.tryCompleteAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: queueAdvanceTimer
|
||||||
|
interval: 0
|
||||||
|
onTriggered: {
|
||||||
|
if (root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
if (actionProc.running) {
|
||||||
|
queueAdvanceTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.startNextAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: fixtureTransitionTimer
|
||||||
|
interval: 0
|
||||||
|
onTriggered: root.tryFinishFixtureTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: delayedFixtureExitTimer
|
||||||
|
interval: 1000
|
||||||
|
onTriggered: root.recordActionExited(root.delayedFixtureExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 60000
|
||||||
|
repeat: true
|
||||||
|
running: ShellState.quickSettingsOpen && !root.fixtureMode
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshDelay
|
||||||
|
interval: 350
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Idle, lock, and sleep timings.
|
||||||
|
//
|
||||||
|
// hypridle has no IPC for reconfiguration, and its config is hyprlang rather
|
||||||
|
// than the shared JSON, so this cannot work the way the compositor settings do.
|
||||||
|
// Instead scripts/panama-idle regenerates a config from the settings store and
|
||||||
|
// restarts the daemon.
|
||||||
|
//
|
||||||
|
// The generated file lives under XDG_STATE_HOME rather than ~/.config/hypr,
|
||||||
|
// because that directory is a symlink into the Panama repository -- writing
|
||||||
|
// there at runtime would put machine state into a tracked file. A systemd
|
||||||
|
// drop-in points hypridle at the generated path with `-c`.
|
||||||
|
//
|
||||||
|
// "Managed" is therefore a real state with two sides: when the drop-in is
|
||||||
|
// installed the timings below are in effect, and when it is not, hypridle is
|
||||||
|
// running the repository's shipped hypridle.conf and these values are only a
|
||||||
|
// stored intention. The Power page says which it is rather than showing
|
||||||
|
// controls that quietly do nothing.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-idle"
|
||||||
|
|
||||||
|
property bool managed: false
|
||||||
|
property string serviceState: "unknown"
|
||||||
|
property string generatedPath: ""
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
readonly property bool busy: statusQuery.running || applyRun.running
|
||||||
|
|
||||||
|
// The values as stored. They only describe what is running when `managed`.
|
||||||
|
readonly property int blankMinutes: DesktopPreferences.get("screenBlankMinutes")
|
||||||
|
readonly property int lockMinutes: DesktopPreferences.get("lockMinutes")
|
||||||
|
readonly property int suspendMinutes: DesktopPreferences.get("suspendMinutes")
|
||||||
|
readonly property bool lockOnSleep: DesktopPreferences.get("lockOnSleep")
|
||||||
|
|
||||||
|
// Blanking after locking is legal but pointless, and blanking with lock off
|
||||||
|
// is fine. Surfacing the one genuinely confusing combination beats silently
|
||||||
|
// reordering the user's numbers.
|
||||||
|
readonly property bool lockBeforeBlank: root.lockMinutes > 0
|
||||||
|
&& root.blankMinutes > 0
|
||||||
|
&& root.lockMinutes < root.blankMinutes
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusQuery
|
||||||
|
command: [root.helperPath, "status"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(this.text);
|
||||||
|
root.managed = state.managed === true;
|
||||||
|
root.serviceState = String(state.active ?? "unknown");
|
||||||
|
root.generatedPath = String(state.generated ?? "");
|
||||||
|
root.lastError = "";
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "Could not read the idle configuration.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: applyRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.lastError = exitCode === 0 ? "" : "Could not update the idle configuration.";
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!statusQuery.running)
|
||||||
|
statusQuery.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerates the config from the current settings and restarts hypridle if
|
||||||
|
// Panama is managing it. Safe to call when it is not: the file is written
|
||||||
|
// and nothing is restarted.
|
||||||
|
function apply(): void {
|
||||||
|
if (applyRun.running)
|
||||||
|
return;
|
||||||
|
applyRun.exec([root.helperPath, "apply"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setManaged(enabled: bool): void {
|
||||||
|
if (applyRun.running)
|
||||||
|
return;
|
||||||
|
applyRun.exec([root.helperPath, enabled ? "install" : "remove"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate whenever one of the four inputs changes. Coalesced, because a
|
||||||
|
// slider drag settles through several commits and each one would otherwise
|
||||||
|
// restart the daemon.
|
||||||
|
Connections {
|
||||||
|
target: DesktopPreferences
|
||||||
|
function onRevisionChanged(): void {
|
||||||
|
if (root.managed)
|
||||||
|
regenerate.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: regenerate
|
||||||
|
interval: 400
|
||||||
|
onTriggered: root.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Shared KDE Connect state. The Python helper owns all CLI and D-Bus parsing;
|
||||||
|
// this singleton exposes only normalized devices and user-initiated actions.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-kdeconnect"
|
||||||
|
|
||||||
|
property bool fixtureMode: false
|
||||||
|
property bool available: false
|
||||||
|
property var devices: []
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
property bool transferActive: false
|
||||||
|
property string transferFileName: ""
|
||||||
|
property string transferDeviceName: ""
|
||||||
|
property var recentExchange: null
|
||||||
|
|
||||||
|
property string actionKind: ""
|
||||||
|
property string actionPath: ""
|
||||||
|
|
||||||
|
readonly property var preferredPhone: {
|
||||||
|
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
|
||||||
|
return phones.find(device => device.reachable) ?? phones[0] ?? null;
|
||||||
|
}
|
||||||
|
readonly property bool phoneReachable: root.preferredPhone?.reachable === true
|
||||||
|
readonly property int pairedCount: root.devices.filter(device => device.paired).length
|
||||||
|
readonly property var phoneActions: root.preferredPhone?.actions ?? []
|
||||||
|
|
||||||
|
function supports(action: string): bool {
|
||||||
|
return root.phoneActions.indexOf(action) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (root.fixtureMode || statusProc.running)
|
||||||
|
return;
|
||||||
|
statusProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeStatus(text: string): void {
|
||||||
|
if (root.fixtureMode)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(text);
|
||||||
|
root.available = result.available === true;
|
||||||
|
root.devices = Array.isArray(result.devices) ? result.devices : [];
|
||||||
|
root.lastError = String(result.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.available = false;
|
||||||
|
root.devices = [];
|
||||||
|
root.lastError = "invalid-response";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(path: string): void {
|
||||||
|
if (!root.phoneReachable || !root.supports("share") || path === "" || actionProc.running)
|
||||||
|
return;
|
||||||
|
root.actionKind = "share";
|
||||||
|
root.actionPath = path;
|
||||||
|
root.transferActive = true;
|
||||||
|
root.transferFileName = path.split("/").pop();
|
||||||
|
root.transferDeviceName = root.preferredPhone.name;
|
||||||
|
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
|
||||||
|
actionProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendClipboard(): void {
|
||||||
|
root.runSimpleAction("clipboard", "send-clipboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ring(): void {
|
||||||
|
root.runSimpleAction("ring", "ring");
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSimpleAction(kind: string, command: string): void {
|
||||||
|
if (!root.phoneReachable || !root.supports(kind) || actionProc.running)
|
||||||
|
return;
|
||||||
|
root.actionKind = kind;
|
||||||
|
root.actionPath = "";
|
||||||
|
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
|
||||||
|
actionProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelTransfer(): void {
|
||||||
|
if (actionProc.running && root.actionKind === "share")
|
||||||
|
actionProc.signal(2);
|
||||||
|
root.actionKind = "";
|
||||||
|
root.actionPath = "";
|
||||||
|
root.transferActive = false;
|
||||||
|
root.transferFileName = "";
|
||||||
|
root.transferDeviceName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishAction(text: string): void {
|
||||||
|
if (root.actionKind === "")
|
||||||
|
return;
|
||||||
|
|
||||||
|
const kind = root.actionKind;
|
||||||
|
const localPath = root.actionPath;
|
||||||
|
const fileName = root.transferFileName;
|
||||||
|
const deviceName = root.transferDeviceName || root.preferredPhone?.name || "Phone";
|
||||||
|
let result = null;
|
||||||
|
try {
|
||||||
|
result = JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
result = { ok: false, error: "invalid-response" };
|
||||||
|
}
|
||||||
|
|
||||||
|
root.actionKind = "";
|
||||||
|
root.actionPath = "";
|
||||||
|
if (kind === "share") {
|
||||||
|
root.transferActive = false;
|
||||||
|
root.transferFileName = "";
|
||||||
|
root.transferDeviceName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ok === true && kind === "share") {
|
||||||
|
root.recentExchange = {
|
||||||
|
direction: "sent",
|
||||||
|
fileName: String(result.fileName || fileName),
|
||||||
|
deviceName,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
StatusEvents.publish({
|
||||||
|
key: "phone-transfer",
|
||||||
|
glyph: "\u{F03F2}",
|
||||||
|
title: "Sent to " + deviceName,
|
||||||
|
detail: String(result.fileName || fileName),
|
||||||
|
tone: "ok",
|
||||||
|
priority: StatusEvents.importantPriority,
|
||||||
|
actionId: localPath !== "" ? "open-path" : "",
|
||||||
|
actionData: localPath
|
||||||
|
});
|
||||||
|
} else if (result.ok !== true) {
|
||||||
|
root.lastError = String(result.error || "action-failed");
|
||||||
|
StatusEvents.publish({
|
||||||
|
key: "phone-action",
|
||||||
|
glyph: "\u{F03F2}",
|
||||||
|
title: kind === "share" ? "File was not sent" : "Phone action failed",
|
||||||
|
detail: "Check that the phone is nearby and try again",
|
||||||
|
tone: "warn",
|
||||||
|
priority: StatusEvents.importantPriority
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refreshDelay.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFixture(name: string): void {
|
||||||
|
if (["reachable", "offline", "transfer"].indexOf(name) < 0)
|
||||||
|
return;
|
||||||
|
root.fixtureMode = true;
|
||||||
|
root.available = true;
|
||||||
|
root.lastError = "";
|
||||||
|
root.recentExchange = null;
|
||||||
|
root.devices = [{
|
||||||
|
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
name: "Fixture iPhone",
|
||||||
|
type: "phone",
|
||||||
|
paired: true,
|
||||||
|
reachable: name !== "offline",
|
||||||
|
actions: ["clipboard", "ping", "ring", "share"]
|
||||||
|
}];
|
||||||
|
root.transferActive = name === "transfer";
|
||||||
|
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
|
||||||
|
root.transferDeviceName = name === "transfer" ? "Fixture iPhone" : "";
|
||||||
|
root.actionKind = name === "transfer" ? "share" : "";
|
||||||
|
root.actionPath = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFixture(): void {
|
||||||
|
root.fixtureMode = false;
|
||||||
|
root.available = false;
|
||||||
|
root.devices = [];
|
||||||
|
root.lastError = "";
|
||||||
|
root.transferActive = false;
|
||||||
|
root.transferFileName = "";
|
||||||
|
root.transferDeviceName = "";
|
||||||
|
root.actionKind = "";
|
||||||
|
root.actionPath = "";
|
||||||
|
root.recentExchange = null;
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusProc
|
||||||
|
command: [root.helperPath, "status"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.consumeStatus(this.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: actionProc
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.finishAction(this.text)
|
||||||
|
}
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (root.actionKind !== "")
|
||||||
|
root.finishAction("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 30000
|
||||||
|
repeat: true
|
||||||
|
running: !root.fixtureMode
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshDelay
|
||||||
|
interval: 500
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The keymap, read from the compositor rather than restated.
|
||||||
|
//
|
||||||
|
// The Shortcuts page used to hold a hand-typed array of nineteen entries while
|
||||||
|
// keybinds.lua produced a hundred and thirteen. It could not show the other
|
||||||
|
// ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j`
|
||||||
|
// is the only description of the keymap that cannot be wrong, so this reads
|
||||||
|
// that and every bind carries its own human label (see the `description`
|
||||||
|
// argument in hypr/keybinds.lua).
|
||||||
|
//
|
||||||
|
// Refreshed on demand, not polled: binds only change when the config is
|
||||||
|
// reloaded, and nothing in this shell should wake up to re-read something that
|
||||||
|
// has not moved.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// [{ chord, description, group, mouse, repeating, locked }], ordered as
|
||||||
|
// Hyprland reports them, which follows the order they appear in the config.
|
||||||
|
property var binds: []
|
||||||
|
property bool loaded: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
readonly property bool busy: query.running
|
||||||
|
|
||||||
|
// Hyprland's modmask bits. SUPER is the Panama modifier.
|
||||||
|
readonly property var modifierBits: [
|
||||||
|
{ bit: 64, name: "Super" },
|
||||||
|
{ bit: 4, name: "Ctrl" },
|
||||||
|
{ bit: 8, name: "Alt" },
|
||||||
|
{ bit: 1, name: "Shift" }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Keysyms whose raw names would be noise in a shortcuts list.
|
||||||
|
readonly property var keyNames: ({
|
||||||
|
"mouse_up": "Scroll up",
|
||||||
|
"mouse_down": "Scroll down",
|
||||||
|
"mouse:272": "Left click",
|
||||||
|
"mouse:273": "Right click",
|
||||||
|
"mouse:274": "Middle click",
|
||||||
|
"bracketleft": "[",
|
||||||
|
"bracketright": "]",
|
||||||
|
"grave": "`",
|
||||||
|
"Print": "Print Screen",
|
||||||
|
"left": "←",
|
||||||
|
"right": "→",
|
||||||
|
"up": "↑",
|
||||||
|
"down": "↓"
|
||||||
|
})
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: ["hyprctl", "-j", "binds"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.parse(this.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "Could not read the keymap from Hyprland.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rebinding ───────────────────────────────────────────────────────────
|
||||||
|
// Overrides map a SHIPPED chord to a replacement. hypr/keybinds.lua reads
|
||||||
|
// them and substitutes only the chord -- the action is always the Lua value
|
||||||
|
// written in that file -- so an override can move a shortcut but can never
|
||||||
|
// make one do something else.
|
||||||
|
//
|
||||||
|
// Applying needs `hyprctl reload` rather than a live `hl.bind`: Hyprland
|
||||||
|
// reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
|
||||||
|
// so the action cannot be reconstructed from the outside to re-bind it.
|
||||||
|
// Reload re-runs the config, which re-reads the settings file.
|
||||||
|
readonly property var overrides: {
|
||||||
|
const stored = DesktopPreferences.get("keybindOverrides");
|
||||||
|
return (stored && typeof stored === "object") ? stored : ({});
|
||||||
|
}
|
||||||
|
|
||||||
|
property bool reloading: false
|
||||||
|
|
||||||
|
// The chord a bind ships with, given the chord it currently answers to.
|
||||||
|
// Displayed binds come from the compositor and so already reflect any
|
||||||
|
// override; the override map is what tells us where they started.
|
||||||
|
function shippedChordFor(currentChord: string): string {
|
||||||
|
for (const shipped in root.overrides) {
|
||||||
|
if (root.overrides[shipped] === currentChord)
|
||||||
|
return shipped;
|
||||||
|
}
|
||||||
|
return currentChord;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverridden(currentChord: string): bool {
|
||||||
|
return root.shippedChordFor(currentChord) !== currentChord;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuses a chord already answering to something else, so rebinding cannot
|
||||||
|
// quietly shadow an existing shortcut.
|
||||||
|
function conflictFor(chord: string, exceptCurrent: string): string {
|
||||||
|
for (const bind of root.binds) {
|
||||||
|
if (bind.luaChord === chord && bind.luaChord !== exceptCurrent)
|
||||||
|
return bind.description;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
const conflict = root.conflictFor(newChord, currentChord);
|
||||||
|
if (conflict !== "") {
|
||||||
|
root.lastError = `${newChord} is already ${conflict}.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shipped = root.shippedChordFor(currentChord);
|
||||||
|
const next = Object.assign({}, root.overrides);
|
||||||
|
if (newChord === shipped)
|
||||||
|
delete next[shipped];
|
||||||
|
else
|
||||||
|
next[shipped] = newChord;
|
||||||
|
|
||||||
|
if (!DesktopPreferences.set("keybindOverrides", next)) {
|
||||||
|
root.lastError = "That shortcut could not be saved.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.applyReload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBind(currentChord: string): bool {
|
||||||
|
const shipped = root.shippedChordFor(currentChord);
|
||||||
|
if (shipped === currentChord)
|
||||||
|
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];
|
||||||
|
if (!DesktopPreferences.set("keybindOverrides", next)) {
|
||||||
|
root.lastError = "That shortcut could not be reset.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.applyReload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll(): void {
|
||||||
|
if (Object.keys(root.overrides).length === 0)
|
||||||
|
return;
|
||||||
|
DesktopPreferences.set("keybindOverrides", ({}));
|
||||||
|
root.applyReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: reloadRun
|
||||||
|
command: ["hyprctl", "reload"]
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.reloading = false;
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.lastError = "The compositor did not reload.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
// The settings file is written on a timer, so re-read the keymap
|
||||||
|
// once the reload has had a moment to pick it up.
|
||||||
|
settle.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: settle
|
||||||
|
interval: 350
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReload(): void {
|
||||||
|
if (reloadRun.running)
|
||||||
|
return;
|
||||||
|
root.reloading = true;
|
||||||
|
// Give DesktopPreferences' coalescing write a moment to land first.
|
||||||
|
reloadDelay.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: reloadDelay
|
||||||
|
interval: 120
|
||||||
|
onTriggered: reloadRun.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (query.running)
|
||||||
|
return;
|
||||||
|
root.lastError = "";
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(text: string): void {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
const out = [];
|
||||||
|
for (const bind of raw) {
|
||||||
|
const description = String(bind.description ?? "").trim();
|
||||||
|
// A bind with no description cannot be presented usefully --
|
||||||
|
// the dispatcher is "__lua" and the argument is a bytecode
|
||||||
|
// offset. Showing the chord alone would be worse than omitting
|
||||||
|
// it, and tests/quickshell/keybinds-contract.sh fails the build
|
||||||
|
// if any exist, so this should never be reached in practice.
|
||||||
|
if (description === "")
|
||||||
|
continue;
|
||||||
|
out.push({
|
||||||
|
chord: root.formatChord(bind),
|
||||||
|
// The same chord in the form hypr/keybinds.lua writes, which
|
||||||
|
// is what an override is keyed by. The display form
|
||||||
|
// prettifies modifiers and arrow keys and so cannot be used
|
||||||
|
// for that.
|
||||||
|
luaChord: root.luaChord(bind),
|
||||||
|
description: description,
|
||||||
|
group: root.groupFor(description, bind),
|
||||||
|
mouse: bind.mouse === true,
|
||||||
|
repeating: bind.repeat === true,
|
||||||
|
locked: bind.locked === true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root.binds = out;
|
||||||
|
root.loaded = true;
|
||||||
|
root.lastError = "";
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "The keymap could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "SUPER + SHIFT + K" -- uppercase modifiers in the order keybinds.lua
|
||||||
|
// writes them, then the raw keysym rather than its display name.
|
||||||
|
function luaChord(bind: var): string {
|
||||||
|
const parts = [];
|
||||||
|
for (const modifier of root.modifierBits) {
|
||||||
|
if ((bind.modmask & modifier.bit) !== 0)
|
||||||
|
parts.push(modifier.name.toUpperCase());
|
||||||
|
}
|
||||||
|
parts.push(String(bind.key ?? ""));
|
||||||
|
return parts.join(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChord(bind: var): string {
|
||||||
|
const parts = [];
|
||||||
|
for (const modifier of root.modifierBits) {
|
||||||
|
if ((bind.modmask & modifier.bit) !== 0)
|
||||||
|
parts.push(modifier.name);
|
||||||
|
}
|
||||||
|
const key = String(bind.key ?? "");
|
||||||
|
parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
|
||||||
|
return parts.join(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouping is by what the shortcut does, taken from its own description,
|
||||||
|
// so adding a bind puts it in the right section without touching this file.
|
||||||
|
function groupFor(description: string, bind: var): string {
|
||||||
|
const text = description.toLowerCase();
|
||||||
|
if (bind.key && String(bind.key).indexOf("XF86") === 0)
|
||||||
|
return "Media & hardware keys";
|
||||||
|
if (text.indexOf("workspace") >= 0)
|
||||||
|
return "Workspaces";
|
||||||
|
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|
||||||
|
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|
||||||
|
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|
||||||
|
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|
||||||
|
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|
||||||
|
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|
||||||
|
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
|
||||||
|
return "Windows";
|
||||||
|
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|
||||||
|
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|
||||||
|
|| text.indexOf("brightness") >= 0)
|
||||||
|
return "Media & hardware keys";
|
||||||
|
return "Applications & shell";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section order for the page. Anything a future bind invents lands at the
|
||||||
|
// end rather than being dropped.
|
||||||
|
readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"]
|
||||||
|
|
||||||
|
function grouped(): var {
|
||||||
|
const buckets = {};
|
||||||
|
for (const bind of root.binds) {
|
||||||
|
buckets[bind.group] = buckets[bind.group] ?? [];
|
||||||
|
buckets[bind.group].push(bind);
|
||||||
|
}
|
||||||
|
const names = Object.keys(buckets).sort((a, b) => {
|
||||||
|
const ia = root.groupOrder.indexOf(a);
|
||||||
|
const ib = root.groupOrder.indexOf(b);
|
||||||
|
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
|
||||||
|
});
|
||||||
|
return names.map(name => ({ name: name, binds: buckets[name] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ Singleton {
|
|||||||
|
|
||||||
// Follow Settings.nightLightFrom .. nightLightTo instead of the manual
|
// Follow Settings.nightLightFrom .. nightLightTo instead of the manual
|
||||||
// switch. Off by default because GNOME's schedule was disabled.
|
// switch. Off by default because GNOME's schedule was disabled.
|
||||||
property bool automatic: DesktopPreferences.nightLightAutomatic
|
property bool automatic: DesktopPreferences.get("nightLightAutomatic")
|
||||||
|
|
||||||
// What is actually applied right now.
|
// What is actually applied right now.
|
||||||
readonly property bool active: root.automatic ? root.scheduled : root.enabled
|
readonly property bool active: root.automatic ? root.scheduled : root.enabled
|
||||||
@@ -81,13 +81,13 @@ Singleton {
|
|||||||
|
|
||||||
// Re-tune in place; restarting the daemon would flash the display.
|
// Re-tune in place; restarting the daemon would flash the display.
|
||||||
onTemperatureChanged: {
|
onTemperatureChanged: {
|
||||||
DesktopPreferences.nightLightTemperature = root.temperature;
|
DesktopPreferences.set("nightLightTemperature", root.temperature);
|
||||||
if (daemon.running)
|
if (daemon.running)
|
||||||
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", String(root.temperature)]);
|
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", String(root.temperature)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEnabledChanged: DesktopPreferences.nightLightEnabled = root.enabled
|
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
|
||||||
onAutomaticChanged: DesktopPreferences.nightLightAutomatic = root.automatic
|
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic)
|
||||||
|
|
||||||
onActiveChanged: {
|
onActiveChanged: {
|
||||||
if (!root.initialized)
|
if (!root.initialized)
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ Singleton {
|
|||||||
if (PrivacyState.microphoneActive)
|
if (PrivacyState.microphoneActive)
|
||||||
result.push(root.privacyActivity("microphone", "\u{F036C}", "Microphone", PrivacyState.microphoneApp));
|
result.push(root.privacyActivity("microphone", "\u{F036C}", "Microphone", PrivacyState.microphoneApp));
|
||||||
|
|
||||||
|
if (KdeConnect.transferActive) {
|
||||||
|
result.push({
|
||||||
|
kind: "phone-transfer",
|
||||||
|
glyph: "\u{F03F2}",
|
||||||
|
label: "Sending to " + (KdeConnect.transferDeviceName || "Phone"),
|
||||||
|
detail: KdeConnect.transferFileName || "Preparing file",
|
||||||
|
state: "Sending",
|
||||||
|
tone: "accent",
|
||||||
|
action: "Cancel"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +95,8 @@ Singleton {
|
|||||||
function activate(kind: string): void {
|
function activate(kind: string): void {
|
||||||
if (kind === "focus")
|
if (kind === "focus")
|
||||||
FocusSession.activateWorkspace();
|
FocusSession.activateWorkspace();
|
||||||
|
else if (kind === "phone-transfer")
|
||||||
|
ShellState.open("quicksettings");
|
||||||
else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0)
|
else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0)
|
||||||
ShellState.open("activity");
|
ShellState.open("activity");
|
||||||
}
|
}
|
||||||
@@ -94,5 +108,7 @@ Singleton {
|
|||||||
Caffeine.toggle();
|
Caffeine.toggle();
|
||||||
else if (kind === "recording")
|
else if (kind === "recording")
|
||||||
Capture.stopRecording();
|
Capture.stopRecording();
|
||||||
|
else if (kind === "phone-transfer")
|
||||||
|
KdeConnect.cancelTransfer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Snapshots of Panama's durable settings stores.
|
||||||
|
//
|
||||||
|
// DesktopPreferences and HomePreferences use separate files. The helper owns
|
||||||
|
// the transactional filesystem boundary; this service owns settling the live
|
||||||
|
// desktop after those files have changed underneath it.
|
||||||
|
//
|
||||||
|
// HomePreferences intentionally keeps its FileView in Quickshell's private
|
||||||
|
// state directory while snapshots use Panama's canonical state directory. This
|
||||||
|
// service bridges them through HomePreferences' public mutation API, then soft
|
||||||
|
// reloads once external consumers have settled.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-backup"
|
||||||
|
|
||||||
|
property var snapshots: []
|
||||||
|
property string lastError: ""
|
||||||
|
property string lastAction: ""
|
||||||
|
|
||||||
|
// Narrow service boundaries keep restore sequencing explicit and make it
|
||||||
|
// possible to verify the real handler in an isolated shell without ever
|
||||||
|
// calling the daily-driver compositor or wallpaper services.
|
||||||
|
property var readHomeState: function() {
|
||||||
|
return {
|
||||||
|
initialized: HomePreferences.initialized,
|
||||||
|
favorites: HomePreferences.favorites
|
||||||
|
};
|
||||||
|
}
|
||||||
|
property var resetHome: function() { HomePreferences.resetHomeDefaults(); }
|
||||||
|
property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
|
||||||
|
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
|
||||||
|
property var reloadDesktop: function() { DesktopPreferences.reload(); }
|
||||||
|
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
||||||
|
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||||
|
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||||
|
property var systemBusy: function() { return SystemSettings.busy; }
|
||||||
|
property var currentWallpaper: function() {
|
||||||
|
return String(DesktopPreferences.get("wallpaperPath") ?? "");
|
||||||
|
}
|
||||||
|
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||||
|
property var reloadShell: function() { Quickshell.reload(false); }
|
||||||
|
|
||||||
|
readonly property bool busy: listQuery.running || actionRun.running
|
||||||
|
|| applyRestoredState.running || settleReload.running
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: listQuery
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
||||||
|
if (root.lastError === "Could not read the list of snapshots.")
|
||||||
|
root.lastError = "";
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "Could not read the list of snapshots.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: actionRun
|
||||||
|
property bool restoring: false
|
||||||
|
property string outputText: ""
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: actionRun.outputText = this.text
|
||||||
|
}
|
||||||
|
onStarted: actionRun.outputText = ""
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.lastError = actionRun.restoring
|
||||||
|
? "That snapshot could not be restored."
|
||||||
|
: "The settings could not be backed up.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||||
|
if (actionRun.restoring) {
|
||||||
|
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
|
||||||
|
root.lastError = homeReloaded
|
||||||
|
? ""
|
||||||
|
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||||
|
} else
|
||||||
|
root.lastError = "";
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: applyRestoredState
|
||||||
|
interval: 80
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
||||||
|
// These services also own state outside QML and need an explicit
|
||||||
|
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
||||||
|
root.applyCompositor();
|
||||||
|
root.reloadKeybinds();
|
||||||
|
root.applyWallpaper(root.currentWallpaper());
|
||||||
|
|
||||||
|
settleReload.attempts = 0;
|
||||||
|
settleReload.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: settleReload
|
||||||
|
property int attempts: 0
|
||||||
|
interval: 100
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
attempts++;
|
||||||
|
// Let the current instances finish their external writes before a
|
||||||
|
// soft reload replaces them. The cap keeps a failed external tool
|
||||||
|
// from leaving restored Home state stale indefinitely.
|
||||||
|
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
||||||
|
stop();
|
||||||
|
root.reloadShell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!listQuery.running)
|
||||||
|
listQuery.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
if (actionRun.running)
|
||||||
|
return;
|
||||||
|
actionRun.restoring = false;
|
||||||
|
actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialiseHomeState(): string {
|
||||||
|
const current = root.readHomeState();
|
||||||
|
const favorites = [];
|
||||||
|
for (const favorite of current.favorites ?? []) {
|
||||||
|
favorites.push({
|
||||||
|
id: String(favorite.id ?? ""),
|
||||||
|
alias: String(favorite.alias ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
initialized: current.initialized === true,
|
||||||
|
favorites: favorites
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRestoreOutput(text: string): bool {
|
||||||
|
if (!root.reloadHomeState(text))
|
||||||
|
return false;
|
||||||
|
root.reloadDesktop();
|
||||||
|
applyRestoredState.restart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore output carries the canonical Home state. Reconstructing through
|
||||||
|
// these methods keeps validation and persistence inside HomePreferences;
|
||||||
|
// this service never mutates its aliases or private FileView directly.
|
||||||
|
function reloadHomeState(text: string): bool {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(text);
|
||||||
|
const restored = result?.home;
|
||||||
|
if (!restored || restored.preserve === true)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (restored.present !== true)
|
||||||
|
return restored.present === false
|
||||||
|
? root.resetHomeState()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const data = restored.data;
|
||||||
|
if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites))
|
||||||
|
return false;
|
||||||
|
const ids = [];
|
||||||
|
const aliases = [];
|
||||||
|
const seen = {};
|
||||||
|
for (const favorite of data.favorites) {
|
||||||
|
const id = favorite?.id;
|
||||||
|
const alias = favorite?.alias;
|
||||||
|
if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id)
|
||||||
|
|| typeof alias !== "string" || seen[id])
|
||||||
|
return false;
|
||||||
|
seen[id] = true;
|
||||||
|
ids.push(id);
|
||||||
|
aliases.push(alias);
|
||||||
|
}
|
||||||
|
if (!data.initialized && ids.length > 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
root.resetHome();
|
||||||
|
if (!data.initialized)
|
||||||
|
return true;
|
||||||
|
root.initializeHome(ids);
|
||||||
|
for (let index = 0; index < ids.length; index++)
|
||||||
|
root.aliasHome(ids[index], aliases[index]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHomeState(): bool {
|
||||||
|
root.resetHome();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The name is matched against the snapshot list rather than trusted, so no
|
||||||
|
// caller-supplied path reaches the helper even though it validates as well.
|
||||||
|
function restore(name: string): bool {
|
||||||
|
if (actionRun.running)
|
||||||
|
return false;
|
||||||
|
if (!root.snapshots.some(snapshot => snapshot.name === name)) {
|
||||||
|
root.lastError = "That snapshot is not in the list.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
actionRun.restoring = true;
|
||||||
|
actionRun.exec([root.helperPath, "restore", name]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Search across every setting, not just page names.
|
||||||
|
//
|
||||||
|
// The sidebar's field used to filter the twelve page labels, so "gaps",
|
||||||
|
// "wallpaper", and "repeat delay" all found nothing — which is precisely the
|
||||||
|
// thing that makes a settings app feel smaller than it is. This indexes the
|
||||||
|
// schema itself, so any setting is reachable by typing what it does, and a new
|
||||||
|
// schema entry becomes searchable with no change here.
|
||||||
|
//
|
||||||
|
// Shortcuts are indexed too: "screenshot" should find the key that takes one.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Which page shows the settings in a given schema group. A group with no
|
||||||
|
// entry here still appears in results and routes to Home rather than being
|
||||||
|
// dropped, so adding a group can never make a setting unreachable.
|
||||||
|
readonly property var groupPages: ({
|
||||||
|
"clock": "appearance",
|
||||||
|
"vitals": "appearance",
|
||||||
|
"windows": "appearance",
|
||||||
|
"effects": "appearance",
|
||||||
|
"wallpaper": "appearance",
|
||||||
|
"dock": "desktop",
|
||||||
|
"focus": "desktop",
|
||||||
|
"display": "displays",
|
||||||
|
"idle": "power",
|
||||||
|
"accessibility": "accessibility",
|
||||||
|
"input": "shortcuts",
|
||||||
|
"weather": "appearance",
|
||||||
|
"notifications": "notifications",
|
||||||
|
"capture": "screen-intelligence"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Settings that are real but have no schema entry, because the system owns
|
||||||
|
// them rather than Panama. Without these, searching "timezone" would fail
|
||||||
|
// on a settings app that plainly has one.
|
||||||
|
readonly property var extraEntries: [
|
||||||
|
{ label: "Timezone", detail: "Set the system timezone", page: "datetime" },
|
||||||
|
{ label: "Network time", detail: "Synchronise the clock with a time server", page: "datetime" },
|
||||||
|
{ label: "Wi-Fi", detail: "Managed by GNOME Settings", page: "connectivity" },
|
||||||
|
{ label: "Bluetooth", detail: "Managed by GNOME Settings", page: "connectivity" },
|
||||||
|
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" },
|
||||||
|
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
|
||||||
|
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
||||||
|
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" }
|
||||||
|
]
|
||||||
|
|
||||||
|
function pageFor(group: string): string {
|
||||||
|
return root.groupPages[group] ?? "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
// [{ label, detail, page, kind }] for a query. Empty query yields nothing:
|
||||||
|
// the sidebar shows its normal navigation in that case.
|
||||||
|
function search(query: string): var {
|
||||||
|
const needle = String(query).trim().toLowerCase();
|
||||||
|
if (needle === "")
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const seen = {};
|
||||||
|
|
||||||
|
function add(label, detail, page, kind) {
|
||||||
|
const dedupe = `${kind}:${label}:${page}`;
|
||||||
|
if (seen[dedupe])
|
||||||
|
return;
|
||||||
|
seen[dedupe] = true;
|
||||||
|
results.push({ label: label, detail: detail, page: page, kind: kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of PreferenceSchema.entries) {
|
||||||
|
if (entry.internal)
|
||||||
|
continue;
|
||||||
|
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group}`.toLowerCase();
|
||||||
|
if (haystack.indexOf(needle) >= 0)
|
||||||
|
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of root.extraEntries) {
|
||||||
|
if (`${entry.label} ${entry.detail}`.toLowerCase().indexOf(needle) >= 0)
|
||||||
|
add(entry.label, entry.detail, entry.page, "setting");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bind of Keybinds.binds) {
|
||||||
|
if (bind.description.toLowerCase().indexOf(needle) >= 0)
|
||||||
|
add(bind.description, bind.chord, "shortcuts", "shortcut");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact prefix matches first: typing "blur" should put "Blur" above
|
||||||
|
// "Blur radius", and both above a setting that merely mentions blur in
|
||||||
|
// its explanation.
|
||||||
|
return results.sort((a, b) => {
|
||||||
|
const al = a.label.toLowerCase();
|
||||||
|
const bl = b.label.toLowerCase();
|
||||||
|
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
|
||||||
|
const bp = bl === needle ? 0 : (bl.indexOf(needle) === 0 ? 1 : 2);
|
||||||
|
return ap !== bp ? ap - bp : al.localeCompare(bl);
|
||||||
|
}).slice(0, 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,9 +92,9 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
function openSettings(page: string): void {
|
||||||
const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
|
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"];
|
||||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.lastPage = root.settingsPage;
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ Singleton {
|
|||||||
root.closeSettings();
|
root.closeSettings();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.openSettings(DesktopPreferences.lastPage || "home");
|
root.openSettings(DesktopPreferences.get("lastPage") || "home");
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSettings(): void {
|
function closeSettings(): void {
|
||||||
|
|||||||
@@ -27,17 +27,41 @@ Singleton {
|
|||||||
property bool hyprpaperActive: false
|
property bool hyprpaperActive: false
|
||||||
property bool hypridleActive: false
|
property bool hypridleActive: false
|
||||||
property bool vicinaeActive: false
|
property bool vicinaeActive: false
|
||||||
|
property bool bluebubblesDetected: false
|
||||||
|
|
||||||
property string hyprlandVersion: ""
|
property string hyprlandVersion: ""
|
||||||
property string quickshellVersion: "0.3.0"
|
property string quickshellVersion: "0.3.0"
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||||
|| autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running
|
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
||||||
|
|
||||||
readonly property bool autoHdr: DesktopPreferences.autoHdr
|
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
|
||||||
readonly property int vrrPolicy: DesktopPreferences.vrrPolicy
|
|
||||||
readonly property int directScanoutPolicy: DesktopPreferences.directScanoutPolicy
|
readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
|
||||||
|
readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
|
||||||
|
readonly property int directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy")
|
||||||
|
|
||||||
|
// ── The Hyprland write boundary ─────────────────────────────────────────
|
||||||
|
// Every option Panama may write, with the hl.config path used to set it and
|
||||||
|
// the getoption path used to read it back. The UI never names an option or
|
||||||
|
// supplies a raw value: it calls a setter, which resolves the option here
|
||||||
|
// and range-checks the value against `allowed`. Nothing user-supplied is
|
||||||
|
// ever interpolated into the payload.
|
||||||
|
//
|
||||||
|
// `hyprctl keyword` is deliberately NOT used. On a Lua-configured Hyprland
|
||||||
|
// it refuses the write, prints "keyword can't work with non-legacy parsers"
|
||||||
|
// to stdout, and still exits 0 -- so code branching on the exit status
|
||||||
|
// believes it succeeded. `hyprctl eval` has the same hazard: it exits 0 on
|
||||||
|
// syntax and runtime errors, reporting them as an "error:" line instead.
|
||||||
|
//
|
||||||
|
// Success therefore means exactly one thing here: the value was read back
|
||||||
|
// from the compositor and matched what was requested.
|
||||||
|
//
|
||||||
|
// The set of writable options is not restated here: it is every schema
|
||||||
|
// entry carrying a `hypr` block. Adding a live-adjustable Hyprland setting
|
||||||
|
// is a schema entry plus a prefs.get() call in the Lua, and needs no new
|
||||||
|
// code in this file.
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: monitorQuery
|
id: monitorQuery
|
||||||
@@ -79,42 +103,39 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Applies a validated batch of options in one `hl.config{}` call, then hands
|
||||||
|
// off to configVerify. Never commits anything on its own: an "ok" here only
|
||||||
|
// means Hyprland parsed the payload.
|
||||||
Process {
|
Process {
|
||||||
id: autoHdrWrite
|
id: configWrite
|
||||||
property bool requested: true
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
// id -> integer value, already validated by applyOptions().
|
||||||
if (exitCode === 0) {
|
property var pending: ({})
|
||||||
DesktopPreferences.autoHdr = requested;
|
|
||||||
root.lastError = "";
|
stdout: StdioCollector {
|
||||||
} else {
|
onStreamFinished: {
|
||||||
root.lastError = "Hyprland rejected the HDR policy.";
|
if (this.text.indexOf("error:") >= 0) {
|
||||||
|
root.reportWriteFailure(configWrite.pending, this.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.verifyPending();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: vrrWrite
|
id: bluebubblesQuery
|
||||||
property int requested: 3
|
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
|
||||||
if (exitCode === 0) {
|
|
||||||
DesktopPreferences.vrrPolicy = requested;
|
|
||||||
root.lastError = "";
|
|
||||||
} else {
|
|
||||||
root.lastError = "Hyprland rejected the VRR policy.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reads the written options back out of the compositor. This is the only
|
||||||
|
// thing that decides whether a write succeeded.
|
||||||
Process {
|
Process {
|
||||||
id: directScanoutWrite
|
id: configVerify
|
||||||
property int requested: 2
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
stdout: StdioCollector {
|
||||||
if (exitCode === 0) {
|
onStreamFinished: root.commitVerified(configWrite.pending, this.text)
|
||||||
DesktopPreferences.directScanoutPolicy = requested;
|
|
||||||
root.lastError = "";
|
|
||||||
} else {
|
|
||||||
root.lastError = "Hyprland rejected the direct-scanout policy.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +157,8 @@ Singleton {
|
|||||||
serviceQuery.running = true;
|
serviceQuery.running = true;
|
||||||
if (!versionQuery.running && !root.hyprlandVersion)
|
if (!versionQuery.running && !root.hyprlandVersion)
|
||||||
versionQuery.running = true;
|
versionQuery.running = true;
|
||||||
|
if (!bluebubblesQuery.running)
|
||||||
|
bluebubblesQuery.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMonitors(text: string): void {
|
function parseMonitors(text: string): void {
|
||||||
@@ -172,39 +195,252 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Applying options ────────────────────────────────────────────────────
|
||||||
|
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
|
||||||
|
// The whole batch is validated before anything is sent, so one bad value
|
||||||
|
// rejects the batch rather than half-applying it.
|
||||||
|
function applyOptions(values: var): bool {
|
||||||
|
const requested = {};
|
||||||
|
for (const key in values) {
|
||||||
|
const entry = PreferenceSchema.spec(key);
|
||||||
|
if (!entry || !entry.hypr) {
|
||||||
|
root.lastError = "That setting is not applied by the compositor.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const coerced = PreferenceSchema.coerce(key, values[key]);
|
||||||
|
if (coerced === undefined) {
|
||||||
|
root.lastError = `Unsupported value for ${entry.label}.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
requested[key] = coerced;
|
||||||
|
}
|
||||||
|
if (Object.keys(requested).length === 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// A write in flight is queued rather than refused. Options are applied
|
||||||
|
// and verified one batch at a time, but the callers are a settings UI
|
||||||
|
// and a startup replay of every compositor-backed preference -- they
|
||||||
|
// overlap routinely, and dropping a change on the floor would leave the
|
||||||
|
// stored value and the compositor disagreeing. Later values for the
|
||||||
|
// same key win.
|
||||||
|
if (configWrite.running || configVerify.running) {
|
||||||
|
root.queued = Object.assign({}, root.queued, requested);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.startWrite(requested);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged batches waiting for the current write to finish.
|
||||||
|
property var queued: ({})
|
||||||
|
|
||||||
|
function startWrite(requested: var): void {
|
||||||
|
configWrite.pending = requested;
|
||||||
|
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when a write settles, however it settled. A failed batch must not
|
||||||
|
// strand whatever queued up behind it.
|
||||||
|
function drainQueue(): void {
|
||||||
|
const next = root.queued;
|
||||||
|
if (Object.keys(next).length === 0)
|
||||||
|
return;
|
||||||
|
root.queued = ({});
|
||||||
|
root.startWrite(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The value as Hyprland stores it. Several options are a toggle in the UI
|
||||||
|
// but an integer in the compositor (cm_auto_hdr, follow_mouse); `readAs`
|
||||||
|
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
||||||
|
// prefs.getInt so both sides agree.
|
||||||
|
function hyprValue(entry: var, value: var): var {
|
||||||
|
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
||||||
|
return value ? 1 : 0;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialises validated values into a nested hl.config{} call. Table paths
|
||||||
|
// come from the schema and values have already passed coerce(), including
|
||||||
|
// the pattern check on constrained strings, so nothing caller-supplied
|
||||||
|
// reaches the payload unchecked.
|
||||||
|
function buildConfigPayload(requested: var): string {
|
||||||
|
const tree = {};
|
||||||
|
for (const key in requested) {
|
||||||
|
const entry = PreferenceSchema.spec(key);
|
||||||
|
const path = entry.hypr.path;
|
||||||
|
let node = tree;
|
||||||
|
for (let i = 0; i < path.length - 1; i++)
|
||||||
|
node = node[path[i]] = node[path[i]] ?? {};
|
||||||
|
node[path[path.length - 1]] = root.serialiseValue(root.hyprValue(entry, requested[key]));
|
||||||
|
}
|
||||||
|
return `hl.config(${root.serialiseTable(tree)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialiseValue(value: var): string {
|
||||||
|
if (typeof value === "boolean")
|
||||||
|
return value ? "true" : "false";
|
||||||
|
if (typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
// Strings only reach here after the schema's pattern check; quoting is
|
||||||
|
// belt-and-braces rather than the primary defence.
|
||||||
|
return `"${String(value).replace(/["\\]/g, "")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialiseTable(node: var): string {
|
||||||
|
const parts = [];
|
||||||
|
for (const name in node) {
|
||||||
|
const child = node[name];
|
||||||
|
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`);
|
||||||
|
}
|
||||||
|
return `{ ${parts.join(", ")} }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPending(): void {
|
||||||
|
const options = Object.keys(configWrite.pending)
|
||||||
|
.map(key => `getoption ${PreferenceSchema.spec(key).hypr.option}`)
|
||||||
|
.join(" ; ");
|
||||||
|
configVerify.exec(["hyprctl", "-j", "--batch", options]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compositor's answer is authoritative. Preferences are only updated for
|
||||||
|
// options that actually read back with the requested value.
|
||||||
|
function commitVerified(requested: var, text: string): void {
|
||||||
|
// Each getoption answers with its own flat JSON object, and the field
|
||||||
|
// carrying the value depends on the option's type -- int, bool, float,
|
||||||
|
// str, or css for the gap box.
|
||||||
|
const observed = {};
|
||||||
|
for (const block of text.match(/\{[^{}]*\}/g) ?? []) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(block);
|
||||||
|
if (parsed.option !== undefined)
|
||||||
|
observed[parsed.option] = parsed;
|
||||||
|
} catch (error) {
|
||||||
|
// A partial line is treated as "not observed", which fails the
|
||||||
|
// comparison below rather than being mistaken for success.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejected = [];
|
||||||
|
for (const key in requested) {
|
||||||
|
const entry = PreferenceSchema.spec(key);
|
||||||
|
if (!root.matchesObserved(entry, requested[key], observed[entry.hypr.option])) {
|
||||||
|
rejected.push(entry.label);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DesktopPreferences.set(key, requested[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
|
||||||
|
root.drainQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesObserved(entry: var, value: var, answer: var): bool {
|
||||||
|
if (!answer)
|
||||||
|
return false;
|
||||||
|
const expected = root.hyprValue(entry, value);
|
||||||
|
switch (entry.hypr.readAs) {
|
||||||
|
case "bool":
|
||||||
|
return answer.bool === expected;
|
||||||
|
case "int":
|
||||||
|
return answer.int === expected;
|
||||||
|
case "float":
|
||||||
|
// getoption prints six decimal places; compare within that.
|
||||||
|
return Math.abs(answer.float - expected) < 1e-5;
|
||||||
|
case "str":
|
||||||
|
return answer.str === expected;
|
||||||
|
case "css":
|
||||||
|
// Gaps read back as a box, e.g. "10 10 10 10".
|
||||||
|
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportWriteFailure(requested: var, text: string): void {
|
||||||
|
const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label);
|
||||||
|
root.lastError = `Hyprland rejected ${labels.join(" and ")}.`;
|
||||||
|
root.drainQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one entry point the settings UI uses to change any preference.
|
||||||
|
//
|
||||||
|
// A compositor-backed setting must be applied and verified before it is
|
||||||
|
// stored, so that preferences never claim a value Hyprland refused.
|
||||||
|
// Everything else is a direct write. Rows bind a schema key and call this;
|
||||||
|
// they never need to know which kind they are holding.
|
||||||
|
function commitPreference(key: string, value: var): bool {
|
||||||
|
const entry = PreferenceSchema.spec(key);
|
||||||
|
if (!entry) {
|
||||||
|
root.lastError = "That setting is not part of Panama.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (entry.hypr) {
|
||||||
|
const batch = {};
|
||||||
|
batch[key] = value;
|
||||||
|
return root.applyOptions(batch);
|
||||||
|
}
|
||||||
|
return DesktopPreferences.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores shipped defaults across every store Panama owns, not just the
|
||||||
|
// schema. Panama keeps user state in more than one file -- the schema store,
|
||||||
|
// the focus session, and the Home accessory arrangement -- and a reset that
|
||||||
|
// silently skipped one would be worse than no reset at all.
|
||||||
|
//
|
||||||
|
// Compositor-backed values are re-applied afterwards, since resetting the
|
||||||
|
// stored value does not by itself tell Hyprland anything.
|
||||||
|
function restoreDefaults(): void {
|
||||||
|
DesktopPreferences.resetDesktopDefaults();
|
||||||
|
|
||||||
|
// Home accessories keep their own store (panama-home.json), so a reset
|
||||||
|
// that only cleared the schema store would silently leave a customised
|
||||||
|
// favourites list behind while claiming to restore Panama's defaults.
|
||||||
|
//
|
||||||
|
// HomePreferences owns the write-through boundary so the state file is
|
||||||
|
// rewritten before this reset can be considered complete.
|
||||||
|
HomePreferences.resetHomeDefaults();
|
||||||
|
|
||||||
|
resettleTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: resettleTimer
|
||||||
|
interval: 60
|
||||||
|
onTriggered: root.applyPersistedDisplayPolicy()
|
||||||
|
}
|
||||||
|
|
||||||
function setAutoHdr(enabled: bool): void {
|
function setAutoHdr(enabled: bool): void {
|
||||||
autoHdrWrite.requested = enabled;
|
root.applyOptions({ autoHdr: enabled });
|
||||||
autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVrrPolicy(policy: int): void {
|
function setVrrPolicy(policy: int): void {
|
||||||
if (policy !== 0 && policy !== 3) {
|
root.applyOptions({ vrrPolicy: policy });
|
||||||
root.lastError = "Unsupported VRR policy.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
vrrWrite.requested = policy;
|
|
||||||
vrrWrite.exec(["hyprctl", "keyword", "misc:vrr", String(policy)]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDirectScanoutPolicy(policy: int): void {
|
function setDirectScanoutPolicy(policy: int): void {
|
||||||
if (policy !== 0 && policy !== 2) {
|
root.applyOptions({ directScanoutPolicy: policy });
|
||||||
root.lastError = "Unsupported direct-scanout policy.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
directScanoutWrite.requested = policy;
|
|
||||||
directScanoutWrite.exec(["hyprctl", "keyword", "render:direct_scanout", String(policy)]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replays every compositor-owned preference in one batch at shell start, so
|
||||||
|
// a value the user changed in Settings survives a reboot even though the
|
||||||
|
// Lua config only reads the file once, at launch.
|
||||||
function applyPersistedDisplayPolicy(): void {
|
function applyPersistedDisplayPolicy(): void {
|
||||||
root.setAutoHdr(DesktopPreferences.autoHdr);
|
const values = {};
|
||||||
root.setVrrPolicy(DesktopPreferences.vrrPolicy);
|
for (const entry of PreferenceSchema.hyprEntries())
|
||||||
root.setDirectScanoutPolicy(DesktopPreferences.directScanoutPolicy);
|
values[entry.key] = DesktopPreferences.get(entry.key);
|
||||||
|
root.applyOptions(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGnomePanelAllowed(panel: string): bool {
|
function isGnomePanelAllowed(panel: string): bool {
|
||||||
|
// Verified against `gnome-control-center --list` on this system. A name
|
||||||
|
// that panel list does not contain opens nothing and reports an error,
|
||||||
|
// so guessing one here would be a silently dead button.
|
||||||
return [
|
return [
|
||||||
"wifi", "network", "bluetooth", "sound", "power", "printers",
|
"applications", "background", "bluetooth", "color", "display",
|
||||||
"online-accounts", "users", "mouse", "keyboard", "sharing"
|
"keyboard", "mouse", "multitasking", "network", "notifications",
|
||||||
|
"online-accounts", "power", "printers", "privacy", "search",
|
||||||
|
"sharing", "sound", "system", "universal-access", "wacom",
|
||||||
|
"wellbeing", "wifi", "wwan"
|
||||||
].indexOf(panel) >= 0;
|
].indexOf(panel) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +461,8 @@ Singleton {
|
|||||||
"nextcloud": ["nextcloud"],
|
"nextcloud": ["nextcloud"],
|
||||||
"rustdesk": ["rustdesk"],
|
"rustdesk": ["rustdesk"],
|
||||||
"kdeconnect": ["kdeconnect-app"],
|
"kdeconnect": ["kdeconnect-app"],
|
||||||
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"]
|
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"],
|
||||||
|
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
|
||||||
};
|
};
|
||||||
const command = commands[id];
|
const command = commands[id];
|
||||||
if (!command) {
|
if (!command) {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The desktop background.
|
||||||
|
//
|
||||||
|
// hyprpaper owns the actual painting; this owns choosing. Two things are worth
|
||||||
|
// knowing about hyprpaper 0.8:
|
||||||
|
//
|
||||||
|
// * Its IPC is much smaller than the documentation for older versions
|
||||||
|
// suggests. `wallpaper <output>,<path>` and `listactive` work; `preload`,
|
||||||
|
// `listloaded`, `unload`, and `reload` all answer "invalid hyprpaper
|
||||||
|
// request". So there is no preload step -- setting is a single call.
|
||||||
|
// * hyprpaper.conf lives in the Panama repo via the ~/.config/hypr symlink,
|
||||||
|
// so it cannot be rewritten at runtime without dirtying a tracked file.
|
||||||
|
// The chosen wallpaper therefore lives in the shared settings store like
|
||||||
|
// every other preference, and is re-applied when the shell starts.
|
||||||
|
//
|
||||||
|
// The argument is "<output>,<path>", so a path containing a comma would be
|
||||||
|
// parsed as a different request. The schema's pattern rejects those, and the
|
||||||
|
// value is passed as a single argv element rather than through a shell.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Absolute paths of candidate images, newest first.
|
||||||
|
property var available: []
|
||||||
|
property string active: ""
|
||||||
|
property string lastError: ""
|
||||||
|
property bool scanning: false
|
||||||
|
|
||||||
|
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
||||||
|
|
||||||
|
// Directories searched for wallpapers, in order. Screenshots are
|
||||||
|
// deliberately excluded: a folder of 300 screenshots is not a wallpaper
|
||||||
|
// picker, and including it made the grid useless on this machine.
|
||||||
|
readonly property var searchRoots: [
|
||||||
|
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
||||||
|
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
||||||
|
`${Quickshell.env("HOME")}/.local/share/backgrounds`,
|
||||||
|
"/usr/share/backgrounds"
|
||||||
|
]
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: scan
|
||||||
|
|
||||||
|
// -print0 would be safer against odd filenames, but the schema already
|
||||||
|
// rejects paths containing commas or newlines, and this list is only
|
||||||
|
// ever offered as candidates -- the value that gets stored is validated
|
||||||
|
// again on the way in.
|
||||||
|
command: ["bash", "-lc",
|
||||||
|
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
||||||
|
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
||||||
|
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const paths = this.text.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
||||||
|
root.available = paths;
|
||||||
|
root.scanning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: activeQuery
|
||||||
|
command: ["hyprctl", "hyprpaper", "listactive"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
// "DP-2: /path/to/image.jpg", one line per output.
|
||||||
|
const first = this.text.split("\n").find(line => line.indexOf(":") > 0);
|
||||||
|
root.active = first ? first.slice(first.indexOf(":") + 1).trim() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyprpaper requires an explicit output name: the "<empty>,<path>" form that
|
||||||
|
// older versions accepted as "all outputs" is silently ignored by 0.8, so a
|
||||||
|
// wallpaper set that way appears to succeed and never changes. Outputs are
|
||||||
|
// therefore walked one at a time.
|
||||||
|
Process {
|
||||||
|
id: apply
|
||||||
|
|
||||||
|
property string requested: ""
|
||||||
|
property var remaining: []
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.lastError = "hyprpaper could not load that image.";
|
||||||
|
apply.remaining = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (apply.remaining.length > 0) {
|
||||||
|
const next = apply.remaining[0];
|
||||||
|
apply.remaining = apply.remaining.slice(1);
|
||||||
|
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${next},${apply.requested}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
DesktopPreferences.set("wallpaperPath", apply.requested);
|
||||||
|
root.refreshActive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.rescan();
|
||||||
|
root.refreshActive();
|
||||||
|
restore.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyprpaper is started by the compositor's autostart, so it may not be
|
||||||
|
// listening yet when the shell comes up. Re-applying the stored choice
|
||||||
|
// after a short delay makes the wallpaper survive a reboot without needing
|
||||||
|
// hyprpaper.conf to know about it.
|
||||||
|
Timer {
|
||||||
|
id: restore
|
||||||
|
interval: 1500
|
||||||
|
onTriggered: {
|
||||||
|
const stored = root.configured;
|
||||||
|
if (stored !== "" && stored !== root.active)
|
||||||
|
root.set(stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rescan(): void {
|
||||||
|
if (scan.running)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
scan.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshActive(): void {
|
||||||
|
if (!activeQuery.running)
|
||||||
|
activeQuery.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies to every connected output. Returns false when the path is not one
|
||||||
|
// the schema will accept, so a caller can report the refusal.
|
||||||
|
function set(path: string): bool {
|
||||||
|
if (PreferenceSchema.coerce("wallpaperPath", path) === undefined) {
|
||||||
|
root.lastError = "That file path cannot be used as a wallpaper.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (apply.running)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
apply.requested = path;
|
||||||
|
// "" clears the preference without touching what is on screen.
|
||||||
|
if (path === "") {
|
||||||
|
DesktopPreferences.set("wallpaperPath", "");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||||
|
if (outputs.length === 0) {
|
||||||
|
root.lastError = "No display to set a wallpaper on.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply.remaining = outputs.slice(1);
|
||||||
|
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${path}`]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The display name for a path: the file's own name, without extension,
|
||||||
|
// with separators turned into spaces.
|
||||||
|
function titleFor(path: string): string {
|
||||||
|
const file = String(path).split("/").pop();
|
||||||
|
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Isolated behavioral harness for SettingsBackup's live restore handoff.
|
||||||
|
// Every external consumer is replaced before restore output is exercised, so
|
||||||
|
// this file never writes the real compositor, wallpaper, keymap, or shell.
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var calls: []
|
||||||
|
property bool homeInitialized: false
|
||||||
|
property var homeFavorites: []
|
||||||
|
|
||||||
|
function record(name: string): void {
|
||||||
|
const next = root.calls.slice();
|
||||||
|
next.push(name);
|
||||||
|
root.calls = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
SettingsBackup.readHomeState = function() {
|
||||||
|
return {
|
||||||
|
initialized: root.homeInitialized,
|
||||||
|
favorites: root.homeFavorites
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SettingsBackup.resetHome = function() {
|
||||||
|
root.record("home.reset");
|
||||||
|
root.homeInitialized = false;
|
||||||
|
root.homeFavorites = [];
|
||||||
|
};
|
||||||
|
SettingsBackup.initializeHome = function(ids) {
|
||||||
|
root.record("home.initialize:" + ids.join(","));
|
||||||
|
root.homeInitialized = true;
|
||||||
|
root.homeFavorites = ids.map(id => ({ id: id, alias: "" }));
|
||||||
|
};
|
||||||
|
SettingsBackup.aliasHome = function(id, alias) {
|
||||||
|
root.record("home.alias:" + id + "=" + alias);
|
||||||
|
root.homeFavorites = root.homeFavorites.map(favorite =>
|
||||||
|
favorite.id === id ? { id: id, alias: alias } : favorite);
|
||||||
|
};
|
||||||
|
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
|
||||||
|
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
|
||||||
|
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
||||||
|
SettingsBackup.keybindsReloading = function() { return false; };
|
||||||
|
SettingsBackup.systemBusy = function() { return false; };
|
||||||
|
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
|
||||||
|
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
|
||||||
|
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "settings-backup-behavior"
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
root.calls = [];
|
||||||
|
root.homeInitialized = false;
|
||||||
|
root.homeFavorites = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(output: string): bool {
|
||||||
|
return SettingsBackup.handleRestoreOutput(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
calls: root.calls,
|
||||||
|
initialized: root.homeInitialized,
|
||||||
|
favorites: root.homeFavorites
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,32 +9,32 @@ ShellRoot {
|
|||||||
target: "settings-pref-test"
|
target: "settings-pref-test"
|
||||||
|
|
||||||
function applyFixture(): void {
|
function applyFixture(): void {
|
||||||
DesktopPreferences.use24Hour = true;
|
DesktopPreferences.set("use24Hour", true);
|
||||||
DesktopPreferences.showSeconds = false;
|
DesktopPreferences.set("showSeconds", false);
|
||||||
DesktopPreferences.dockAutohide = false;
|
DesktopPreferences.set("dockAutohide", false);
|
||||||
DesktopPreferences.focusDurationMinutes = 70;
|
DesktopPreferences.set("focusDurationMinutes", 70);
|
||||||
DesktopPreferences.autoHdr = false;
|
DesktopPreferences.set("autoHdr", false);
|
||||||
DesktopPreferences.vrrPolicy = 0;
|
DesktopPreferences.set("vrrPolicy", 0);
|
||||||
DesktopPreferences.directScanoutPolicy = 0;
|
DesktopPreferences.set("directScanoutPolicy", 0);
|
||||||
DesktopPreferences.nightLightEnabled = true;
|
DesktopPreferences.set("nightLightEnabled", true);
|
||||||
DesktopPreferences.nightLightAutomatic = true;
|
DesktopPreferences.set("nightLightAutomatic", true);
|
||||||
DesktopPreferences.nightLightTemperature = 4100;
|
DesktopPreferences.set("nightLightTemperature", 4100);
|
||||||
DesktopPreferences.lastPage = "desktop";
|
DesktopPreferences.set("lastPage", "desktop");
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
use24Hour: DesktopPreferences.use24Hour,
|
use24Hour: DesktopPreferences.get("use24Hour"),
|
||||||
showSeconds: DesktopPreferences.showSeconds,
|
showSeconds: DesktopPreferences.get("showSeconds"),
|
||||||
dockAutohide: DesktopPreferences.dockAutohide,
|
dockAutohide: DesktopPreferences.get("dockAutohide"),
|
||||||
focusDurationMinutes: DesktopPreferences.focusDurationMinutes,
|
focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes"),
|
||||||
autoHdr: DesktopPreferences.autoHdr,
|
autoHdr: DesktopPreferences.get("autoHdr"),
|
||||||
vrrPolicy: DesktopPreferences.vrrPolicy,
|
vrrPolicy: DesktopPreferences.get("vrrPolicy"),
|
||||||
directScanoutPolicy: DesktopPreferences.directScanoutPolicy,
|
directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy"),
|
||||||
nightLightEnabled: DesktopPreferences.nightLightEnabled,
|
nightLightEnabled: DesktopPreferences.get("nightLightEnabled"),
|
||||||
nightLightAutomatic: DesktopPreferences.nightLightAutomatic,
|
nightLightAutomatic: DesktopPreferences.get("nightLightAutomatic"),
|
||||||
nightLightTemperature: DesktopPreferences.nightLightTemperature,
|
nightLightTemperature: DesktopPreferences.get("nightLightTemperature"),
|
||||||
lastPage: DesktopPreferences.lastPage,
|
lastPage: DesktopPreferences.get("lastPage"),
|
||||||
stateDir: Quickshell.stateDir
|
stateDir: Quickshell.stateDir
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "settings-search-test"
|
||||||
|
|
||||||
|
function find(query: string): string {
|
||||||
|
const hits = SettingsSearch.search(query);
|
||||||
|
return JSON.stringify({
|
||||||
|
count: hits.length,
|
||||||
|
top: hits.length > 0 ? hits[0].label : "",
|
||||||
|
topPage: hits.length > 0 ? hits[0].page : "",
|
||||||
|
labels: hits.slice(0, 6).map(hit => hit.label)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,49 @@ ShellRoot {
|
|||||||
|
|
||||||
function refresh(): void { SystemSettings.refresh(); }
|
function refresh(): void { SystemSettings.refresh(); }
|
||||||
|
|
||||||
|
// One batch, matching how the shell applies persisted policy. Three
|
||||||
|
// separate setters would be refused as overlapping writes, since each
|
||||||
|
// one is only complete after its value has been read back.
|
||||||
function apply(autoHdr: bool, vrr: int, directScanout: int): void {
|
function apply(autoHdr: bool, vrr: int, directScanout: int): void {
|
||||||
SystemSettings.setAutoHdr(autoHdr);
|
SystemSettings.applyOptions({
|
||||||
SystemSettings.setVrrPolicy(vrr);
|
autoHdr: autoHdr,
|
||||||
SystemSettings.setDirectScanoutPolicy(directScanout);
|
vrrPolicy: vrr,
|
||||||
|
directScanoutPolicy: directScanout
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Applies an arbitrary batch of schema keys, so the contract can cover
|
||||||
|
// every getoption answer shape -- int, bool, float, str, and the css
|
||||||
|
// box that gaps read back as -- not just the display policies.
|
||||||
|
function applyJson(payload: string): bool {
|
||||||
|
return SystemSettings.applyOptions(JSON.parse(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The routing entry point the settings rows use: a compositor-backed
|
||||||
|
// key must be applied and verified before it is stored, a local one is
|
||||||
|
// written directly. The contract checks both halves.
|
||||||
|
function commit(key: string, payload: string): bool {
|
||||||
|
return SystemSettings.commitPreference(key, JSON.parse(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stored(key: string): string {
|
||||||
|
return JSON.stringify(DesktopPreferences.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedHome(): void {
|
||||||
|
HomePreferences.favorites = [{ id: "light.contract_probe", alias: "Probe" }];
|
||||||
|
HomePreferences.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function homeState(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
count: HomePreferences.favorites.length,
|
||||||
|
initialized: HomePreferences.initialized
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDefaults(): void { SystemSettings.restoreDefaults(); }
|
||||||
|
|
||||||
function panelAllowed(panel: string): bool {
|
function panelAllowed(panel: string): bool {
|
||||||
return SystemSettings.isGnomePanelAllowed(panel);
|
return SystemSettings.isGnomePanelAllowed(panel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,14 +78,14 @@ ShellRoot {
|
|||||||
// overview or quick settings is instant rather than paying a load.
|
// overview or quick settings is instant rather than paying a load.
|
||||||
Overview {}
|
Overview {}
|
||||||
// QuickSettings is the window; QuickSettingsPanel is its content.
|
// QuickSettings is the window; QuickSettingsPanel is its content.
|
||||||
QuickSettings {}
|
QuickSettings { id: quickSettings }
|
||||||
DateMenu { id: dateMenu }
|
DateMenu { id: dateMenu }
|
||||||
ClipboardPanel {}
|
ClipboardPanel {}
|
||||||
CaptureOverlay {}
|
CaptureOverlay {}
|
||||||
IntelligenceResult {}
|
IntelligenceResult {}
|
||||||
ActivityPanel {}
|
ActivityPanel {}
|
||||||
PowerMenu {}
|
PowerMenu {}
|
||||||
SettingsWindow {}
|
SettingsWindow { id: settingsWindow }
|
||||||
|
|
||||||
// Toasts are their own always-on layer; they must be able to appear
|
// Toasts are their own always-on layer; they must be able to appear
|
||||||
// without any overlay being open.
|
// without any overlay being open.
|
||||||
@@ -221,16 +221,83 @@ ShellRoot {
|
|||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "quicksettings"
|
target: "quicksettings"
|
||||||
function toggle(): void { ShellState.toggle("quicksettings"); }
|
function toggle(): void { ShellState.toggle("quicksettings"); }
|
||||||
|
function open(): void { ShellState.open("quicksettings"); }
|
||||||
|
function close(): void { ShellState.close(); }
|
||||||
|
function section(name: string): void { quickSettings.expand(name); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
open: ShellState.quickSettingsOpen,
|
||||||
|
expandedSection: quickSettings.expandedSection
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "kdeconnect"
|
||||||
|
function fixture(name: string): void { KdeConnect.applyFixture(name); }
|
||||||
|
function reset(): void { KdeConnect.clearFixture(); }
|
||||||
|
function refresh(): void { KdeConnect.refresh(); }
|
||||||
|
function cancel(): void { KdeConnect.cancelTransfer(); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
fixture: KdeConnect.fixtureMode,
|
||||||
|
available: KdeConnect.available,
|
||||||
|
reachable: KdeConnect.phoneReachable,
|
||||||
|
pairedCount: KdeConnect.pairedCount,
|
||||||
|
actionCount: KdeConnect.phoneActions.length,
|
||||||
|
transferActive: KdeConnect.transferActive,
|
||||||
|
transferFileName: KdeConnect.transferFileName,
|
||||||
|
ongoingCount: Ongoing.activities.filter(item => item.kind === "phone-transfer").length,
|
||||||
|
lastError: KdeConnect.lastError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "home-assistant"
|
||||||
|
function fixture(name: string): void { HomeAssistant.applyFixture(name); }
|
||||||
|
function reset(): void { HomeAssistant.clearFixture(); }
|
||||||
|
function refresh(): void { HomeAssistant.refresh(); }
|
||||||
|
function brightness(id: string, percent: int): void { HomeAssistant.setBrightness(id, percent); }
|
||||||
|
function toggle(id: string): void { HomeAssistant.toggleEntity(id); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
fixture: HomeAssistant.fixtureMode,
|
||||||
|
phase: HomeAssistant.phase,
|
||||||
|
discoveredCount: HomeAssistant.discoveredCount,
|
||||||
|
configuredCount: HomeAssistant.configuredCount,
|
||||||
|
visibleCount: HomeAssistant.visibleEntities.length,
|
||||||
|
selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id),
|
||||||
|
entities: HomeAssistant.selectedEntities,
|
||||||
|
stale: HomeAssistant.stale,
|
||||||
|
busy: HomeAssistant.busyEntityIds.length > 0,
|
||||||
|
busyEntityIds: HomeAssistant.busyEntityIds,
|
||||||
|
pendingBrightness: HomeAssistant.pendingBrightness,
|
||||||
|
entityErrors: HomeAssistant.entityErrors,
|
||||||
|
actionProcessRunning: HomeAssistant.actionProcessRunning,
|
||||||
|
actionStreamFinished: HomeAssistant.actionStreamFinished,
|
||||||
|
fixtureTransitionDraining: HomeAssistant.fixtureTransitionDraining,
|
||||||
|
queuedActionCount: HomeAssistant.actionQueue.length,
|
||||||
|
actionActive: HomeAssistant.activeAction !== null,
|
||||||
|
lastError: HomeAssistant.lastError
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "settings"
|
target: "settings"
|
||||||
function open(): void { ShellState.openSettings(DesktopPreferences.lastPage || "home"); }
|
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
|
||||||
function toggle(): void { ShellState.toggleSettings(); }
|
function toggle(): void { ShellState.toggleSettings(); }
|
||||||
function close(): void { ShellState.closeSettings(); }
|
function close(): void { ShellState.closeSettings(); }
|
||||||
function page(name: string): void { ShellState.openSettings(name); }
|
function page(name: string): void { ShellState.openSettings(name); }
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify({ open: ShellState.settingsOpen, page: ShellState.settingsPage });
|
return JSON.stringify({
|
||||||
|
open: ShellState.settingsOpen,
|
||||||
|
page: ShellState.settingsPage,
|
||||||
|
discoveredCount: HomeAssistant.discoveredCount,
|
||||||
|
selectedCount: HomeAssistant.configuredCount,
|
||||||
|
homePhone: settingsWindow.homePhoneDiagnostics
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +312,7 @@ ShellRoot {
|
|||||||
autoHdr: SystemSettings.autoHdr,
|
autoHdr: SystemSettings.autoHdr,
|
||||||
vrrPolicy: SystemSettings.vrrPolicy,
|
vrrPolicy: SystemSettings.vrrPolicy,
|
||||||
directScanoutPolicy: SystemSettings.directScanoutPolicy,
|
directScanoutPolicy: SystemSettings.directScanoutPolicy,
|
||||||
|
bluebubblesAvailable: SystemSettings.bluebubblesAvailable,
|
||||||
busy: SystemSettings.busy,
|
busy: SystemSettings.busy,
|
||||||
lastError: SystemSettings.lastError
|
lastError: SystemSettings.lastError
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,935 @@
|
|||||||
|
# Panama Home Accessories Customization 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:** Build the approved Accessory Shelf, a durable Home & Phone settings page for selecting, ordering, and naming Home Assistant lights, brightness controls that commit on release, and an independent BlueBubbles Messages action.
|
||||||
|
|
||||||
|
**Architecture:** The Python helper remains the credential-bearing Home Assistant boundary and exposes a complete normalized light catalog plus allow-checked actions. A new `HomePreferences` singleton atomically persists only selected IDs, order, aliases, and initialization state; `HomeAssistant.qml` combines that state with live catalog data and owns per-entity action queues/errors. Settings and Control Center render the same service model, while `SystemSettings` owns BlueBubbles detection and its fixed argument-vector launch.
|
||||||
|
|
||||||
|
**Tech Stack:** Quickshell 0.3 QML, QtQuick, Quickshell.Io `FileView`/`JsonAdapter`, Python 3 standard library, Home Assistant REST API, Flatpak CLI, Bash contract tests, `unittest`, `jq`, Hyprland IPC
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-17-home-accessories-customization-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Preserve the approved A · Accessory Shelf visual direction: a quiet two-column grid, two rows at rest, Prism/Tokyo Night Moon styling, and always-visible amber dimmers.
|
||||||
|
- The resting shelf contains the first four selected lights; the expanded shelf contains every selected light in preference order.
|
||||||
|
- Slider movement is preview-only; release sends exactly one request. Percent 1–100 calls `light.turn_on` with `brightness_pct`, while zero calls `light.turn_off`.
|
||||||
|
- Power actions and successful brightness actions remain globally quiet. A failure is shown only on the affected light and restores its confirmed value.
|
||||||
|
- Aliases are Panama-only. Do not rename Home Assistant entities or friendly names.
|
||||||
|
- Persist only `{ initialized, favorites: [{ id, alias }] }` in Quickshell state. Never persist or log the Home Assistant URL, token, catalog response, or response bodies.
|
||||||
|
- Preserve private Panama environment values as the first credential source, followed by the GNOME extension URL and Secret Service token.
|
||||||
|
- Use the legacy selected-light list only for the first successful initialization. An intentionally empty initialized selection must remain empty.
|
||||||
|
- Keep missing selected entities in preference order and present them as unavailable until the user removes them.
|
||||||
|
- Messages launches `flatpak run app.bluebubbles.BlueBubbles` as separate arguments and is never gated by KDE Connect reachability.
|
||||||
|
- Automated verification must not toggle or dim a real light and must not launch BlueBubbles.
|
||||||
|
- Keep existing Control Center, Settings, notifications, Ongoing, calendar, KDE Connect, and shell contracts compatible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `config/dot/quickshell/scripts/panama-home-assistant` — credential-safe catalog, action authorization, brightness parsing, and Home Assistant REST calls.
|
||||||
|
- `tests/quickshell/home_assistant_bridge_test.py` — fake-server unit tests for filtering, normalization, authorization, payloads, and redaction.
|
||||||
|
- `tests/quickshell/home-assistant-helper-contract.sh` — live read-only probe/catalog shape check; it never invokes an action command.
|
||||||
|
- `config/dot/quickshell/config/HomePreferences.qml` — sole durable owner of initialization state and ordered `{id, alias}` favorites.
|
||||||
|
- `config/dot/quickshell/config/qmldir` — registers `HomePreferences` as a singleton.
|
||||||
|
- `config/dot/quickshell/home-preferences-harness.qml` — isolated IPC surface for preference mutation and restart tests.
|
||||||
|
- `tests/quickshell/home-preferences-contract.sh` — first-run, empty-state, alias, reorder, remove, and restart-persistence contract.
|
||||||
|
- `config/dot/quickshell/services/HomeAssistant.qml` — complete catalog, preference resolution, four-item shelf, fixtures, sequential action queue, and per-entity pending/error maps.
|
||||||
|
- `config/dot/quickshell/shell.qml` — typed Home Assistant diagnostics/actions for fixture-only testing and Home & Phone routing.
|
||||||
|
- `tests/quickshell/control-center-services-contract.sh` — service-model and per-light action-state contract using fixtures only.
|
||||||
|
- `config/dot/quickshell/services/SystemSettings.qml` — BlueBubbles installed-state query and fixed allow-listed launch vector.
|
||||||
|
- `config/dot/quickshell/modules/settings/HomePhonePage.qml` — page composition, connection health, search, selected/available sections, save error, and phone continuity.
|
||||||
|
- `config/dot/quickshell/modules/settings/HomeFavoriteCard.qml` — editable alias, source name, first-four badge, remove action, and drag handle.
|
||||||
|
- `config/dot/quickshell/modules/settings/AvailableLightRow.qml` — searchable unselected-light row with Add action.
|
||||||
|
- `config/dot/quickshell/modules/settings/SettingsSidebar.qml` — Home & Phone destination between Network & Devices and Desktop & Dock.
|
||||||
|
- `config/dot/quickshell/modules/settings/SettingsShell.qml` — page loader registration.
|
||||||
|
- `config/dot/quickshell/modules/settings/qmldir` — component registrations.
|
||||||
|
- `config/dot/quickshell/services/ShellState.qml` — `home-phone` settings-page allow-list entry.
|
||||||
|
- `tests/quickshell/settings-pages-contract.sh` — route and single-window behavior.
|
||||||
|
- `tests/quickshell/home-phone-settings-contract.sh` — static and fixture-driven settings-page behavior without writing the user's real preferences.
|
||||||
|
- `config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml` — local preview and one-shot commit interaction.
|
||||||
|
- `config/dot/quickshell/modules/quicksettings/HomeTile.qml` — polished power, state, percent, dimmer, busy, and inline-error presentation.
|
||||||
|
- `config/dot/quickshell/modules/quicksettings/HomeControls.qml` — resting/expanded shelf, setup state, stale state, and Manage in Settings row.
|
||||||
|
- `config/dot/quickshell/modules/quicksettings/PhoneControls.qml` — four equal actions and independent Messages enablement.
|
||||||
|
- `tests/quickshell/control-center-contract.sh` — mapped panel, exclusive expansion, component presence, and approved shelf structure.
|
||||||
|
- `tests/quickshell/phone-messages-contract.sh` — BlueBubbles detection/allow-list/independent-enable contract; it never invokes the action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Complete Home Assistant Catalog and Brightness Boundary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/dot/quickshell/scripts/panama-home-assistant:44-434`
|
||||||
|
- Modify: `tests/quickshell/home_assistant_bridge_test.py:25-182`
|
||||||
|
- Modify: `tests/quickshell/home-assistant-helper-contract.sh:1-29`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Config(base_url: str, token: str, entity_ids: tuple[str, ...])`, `request_json(config, method, path, payload)` and current URL/token/legacy resolution.
|
||||||
|
- Produces: `normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]`, `collect_catalog(config: Config) -> dict[str, object]`, `discovered_light_ids(config: Config) -> set[str]`, `toggle(config: Config, entity_id: str) -> dict[str, object]`, `set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, object]`, and CLI commands `catalog`, `toggle ENTITY_ID`, `brightness ENTITY_ID PERCENT`.
|
||||||
|
- Produces catalog entities with exactly `id`, `sourceName`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; the top level also contains `legacyEntityIds` for one-time migration.
|
||||||
|
|
||||||
|
- [x] **Step 1: Expand the fake Home Assistant and write failing catalog tests**
|
||||||
|
|
||||||
|
Change the fake `/api/states` response to include an on dimmable light, an off dimmable light, a sensor, a malformed light, and an unavailable light:
|
||||||
|
|
||||||
|
```python
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "light.kitchen",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "Kitchen",
|
||||||
|
"brightness": 128,
|
||||||
|
"supported_color_modes": ["brightness"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "light.hall",
|
||||||
|
"state": "off",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "Hall",
|
||||||
|
"supported_color_modes": ["color_temp"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"entity_id": "sensor.private", "state": "1", "attributes": {"token": "never-return"}},
|
||||||
|
{"entity_id": "light.malformed", "state": "on", "attributes": "invalid"},
|
||||||
|
{
|
||||||
|
"entity_id": "light.corner",
|
||||||
|
"state": "unavailable",
|
||||||
|
"attributes": {"friendly_name": "Corner", "supported_color_modes": ["brightness"]},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add assertions that `collect_catalog(self.config())` returns Kitchen, Hall, and Corner in source order; returns no sensor, malformed entity, or raw attribute key; rounds Kitchen brightness to 50; sets Hall brightness to 0; and reports all three as dimmable.
|
||||||
|
|
||||||
|
- [x] **Step 2: Write failing action authorization and payload tests**
|
||||||
|
|
||||||
|
Teach the fake POST handler to accept all three exact service routes and record bodies. Add these tests:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_toggle_authorizes_against_discovered_catalog(self) -> None:
|
||||||
|
result = bridge.toggle(self.config(), "light.corner")
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual(FakeHomeAssistant.requests[-1]["path"], "/api/services/homeassistant/toggle")
|
||||||
|
|
||||||
|
def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "entity-not-discovered"):
|
||||||
|
bridge.toggle(self.config(), "light.office")
|
||||||
|
|
||||||
|
def test_brightness_uses_turn_on_for_positive_percent(self) -> None:
|
||||||
|
bridge.set_brightness(self.config(), "light.kitchen", 62)
|
||||||
|
request = FakeHomeAssistant.requests[-1]
|
||||||
|
self.assertEqual(request["path"], "/api/services/light/turn_on")
|
||||||
|
self.assertEqual(json.loads(request["body"]), {"entity_id": "light.kitchen", "brightness_pct": 62})
|
||||||
|
|
||||||
|
def test_brightness_zero_uses_turn_off(self) -> None:
|
||||||
|
bridge.set_brightness(self.config(), "light.hall", 0)
|
||||||
|
request = FakeHomeAssistant.requests[-1]
|
||||||
|
self.assertEqual(request["path"], "/api/services/light/turn_off")
|
||||||
|
self.assertEqual(json.loads(request["body"]), {"entity_id": "light.hall"})
|
||||||
|
```
|
||||||
|
|
||||||
|
Add table-driven validation for `-1`, `101`, `1.5`, and `bright`, expecting `invalid-brightness` before any POST. Keep the existing authentication-redaction and configuration-precedence tests.
|
||||||
|
|
||||||
|
- [x] **Step 3: Run the focused unit suite and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tests/quickshell/home_assistant_bridge_test.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: failures identify missing `collect_catalog`, live-catalog authorization, and `set_brightness`; the existing tests remain green.
|
||||||
|
|
||||||
|
- [x] **Step 4: Implement normalized catalog output**
|
||||||
|
|
||||||
|
Change `Config.configured` to require only `base_url` and `token`; `entity_ids` becomes migration metadata, not an operational requirement. Replace configured-only normalization with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
entity_id = item.get("entity_id")
|
||||||
|
attributes = item.get("attributes")
|
||||||
|
if not isinstance(entity_id, str) or not entity_id.startswith("light."):
|
||||||
|
continue
|
||||||
|
if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
|
||||||
|
continue
|
||||||
|
state = str(item.get("state", "unavailable"))
|
||||||
|
available = state not in {"unknown", "unavailable"}
|
||||||
|
active = available and state == "on"
|
||||||
|
raw_brightness = attributes.get("brightness")
|
||||||
|
brightness_pct = (
|
||||||
|
round(max(0, min(255, raw_brightness)) * 100 / 255)
|
||||||
|
if active and isinstance(raw_brightness, (int, float)) and not isinstance(raw_brightness, bool)
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
modes = attributes.get("supported_color_modes", [])
|
||||||
|
dimmable = (
|
||||||
|
isinstance(modes, list) and any(mode != "onoff" for mode in modes)
|
||||||
|
) or isinstance(raw_brightness, (int, float))
|
||||||
|
source_name = attributes.get("friendly_name")
|
||||||
|
result.append({
|
||||||
|
"id": entity_id,
|
||||||
|
"sourceName": source_name.strip() if isinstance(source_name, str) and source_name.strip() else fallback_name(entity_id),
|
||||||
|
"state": state,
|
||||||
|
"available": available,
|
||||||
|
"active": active,
|
||||||
|
"dimmable": dimmable,
|
||||||
|
"brightnessPct": brightness_pct,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
`collect_catalog()` must return `legacyEntityIds: list(config.entity_ids)` on both success and safe failures, while retaining the current `ok`, `configured`, `generatedAt`, `entities`, and redacted `error` envelope. Keep `snapshot` as a compatibility alias for this release, but make QML and live shape tests call `catalog`.
|
||||||
|
|
||||||
|
- [x] **Step 5: Implement discovered-light authorization and brightness commands**
|
||||||
|
|
||||||
|
Fetch `/api/states` immediately before every action, derive a set only from `normalize_catalog()`, and reject anything absent with `ValueError("entity-not-discovered")`. Validate the percentage as an ASCII integer string at the CLI boundary and as an integer in `set_brightness()`; reject booleans and values outside 0–100 with `ValueError("invalid-brightness")`.
|
||||||
|
|
||||||
|
Change `request_json()`'s payload annotation from `dict[str, str] | None` to `Mapping[str, object] | None` so the integer `brightness_pct` is represented honestly. Parse the CLI value with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def parse_brightness(value: str) -> int:
|
||||||
|
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
percent = int(value)
|
||||||
|
if percent > 100:
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
return percent
|
||||||
|
```
|
||||||
|
|
||||||
|
Use exact payloads:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, object]:
|
||||||
|
if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
ensure_discovered(config, entity_id)
|
||||||
|
if percent == 0:
|
||||||
|
path = "/api/services/light/turn_off"
|
||||||
|
payload = {"entity_id": entity_id}
|
||||||
|
else:
|
||||||
|
path = "/api/services/light/turn_on"
|
||||||
|
payload = {"entity_id": entity_id, "brightness_pct": percent}
|
||||||
|
request_json(config, "POST", path, payload)
|
||||||
|
return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not print the discovery response, request body, URL, or token on action failure.
|
||||||
|
|
||||||
|
- [x] **Step 6: Make unit and live read-only contracts GREEN**
|
||||||
|
|
||||||
|
Update `home-assistant-helper-contract.sh` to call `catalog`, assert the exact seven entity keys, assert `legacyEntityIds` is an array, and report only the count:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
catalog="$($helper catalog)"
|
||||||
|
jq -e '
|
||||||
|
.ok == true and .configured == true and .error == "" and
|
||||||
|
(.entities | type == "array" and length > 0) and
|
||||||
|
([.entities[] | (keys | sort) == (["active", "available", "brightnessPct", "dimmable", "id", "sourceName", "state"] | sort)] | all) and
|
||||||
|
([.entities[] | (.id | startswith("light.")) and (.brightnessPct >= 0 and .brightnessPct <= 100)] | all) and
|
||||||
|
(.legacyEntityIds | type == "array")
|
||||||
|
' <<<"$catalog" >/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tests/quickshell/home_assistant_bridge_test.py -v
|
||||||
|
tests/quickshell/home-assistant-helper-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all unit tests PASS; the live contract prints a redacted light count and performs GET requests only.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit the helper boundary**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/scripts/panama-home-assistant \
|
||||||
|
tests/quickshell/home_assistant_bridge_test.py \
|
||||||
|
tests/quickshell/home-assistant-helper-contract.sh
|
||||||
|
git commit -m "Add Home Assistant light catalog and dimming"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Persist Home Favorites, Aliases, and Order
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `config/dot/quickshell/config/HomePreferences.qml`
|
||||||
|
- Modify: `config/dot/quickshell/config/qmldir:1-4`
|
||||||
|
- Create: `config/dot/quickshell/home-preferences-harness.qml`
|
||||||
|
- Create: `tests/quickshell/home-preferences-contract.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: top-level helper field `legacyEntityIds: string[]` after the first successful catalog.
|
||||||
|
- Produces: `initialized: bool`, `favorites: var`, `saveError: string`, `initialize(legacyIds)`, `isSelected(entityId)`, `aliasFor(entityId, sourceName)`, `add(entityId)`, `remove(entityId)`, `setAlias(entityId, alias)`, `move(entityId, targetIndex)`, `retrySave()`, and state file `Quickshell.stateDir + "/panama-home.json"`.
|
||||||
|
- Produces favorite records with exactly `{ id: string, alias: string }`; every mutator assigns a cloned array so QML change notification and persistence are deterministic.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the isolated persistence harness and failing restart contract**
|
||||||
|
|
||||||
|
Create an IPC harness with these methods:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
IpcHandler {
|
||||||
|
target: "home-pref-test"
|
||||||
|
function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
|
||||||
|
function add(id: string): void { HomePreferences.add(id); }
|
||||||
|
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
|
||||||
|
function move(id: string, index: int): void { HomePreferences.move(id, index); }
|
||||||
|
function remove(id: string): void { HomePreferences.remove(id); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
initialized: HomePreferences.initialized,
|
||||||
|
favorites: HomePreferences.favorites,
|
||||||
|
saveError: HomePreferences.saveError,
|
||||||
|
stateDir: Quickshell.stateDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Bash contract must use a temporary `XDG_STATE_HOME`, initialize `light.kitchen`, `light.hall`, `light.desk`, then alias Kitchen to ` Island `, move Desk to index 0, remove Hall, restart the harness, and assert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then remove both records, restart again, call `initialize` with a different legacy list, and assert the selection stays empty. Assert the JSON file contains only `initialized` and `favorites` keys and no strings matching `token`, `url`, or `api` case-insensitively.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the contract and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/home-preferences-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the singleton and harness do not exist.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement the atomic preference singleton**
|
||||||
|
|
||||||
|
Register `singleton HomePreferences 1.0 HomePreferences.qml`. Build `HomePreferences.qml` around this adapter:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property alias initialized: values.initialized
|
||||||
|
property alias favorites: values.favorites
|
||||||
|
property string saveError: ""
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: preferencesFile
|
||||||
|
path: Quickshell.stateDir + "/panama-home.json"
|
||||||
|
blockLoading: true
|
||||||
|
printErrors: false
|
||||||
|
atomicWrites: true
|
||||||
|
onSaved: root.saveError = ""
|
||||||
|
onSaveFailed: error => root.saveError = "Could not save Home favourites."
|
||||||
|
JsonAdapter {
|
||||||
|
id: values
|
||||||
|
property bool initialized: false
|
||||||
|
property var favorites: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a 180 ms single-shot persistence timer. `initialize()` filters IDs through `^light\.[a-z0-9_]+$`, removes duplicates while preserving order, seeds only when `initialized === false`, then sets `initialized = true`. `setAlias()` trims with `String(value).trim()`. `move()` clamps the target index to `0..length - 1`. `retrySave()` directly invokes `preferencesFile.writeAdapter()`. On save failure, leave the mutated `favorites` array untouched so the user can retry without re-entering edits.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run the restart contract and inspect the private file shape**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/home-preferences-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS for initial migration, trim, reorder, remove, restart persistence, and initialized-empty behavior. The temporary file is valid JSON and contains no credential-like fields.
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit preference ownership**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/config/HomePreferences.qml \
|
||||||
|
config/dot/quickshell/config/qmldir \
|
||||||
|
config/dot/quickshell/home-preferences-harness.qml \
|
||||||
|
tests/quickshell/home-preferences-contract.sh
|
||||||
|
git commit -m "Persist Home accessory preferences"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Compose Catalog and Preferences in the QML Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/dot/quickshell/services/HomeAssistant.qml:1-185`
|
||||||
|
- Modify: `config/dot/quickshell/shell.qml:252-272`
|
||||||
|
- Modify: `tests/quickshell/control-center-services-contract.sh:10-90`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: helper `catalog`, `toggle ENTITY_ID`, and `brightness ENTITY_ID PERCENT`; all `HomePreferences` interfaces from Task 2.
|
||||||
|
- Produces: `catalog: var`, `selectedEntities: var`, `visibleEntities: var`, `discoveredCount: int`, `configuredCount: int`, `busyEntityIds: var`, `pendingBrightness: var`, `entityErrors: var`, `refresh()`, `toggleEntity(id)`, `setBrightness(id, percent)`, `isBusy(id)`, `pendingFor(id)`, `errorFor(id)`, and current phase/stale/open behavior.
|
||||||
|
- Produces selected entity objects with `id`, `sourceName`, `name`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; `name` is the trimmed Panama alias or `sourceName` fallback.
|
||||||
|
|
||||||
|
- [x] **Step 1: Extend the fixture contract for ordering, missing entities, and local action state**
|
||||||
|
|
||||||
|
Change the `ready` fixture assertion to require `discoveredCount == 7`, `configuredCount == 7`, `visibleCount == 4`, and ordered aliases. Add fixture-only IPC methods `brightness(id, percent)` and `toggle(id)`, then assert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qs ipc call home-assistant fixture ready >/dev/null
|
||||||
|
before="$(qs ipc call home-assistant status)"
|
||||||
|
jq -e '.selectedIds[0:4] == ["light.fixture_all", "light.fixture_kitchen", "light.fixture_living", "light.fixture_bedroom"]' <<<"$before"
|
||||||
|
|
||||||
|
qs ipc call home-assistant brightness light.fixture_living 64 >/dev/null
|
||||||
|
jq -e '.entities[] | select(.id == "light.fixture_living") | .active == true and .brightnessPct == 64' \
|
||||||
|
<<<"$(qs ipc call home-assistant status)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `missing-selected` and `action-error` fixtures. `missing-selected` retains one preferred ID absent from catalog as unavailable. `action-error` returns `entityErrors["light.fixture_kitchen"] == "request-failed"` while Hall has no error and the global phase is still ready.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the service contract and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/control-center-services-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the new catalog counts, selected IDs, brightness fixture action, and per-entity errors are absent.
|
||||||
|
|
||||||
|
- [x] **Step 3: Replace configured entities with catalog/preference resolution**
|
||||||
|
|
||||||
|
Import `qs.config`. Change the refresh command to `[helperPath, "catalog"]`. On a successful live catalog:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
root.catalog = Array.isArray(result.entities) ? result.entities : [];
|
||||||
|
HomePreferences.initialize(Array.isArray(result.legacyEntityIds) ? result.legacyEntityIds : []);
|
||||||
|
root.rebuildSelection();
|
||||||
|
root.phase = "ready";
|
||||||
|
root.stale = false;
|
||||||
|
root.lastError = "";
|
||||||
|
```
|
||||||
|
|
||||||
|
`rebuildSelection()` maps the ordered preference records. If an ID exists in catalog, copy normalized fields and add `name`. If it is missing, create:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
{
|
||||||
|
id: favorite.id,
|
||||||
|
sourceName: favorite.id.split(".")[1].replaceAll("_", " "),
|
||||||
|
name: favorite.alias || favorite.id.split(".")[1].replaceAll("_", " "),
|
||||||
|
state: "unavailable",
|
||||||
|
available: false,
|
||||||
|
active: false,
|
||||||
|
dimmable: false,
|
||||||
|
brightnessPct: 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect to `HomePreferences.favoritesChanged` and rebuild immediately. In fixture mode, resolve against fixture-local favorite records instead of mutating or reading the user's durable list.
|
||||||
|
|
||||||
|
- [x] **Step 4: Implement per-entity sequential actions**
|
||||||
|
|
||||||
|
Replace the one global busy ID with a queue and cloned maps:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property var actionQueue: []
|
||||||
|
property var busyEntityIds: []
|
||||||
|
property var pendingBrightness: ({})
|
||||||
|
property var entityErrors: ({})
|
||||||
|
|
||||||
|
function isBusy(entityId: string): bool {
|
||||||
|
return root.busyEntityIds.indexOf(entityId) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBrightness(entityId: string, percent: int): void {
|
||||||
|
if (!Number.isInteger(percent) || percent < 0 || percent > 100)
|
||||||
|
return;
|
||||||
|
root.enqueueAction({ kind: "brightness", entityId, percent });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each accepted action adds only its entity ID to `busyEntityIds`; brightness also stores the requested percentage in `pendingBrightness`. One `Process` runs queue entries in order, allowing unrelated tiles to enqueue without a global disable. Completion removes only that entity's busy/pending fields. Success clears only that entity's error and starts the existing 350 ms catalog refresh. Failure leaves catalog/phase intact, removes the preview so the UI snaps to confirmed brightness, and writes the safe code only to `entityErrors[entityId]`. A new action on an entity clears its previous inline error.
|
||||||
|
|
||||||
|
- [x] **Step 5: Expand fixtures and diagnostics without touching durable preferences**
|
||||||
|
|
||||||
|
Fixture entities must include brightness percentages, dimmable flags, aliases, one off light, and one unavailable light. Add status fields:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
return JSON.stringify({
|
||||||
|
fixture: HomeAssistant.fixtureMode,
|
||||||
|
phase: HomeAssistant.phase,
|
||||||
|
discoveredCount: HomeAssistant.discoveredCount,
|
||||||
|
configuredCount: HomeAssistant.configuredCount,
|
||||||
|
visibleCount: HomeAssistant.visibleEntities.length,
|
||||||
|
selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id),
|
||||||
|
entities: HomeAssistant.selectedEntities,
|
||||||
|
stale: HomeAssistant.stale,
|
||||||
|
busyEntityIds: HomeAssistant.busyEntityIds,
|
||||||
|
pendingBrightness: HomeAssistant.pendingBrightness,
|
||||||
|
entityErrors: HomeAssistant.entityErrors,
|
||||||
|
lastError: HomeAssistant.lastError
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixture actions update only in-memory fixture catalog. `clearFixture()` clears fixture action state and returns to a live `catalog` refresh; it never changes `HomePreferences`.
|
||||||
|
|
||||||
|
- [x] **Step 6: Run service and preference regression contracts**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/home-preferences-contract.sh
|
||||||
|
tests/quickshell/control-center-services-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both PASS; fixture cleanup returns live mode and the durable preference contract remains unchanged.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit service composition**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/services/HomeAssistant.qml \
|
||||||
|
config/dot/quickshell/shell.qml \
|
||||||
|
tests/quickshell/control-center-services-contract.sh
|
||||||
|
git commit -m "Compose Home catalog with accessory preferences"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add BlueBubbles and the Home & Phone Settings Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/dot/quickshell/services/SystemSettings.qml:14-237`
|
||||||
|
- Create: `config/dot/quickshell/modules/settings/HomePhonePage.qml`
|
||||||
|
- Create: `config/dot/quickshell/modules/settings/HomeFavoriteCard.qml`
|
||||||
|
- Create: `config/dot/quickshell/modules/settings/AvailableLightRow.qml`
|
||||||
|
- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml:10-25`
|
||||||
|
- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml:65-130`
|
||||||
|
- Modify: `config/dot/quickshell/modules/settings/qmldir:1-19`
|
||||||
|
- Modify: `config/dot/quickshell/services/ShellState.qml:94-99`
|
||||||
|
- Modify: `config/dot/quickshell/shell.qml:274-299`
|
||||||
|
- Modify: `tests/quickshell/settings-pages-contract.sh:15-28`
|
||||||
|
- Create: `tests/quickshell/home-phone-settings-contract.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `HomeAssistant.catalog`, `selectedEntities`, `discoveredCount`, phase/stale/error, `refresh()`, `open()`; all `HomePreferences` mutators; `SystemSettings.bluebubblesAvailable` and `openApplication("bluebubbles")`.
|
||||||
|
- Produces: Settings route `home-phone`, a searchable `availableLights` projection, selected-card drag reorder, inline preference-save Retry, and `bluebubblesAvailable: bool`.
|
||||||
|
- `HomeFavoriteCard` consumes `favorite`, `sourceName`, `index`, `featured`; emits `aliasCommitted(id, alias)`, `removeRequested(id)`, and `moveRequested(id, targetIndex)`.
|
||||||
|
- `AvailableLightRow` consumes `entity`; emits `addRequested(id)`.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing route, component, and privacy-safe page contracts**
|
||||||
|
|
||||||
|
Add `home-phone` to the route array immediately after `connectivity`. The focused page contract must assert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -Fq 'text: "Home & Phone"' config/dot/quickshell/modules/settings/HomePhonePage.qml
|
||||||
|
rg -Fq 'HomePreferences.setAlias' config/dot/quickshell/modules/settings/HomePhonePage.qml
|
||||||
|
rg -Fq 'HomePreferences.move' config/dot/quickshell/modules/settings/HomePhonePage.qml
|
||||||
|
rg -Fq 'HomePreferences.remove' config/dot/quickshell/modules/settings/HomePhonePage.qml
|
||||||
|
rg -Fq 'HomePreferences.add' config/dot/quickshell/modules/settings/HomePhonePage.qml
|
||||||
|
rg -Fq 'HomePreferences.retrySave' config/dot/quickshell/modules/settings/HomePhonePage.qml
|
||||||
|
! rg -i 'token|bearer|api/states' config/dot/quickshell/modules/settings/HomePhonePage.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
Using the ready fixture, route Settings to `home-phone` and assert one tiled `Panama Settings` client. IPC status must report `page == "home-phone"`, `discoveredCount == 7`, and `selectedCount == 7`. The test may read fixture state but must not call preference mutators.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the settings contracts and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/settings-pages-contract.sh
|
||||||
|
tests/quickshell/home-phone-settings-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `home-phone` falls back to Home and the new components are missing.
|
||||||
|
|
||||||
|
- [x] **Step 3: Add BlueBubbles availability and fixed launch mapping**
|
||||||
|
|
||||||
|
Add a dedicated probe process and expose its mutable result through a read-only public property:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property bool bluebubblesDetected: false
|
||||||
|
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: bluebubblesQuery
|
||||||
|
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
|
||||||
|
onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Start it from `refresh()` when not running. Extend `openApplication()` with exactly:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Expose `bluebubblesAvailable` through the existing `settings-system` IPC status for read-only tests. Do not derive this property from KDE Connect and do not construct a shell command string.
|
||||||
|
|
||||||
|
- [x] **Step 4: Build the page shell and Home Assistant health card**
|
||||||
|
|
||||||
|
Create a `Flickable` page matching existing 34 px horizontal/30 px top insets. Use title `Home & Phone` and subtitle `Choose what appears in Control Center and keep phone continuity close at hand.` The Home Assistant card shows:
|
||||||
|
|
||||||
|
- `Connected · N lights discovered` for ready;
|
||||||
|
- `Last update unavailable · showing saved controls` for degraded;
|
||||||
|
- `Authentication required` for `authentication-required`;
|
||||||
|
- `Home Assistant is not configured` for `not-configured`;
|
||||||
|
- Refresh and Open buttons wired only to `HomeAssistant.refresh()` and `HomeAssistant.open()`.
|
||||||
|
|
||||||
|
- [x] **Step 5: Build selected cards with alias editing and drag reordering**
|
||||||
|
|
||||||
|
Render `HomeAssistant.selectedEntities` in a two-column `GridView`. Each `HomeFavoriteCard` shows alias in a `TextInput`, source name beneath it, a quiet `Control Center` badge for indexes 0–3, a remove button, and a six-dot drag handle.
|
||||||
|
|
||||||
|
The drag handle owns a `DragHandler`; while active the card lifts with a Prism border and `z: 10`. On release, convert the card center into a target grid cell:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
|
||||||
|
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
|
||||||
|
root.moveRequested(favorite.id, Math.min(modelCount - 1, row * 2 + column));
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit alias on editing finished, not on every keystroke. Preserve duplicate aliases. An empty trimmed alias displays the current source name in Control Center.
|
||||||
|
|
||||||
|
- [x] **Step 6: Build searchable Available lights and persistence failure state**
|
||||||
|
|
||||||
|
Define:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
readonly property var availableLights: HomeAssistant.catalog.filter(entity => {
|
||||||
|
if (HomePreferences.isSelected(entity.id))
|
||||||
|
return false;
|
||||||
|
const haystack = (entity.sourceName + " " + entity.id).toLowerCase();
|
||||||
|
return root.lightQuery === "" || haystack.includes(root.lightQuery);
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The Available lights card contains a search field and rows showing source name, entity ID, state, and Add. If no favorites are selected, show `Choose lights below to build your Control Center shelf.` If the filtered list is empty, distinguish `All discovered lights are already selected` from `No lights match that search`.
|
||||||
|
|
||||||
|
When `HomePreferences.saveError` is non-empty, show one inline amber row with the exact safe message and a Retry button calling `retrySave()`. Do not route this error through global `SystemSettings.lastError`.
|
||||||
|
|
||||||
|
- [x] **Step 7: Add Phone continuity and route registration**
|
||||||
|
|
||||||
|
Add a final compact card showing Messages, `Opens BlueBubbles`, installed/unavailable status, and an Open button enabled only by `SystemSettings.bluebubblesAvailable`. Wire the sidebar destination between connectivity and desktop, add the loader component and qmldir registrations, and allow `home-phone` in `ShellState.openSettings()`.
|
||||||
|
|
||||||
|
Extend settings diagnostics with read-only counts:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
{
|
||||||
|
open: ShellState.settingsOpen,
|
||||||
|
page: ShellState.settingsPage,
|
||||||
|
discoveredCount: HomeAssistant.discoveredCount,
|
||||||
|
selectedCount: HomeAssistant.configuredCount
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 8: Run Settings, system, and persistence contracts**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/settings-system-contract.sh
|
||||||
|
tests/quickshell/settings-pages-contract.sh
|
||||||
|
tests/quickshell/home-phone-settings-contract.sh
|
||||||
|
tests/quickshell/home-preferences-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS; Panama Settings remains one normal tiled client, fixture tests leave real Home preferences unchanged, and no BlueBubbles process starts.
|
||||||
|
|
||||||
|
- [x] **Step 9: Commit the management UI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/services/SystemSettings.qml \
|
||||||
|
config/dot/quickshell/modules/settings/HomePhonePage.qml \
|
||||||
|
config/dot/quickshell/modules/settings/HomeFavoriteCard.qml \
|
||||||
|
config/dot/quickshell/modules/settings/AvailableLightRow.qml \
|
||||||
|
config/dot/quickshell/modules/settings/SettingsSidebar.qml \
|
||||||
|
config/dot/quickshell/modules/settings/SettingsShell.qml \
|
||||||
|
config/dot/quickshell/modules/settings/qmldir \
|
||||||
|
config/dot/quickshell/services/ShellState.qml \
|
||||||
|
config/dot/quickshell/shell.qml \
|
||||||
|
tests/quickshell/settings-pages-contract.sh \
|
||||||
|
tests/quickshell/home-phone-settings-contract.sh
|
||||||
|
git commit -m "Add Home and Phone settings"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Build the Accessory Shelf and Release-Commit Dimmers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml`
|
||||||
|
- Modify: `config/dot/quickshell/modules/quicksettings/HomeTile.qml:1-73`
|
||||||
|
- Modify: `config/dot/quickshell/modules/quicksettings/HomeControls.qml:1-280`
|
||||||
|
- Modify: `config/dot/quickshell/modules/quicksettings/qmldir`
|
||||||
|
- Modify: `tests/quickshell/control-center-contract.sh:19-72`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `HomeAssistant.visibleEntities`, `selectedEntities`, `isBusy(id)`, `pendingFor(id)`, `errorFor(id)`, `toggleEntity(id)`, `setBrightness(id, percent)`, phase/stale, refresh/open; `ShellState.openSettings("home-phone")`.
|
||||||
|
- Produces: `HomeBrightnessSlider.value: int`, `enabled: bool`, `previewChanged(int)`, and `committed(int)`; `HomeTile` emits `powerRequested` and `brightnessRequested(int)`.
|
||||||
|
- Produces a two-column resting and expanded shelf with slider hit areas that do not bubble power clicks.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing structure and fixture interaction assertions**
|
||||||
|
|
||||||
|
Require `HomeBrightnessSlider.qml` and its qmldir entry. Static assertions must prove:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -Fq 'columns: 2' config/dot/quickshell/modules/quicksettings/HomeControls.qml
|
||||||
|
rg -Fq 'model: HomeAssistant.visibleEntities' config/dot/quickshell/modules/quicksettings/HomeControls.qml
|
||||||
|
rg -Fq 'model: HomeAssistant.selectedEntities' config/dot/quickshell/modules/quicksettings/HomeControls.qml
|
||||||
|
rg -Fq 'onCommitted: value => root.brightnessRequested(value)' config/dot/quickshell/modules/quicksettings/HomeTile.qml
|
||||||
|
rg -Fq 'ShellState.openSettings("home-phone")' config/dot/quickshell/modules/quicksettings/HomeControls.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the live panel mapping and exclusive expansion assertions. Add ready-fixture status assertions that resting count is four and expanded selected count is seven.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the Control Center contract and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/control-center-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the two-column shelf, slider, and Home & Phone management route are absent.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement a slider with local preview and one commit**
|
||||||
|
|
||||||
|
Create a focused slider instead of changing shared `ValueSlider.qml`. It accepts integer 0–100 and keeps `previewValue` local while pressed. Pointer press/move emits `previewChanged(previewValue)`; pointer release emits `committed(previewValue)` once. Wheel steps by 5 and commits once per wheel event. External value changes update preview only when not pressed.
|
||||||
|
|
||||||
|
Use a 10 px rounded track, amber-to-warm Prism fill, 16 px light knob, and a 16 px effective vertical hit expansion. Expose the hit area only inside the slider component so tile power clicks cannot intercept dimming.
|
||||||
|
|
||||||
|
- [x] **Step 4: Rebuild `HomeTile` as the approved larger accessory control**
|
||||||
|
|
||||||
|
Use a 124 px minimum height, 14 px radius, 13 px insets, active amber glass, and quiet inactive glass. Layout:
|
||||||
|
|
||||||
|
- top row: 28 px bulb power control, state at right;
|
||||||
|
- middle: alias, elided to one line; confirmed/pending percentage below;
|
||||||
|
- bottom: full-width `HomeBrightnessSlider`;
|
||||||
|
- inline error replaces the secondary state line in `Theme.warn` without changing tile height.
|
||||||
|
|
||||||
|
The tile body and bulb call `powerRequested()` only when available and not busy. The slider remains enabled only when available, dimmable, and not busy. While dragging, percentage text uses local preview. On failure the service removes pending state, causing the slider and text to bind back to confirmed `entity.brightnessPct`.
|
||||||
|
|
||||||
|
- [x] **Step 5: Rebuild `HomeControls` resting, expanded, and setup states**
|
||||||
|
|
||||||
|
Resting state uses `Grid { columns: 2; columnSpacing: 8; rowSpacing: 8 }` over `visibleEntities`. Expanded state uses the same two-column tile language over every `selectedEntities` item inside its existing `Section`/scroll boundary. Wire each delegate:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
HomeTile {
|
||||||
|
entity: modelData
|
||||||
|
busy: HomeAssistant.isBusy(modelData.id)
|
||||||
|
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
|
||||||
|
errorCode: HomeAssistant.errorFor(modelData.id)
|
||||||
|
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
|
||||||
|
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
At the expanded list footer add `Manage in Settings`, which closes Control Center and opens `home-phone`. If the initialized selection is empty, replace the shelf with one setup row opening `home-phone`. Preserve loading, authentication, not-configured, stale, Retry, and Open Home Assistant states, but base selected counts on `configuredCount` and discovery copy on `discoveredCount`.
|
||||||
|
|
||||||
|
- [x] **Step 6: Run service and Control Center contracts**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/control-center-services-contract.sh
|
||||||
|
tests/quickshell/control-center-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS. Opening and expanding Home maps one panel, displays the fixture shelf, and does not invoke the real Home Assistant API.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit the accessory shelf**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml \
|
||||||
|
config/dot/quickshell/modules/quicksettings/HomeTile.qml \
|
||||||
|
config/dot/quickshell/modules/quicksettings/HomeControls.qml \
|
||||||
|
config/dot/quickshell/modules/quicksettings/qmldir \
|
||||||
|
tests/quickshell/control-center-contract.sh
|
||||||
|
git commit -m "Build the Home accessory shelf"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Add Independent BlueBubbles Messages Action
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/dot/quickshell/modules/quicksettings/PhoneControls.qml:1-273`
|
||||||
|
- Create: `tests/quickshell/phone-messages-contract.sh`
|
||||||
|
- Modify: `tests/quickshell/control-center-contract.sh:41-72`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `SystemSettings.bluebubblesAvailable`, `SystemSettings.openApplication("bluebubbles")`, and existing KDE Connect capability/reachability/action interfaces.
|
||||||
|
- Produces: four equal action models where `share`, `clipboard`, and `ring` use KDE Connect support/reachability, while `messages` uses only BlueBubbles availability.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing independent-enable contract**
|
||||||
|
|
||||||
|
The static contract must require a Messages model, exact application ID invocation, and an enable expression independent of `KdeConnect.phoneReachable`. It must also assert the fixed command array exists in `SystemSettings.qml`. The live read-only portion calls only `settings-system status` and requires `bluebubblesAvailable == true` on this workstation.
|
||||||
|
|
||||||
|
Add a guard that fails if the script contains `openApplication` in an executed `qs ipc call`; the test must never launch the client.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the contract and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/phone-messages-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because PhoneControls still has three KDE-only actions.
|
||||||
|
|
||||||
|
- [x] **Step 3: Split action capability from action enablement**
|
||||||
|
|
||||||
|
Build four fixed models:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
readonly property var actionModels: [
|
||||||
|
{ id: "share", glyph: "\u{F0142}", label: "Send file", available: KdeConnect.supports("share"), enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive },
|
||||||
|
{ id: "clipboard", glyph: "\u{F014C}", label: "Clipboard", available: KdeConnect.supports("clipboard"), enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive },
|
||||||
|
{ id: "ring", glyph: "\u{F009A}", label: "Ring", available: KdeConnect.supports("ring"), enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive },
|
||||||
|
{ id: "messages", glyph: "\u{F0369}", label: "Messages", available: true, enabled: SystemSettings.bluebubblesAvailable }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Render four equal columns even if a KDE plugin is unavailable; unavailable actions remain visible but disabled so the card does not jump. `invoke("messages")` calls only `SystemSettings.openApplication("bluebubbles")` and closes the Control Center on successful handoff. Keep file dialog, clipboard, and ring paths unchanged.
|
||||||
|
|
||||||
|
- [x] **Step 4: Add missing-client copy to Phone details**
|
||||||
|
|
||||||
|
When BlueBubbles is absent, append one quiet detail row: `BlueBubbles is not installed` with no action. Keep `Actions become available when the iPhone reconnects` scoped to the three KDE actions so it does not imply Messages requires proximity.
|
||||||
|
|
||||||
|
- [x] **Step 5: Run phone and Control Center contracts**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/phone-messages-contract.sh
|
||||||
|
tests/quickshell/control-center-contract.sh
|
||||||
|
tests/quickshell/control-center-services-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS; the offline KDE fixture still exposes an enabled Messages action when BlueBubbles is installed, and no client launches during testing.
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit Messages integration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/modules/quicksettings/PhoneControls.qml \
|
||||||
|
tests/quickshell/phone-messages-contract.sh \
|
||||||
|
tests/quickshell/control-center-contract.sh
|
||||||
|
git commit -m "Add BlueBubbles to Phone controls"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Full Verification, Visual Review, Documentation, and Push
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/dot/hypr/README.md`
|
||||||
|
- Modify: `docs/superpowers/plans/2026-08-17-home-accessories-customization.md` (mark executed checkboxes)
|
||||||
|
- Verify: all files changed in Tasks 1–6
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: completed helper, preferences, service, Settings, Accessory Shelf, and BlueBubbles integration.
|
||||||
|
- Produces: user-facing operating notes, a clean live shell, visual evidence for all required states, a final verification commit, and synchronized `origin/main`.
|
||||||
|
|
||||||
|
- [x] **Step 1: Update durable user documentation**
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
- Control Center shows the first four Home favorites and expands to all selected lights;
|
||||||
|
- `Panama Settings → Home & Phone` manages order and aliases;
|
||||||
|
- slider release sends the brightness action and power toggle restores Home Assistant's own previous level;
|
||||||
|
- Messages opens BlueBubbles independently of KDE Connect;
|
||||||
|
- Home credentials remain in `bash/env` or the legacy GNOME/Secret Service fallback, while favorites live in Quickshell state.
|
||||||
|
|
||||||
|
Do not include entity IDs, friendly names, URLs, tokens, or the contents of the user's preference file.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run focused automated verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tests/quickshell/home_assistant_bridge_test.py -v
|
||||||
|
tests/quickshell/home-assistant-helper-contract.sh
|
||||||
|
tests/quickshell/home-preferences-contract.sh
|
||||||
|
tests/quickshell/control-center-services-contract.sh
|
||||||
|
tests/quickshell/home-phone-settings-contract.sh
|
||||||
|
tests/quickshell/phone-messages-contract.sh
|
||||||
|
tests/quickshell/control-center-contract.sh
|
||||||
|
tests/quickshell/settings-system-contract.sh
|
||||||
|
tests/quickshell/settings-pages-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: every command exits 0. The helper contract performs read-only GETs; fixtures perform no real light action; BlueBubbles remains closed unless it was already open.
|
||||||
|
|
||||||
|
- [x] **Step 3: Run the complete Quickshell regression suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for test in tests/quickshell/*contract.sh; do
|
||||||
|
printf 'Running %s\n' "$test"
|
||||||
|
"$test"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: every contract prints PASS and exits 0. If a test opens a panel, its trap closes it and restores live service mode.
|
||||||
|
|
||||||
|
- [x] **Step 4: Validate the live shell and logs**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hyprctl reload
|
||||||
|
qs ipc call home-assistant reset
|
||||||
|
qs ipc call kdeconnect reset
|
||||||
|
qs log -n -t 300 --no-color | rg -i 'qml|homeassistant|homepreferences|bluebubbles|error|warn' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Hyprland reports success; Quickshell remains running; there are no new QML construction errors, binding loops, uncaught JavaScript exceptions, credential strings, or rapid process respawns. Investigate any warning produced by changed components before proceeding.
|
||||||
|
|
||||||
|
- [x] **Step 5: Capture and inspect approved visual states**
|
||||||
|
|
||||||
|
Create `/tmp/panama-home-verification` and capture full-screen PNGs after each fixture/routing setup:
|
||||||
|
|
||||||
|
1. `home-assistant fixture ready` + resting Control Center;
|
||||||
|
2. ready fixture + expanded Home section;
|
||||||
|
3. `missing-selected` + expanded Home section;
|
||||||
|
4. `action-error` + resting Control Center;
|
||||||
|
5. ready fixture + Panama Settings on `home-phone`;
|
||||||
|
6. offline KDE Connect fixture + Phone section, proving Messages remains available.
|
||||||
|
|
||||||
|
Inspect every PNG at original resolution. Confirm two equal shelf columns, no clipped aliases or percentages, stable tile heights, amber slider contrast, quiet inline errors, correct four-item badge treatment in Settings, readable source names, tight Control Center attachment, and four equal Phone actions. Keep captures under `/tmp`; do not commit private Home Assistant names.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Return fixtures to live state and perform user-driven action checks**
|
||||||
|
|
||||||
|
Reset fixtures and leave the interfaces open for the user. Ask the user to perform these explicit checks because they change external state:
|
||||||
|
|
||||||
|
- drag one real light slider and confirm the light changes only on release;
|
||||||
|
- set it to zero and confirm it turns off;
|
||||||
|
- toggle it normally and confirm Home Assistant restores its prior brightness;
|
||||||
|
- rename/reorder one favorite and confirm Control Center updates and survives a Quickshell restart;
|
||||||
|
- click Messages and confirm BlueBubbles opens.
|
||||||
|
|
||||||
|
Do not perform those actions on the user's behalf.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit documentation and plan completion**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/hypr/README.md \
|
||||||
|
docs/superpowers/plans/2026-08-17-home-accessories-customization.md
|
||||||
|
git commit -m "Document Home accessory customization"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify repository state and push**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short --branch
|
||||||
|
git log --oneline --decorate -8
|
||||||
|
git push origin main
|
||||||
|
git status --short --branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the worktree is clean, the feature commits are visible, push succeeds, and `main` is synchronized with `origin/main`.
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# Panama Cohesion Implementation Plan
|
||||||
|
|
||||||
|
**Goal:** Make Panama one product instead of several good parts. A user changes
|
||||||
|
how their desktop looks and behaves entirely from Panama Settings; the Lua config
|
||||||
|
holds the shipped defaults; one JSON file is the truth both sides read.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
|
||||||
|
|
||||||
|
**Tech Stack:** Quickshell 0.3.0, Qt 6 QML, Hyprland 0.56.2 (Lua config),
|
||||||
|
`hyprctl eval`, Bash contract tests.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
- `hyprctl keyword` is banned. It exits 0 without acting on this build.
|
||||||
|
- Verify every write by reading the value back (`hyprctl getoption`), never by
|
||||||
|
trusting an exit code.
|
||||||
|
- No UI-supplied string is interpolated into `eval`, a shell command, or a config
|
||||||
|
value. Numbers range-checked, choices allow-listed, colours hex-validated.
|
||||||
|
- A missing or malformed `settings.json` degrades to shipped defaults. The Lua
|
||||||
|
read is `pcall`-wrapped so it can never take down the compositor config.
|
||||||
|
- Tokyo Night Moon and Prism stay the only identity; customisation adjusts its
|
||||||
|
parameters, it does not replace it.
|
||||||
|
- Motion stays event-driven at every setting. No idle repaint.
|
||||||
|
- Do not restart the running Quickshell process during development; it hot-reloads.
|
||||||
|
- Keep unrelated in-flight Panama work untouched (see the
|
||||||
|
`feat/home-accessories-customization` worktree).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 0 — Stop the lying (ship first, standalone)
|
||||||
|
|
||||||
|
The display-policy toggles report success while doing nothing. This is a
|
||||||
|
correctness bug in shipped behaviour and does not depend on any of the
|
||||||
|
architecture below.
|
||||||
|
|
||||||
|
**Files:** Modify `config/dot/quickshell/services/SystemSettings.qml`;
|
||||||
|
Test `tests/quickshell/settings-hyprland-write-contract.sh`
|
||||||
|
|
||||||
|
- [x] Write a contract that sets a display policy through `SystemSettings`, then
|
||||||
|
asserts via `hyprctl getoption` that the compositor value actually changed —
|
||||||
|
and that a rejected write leaves `lastError` non-empty.
|
||||||
|
- [x] Run it; confirm it fails against the current `hyprctl keyword` implementation.
|
||||||
|
- [x] Add a single `applyOptions(values)` boundary that serialises validated values
|
||||||
|
into `hl.config{}` and runs them through `hyprctl eval`.
|
||||||
|
- [x] Route `setAutoHdr`, `setVrrPolicy`, and `setDirectScanoutPolicy` through it.
|
||||||
|
- [x] Treat "wrote it back and read it back equal" as the only success condition;
|
||||||
|
persist to preferences only on verified success.
|
||||||
|
- [x] Run the contract to green, and confirm live that HDR/VRR/scanout change.
|
||||||
|
|
||||||
|
**Exit criteria:** the three toggles do what they claim, and a failed write says so.
|
||||||
|
|
||||||
|
**Landed.** `hyprctl eval` also exits 0 on syntax and runtime errors — it reports
|
||||||
|
them as an `error:` line on stdout — so exit status is useless for both commands.
|
||||||
|
`applyOptions` therefore parses stdout for the error line *and* reads every
|
||||||
|
written option back with `hyprctl -j --batch getoption`, committing to
|
||||||
|
preferences only for options that read back equal. A `writableOptions` registry
|
||||||
|
holds the group/key/option path and allow-list per option, so the UI never names
|
||||||
|
an option or supplies an unchecked value. Policy is applied as one batch, so the
|
||||||
|
shell cannot come up half-configured.
|
||||||
|
|
||||||
|
New: `tests/quickshell/settings-hyprland-write-contract.sh` — flips each policy
|
||||||
|
to a value it does not hold and reads it back, so a no-op write cannot pass.
|
||||||
|
The pre-existing `settings-system-contract.sh` re-applied the values already in
|
||||||
|
place, which is why it passed throughout the outage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 1 — One schema, one store
|
||||||
|
|
||||||
|
**Files:** Create `config/dot/quickshell/config/PreferenceSchema.qml`;
|
||||||
|
Modify `config/dot/quickshell/config/DesktopPreferences.qml`,
|
||||||
|
`config/dot/quickshell/config/Settings.qml`;
|
||||||
|
Test `tests/quickshell/preference-schema-contract.sh`
|
||||||
|
|
||||||
|
Schema entry shape:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
{ key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
|
||||||
|
group: "dock", label: "Hide delay",
|
||||||
|
detail: "Prevents flicker when crossing icons" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] Write a contract asserting: every schema key round-trips through disk;
|
||||||
|
an out-of-range value is clamped rather than stored; an unknown key in the
|
||||||
|
file is preserved rather than dropped; and `reset()` returns *every* key to
|
||||||
|
its schema default with no hand-maintained list.
|
||||||
|
- [x] Run it; confirm it fails.
|
||||||
|
- [x] Author `PreferenceSchema.qml` covering the existing 16 user-facing keys.
|
||||||
|
- [x] Rewrite `DesktopPreferences` to derive persistence, change notification,
|
||||||
|
validation, and reset from the schema — deleting the four-places-per-key
|
||||||
|
boilerplate and `resetDesktopDefaults()`'s hand-written body.
|
||||||
|
- [x] Move the store to `~/.config/panama/settings.json`, migrating the existing
|
||||||
|
file from `Quickshell.stateDir` on first run if present.
|
||||||
|
- [x] Keep `Settings.qml` as the stable public read surface; existing consumers
|
||||||
|
must not change.
|
||||||
|
- [x] Run the new contract plus `settings-preferences-contract.sh` and
|
||||||
|
`settings-pages-contract.sh` to green.
|
||||||
|
|
||||||
|
**Exit criteria:** adding a setting is one schema line; reset is complete by
|
||||||
|
construction; the store lives at a stable, user-visible path.
|
||||||
|
|
||||||
|
**Landed.** Reads go through `DesktopPreferences.get(key)` and writes through
|
||||||
|
`set(key, value)`; a `revision` counter gives function-call bindings something to
|
||||||
|
invalidate, which a bare call would not have. `Settings.qml` stays the typed
|
||||||
|
public surface — every consumer outside it was already reading through it.
|
||||||
|
`set()` returns false on an unknown key or an unrepresentable value, so a
|
||||||
|
rejection is observable instead of inferred.
|
||||||
|
|
||||||
|
Unknown keys on disk are carried through writes untouched, so rolling back to an
|
||||||
|
older Panama does not discard a newer version's settings. A corrupt file falls
|
||||||
|
back to shipped defaults rather than costing the user a working desktop. Both are
|
||||||
|
pinned by contract.
|
||||||
|
|
||||||
|
Migration reads the old `Quickshell.stateDir` file only when the new one is
|
||||||
|
absent, and never deletes the original. Verified live: the running shell adopted
|
||||||
|
all 17 values into `~/.config/panama/settings.json` with the legacy files intact.
|
||||||
|
|
||||||
|
New: `config/PreferenceSchema.qml`, `preference-schema-harness.qml`,
|
||||||
|
`tests/quickshell/preference-schema-contract.sh`. Full suite green — 6 settings
|
||||||
|
contracts, 22 other Quickshell contracts, 3 Python bridge tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 2 — Hyprland reads the same file
|
||||||
|
|
||||||
|
**Files:** Create `config/dot/hypr/prefs.lua`;
|
||||||
|
Modify `config/dot/hypr/hyprland.lua`, `looks.lua`, `input.lua`, `monitors.lua`;
|
||||||
|
Test `tests/hypr/prefs-fallback-contract.sh`
|
||||||
|
|
||||||
|
- [x] Write a contract that verifies `Hyprland --verify-config` passes with the
|
||||||
|
file absent, empty, truncated mid-object, and containing wrong-typed values —
|
||||||
|
and that each case yields the shipped default.
|
||||||
|
- [x] Run it; confirm it fails (no `prefs.lua` yet).
|
||||||
|
- [x] Implement a dependency-free JSON reader exposing `prefs.get(key, fallback)`,
|
||||||
|
`pcall`-wrapped, reading `$XDG_CONFIG_HOME/panama/settings.json`.
|
||||||
|
- [x] Require it first in `hyprland.lua`, before `looks`.
|
||||||
|
- [x] Convert the appearance and behaviour literals in `looks.lua` and `input.lua`
|
||||||
|
to `prefs.get("<key>", <current literal>)`, keeping every current value as
|
||||||
|
the fallback so shipped behaviour is byte-identical.
|
||||||
|
- [x] Extend `PreferenceSchema.qml` with the Hyprland-owned keys, each carrying
|
||||||
|
the `hl.config` path it maps to.
|
||||||
|
- [x] Have `SystemSettings` derive its `eval` payload from that mapping, so a new
|
||||||
|
Hyprland setting needs no new writer code.
|
||||||
|
- [x] Run the contract, `Hyprland --verify-config`, and a live reload to green.
|
||||||
|
|
||||||
|
**Exit criteria:** one file is the truth; the Lua is the default; Settings is the
|
||||||
|
editor; changes apply live *and* survive a reboot.
|
||||||
|
|
||||||
|
**Landed.** The compositor-adjustable surface went from 3 keys to 22.
|
||||||
|
`SystemSettings` no longer names any option: it walks `PreferenceSchema.hyprEntries()`,
|
||||||
|
builds one nested `hl.config{}` payload from each entry's table path, and verifies
|
||||||
|
against the entry's `option` path. Adding a live-adjustable Hyprland setting is now
|
||||||
|
a schema entry plus a `prefs.get` call, with no new writer code.
|
||||||
|
|
||||||
|
Verification had to learn the compositor's answer shapes: `getoption` returns the
|
||||||
|
value in a different field per type — `int`, `bool`, `float`, `str`, and `css` for
|
||||||
|
gaps, which read back as a four-value box (`"10 10 10 10"`). A verifier that only
|
||||||
|
understood `int` would have reported every other type as rejected. All five are
|
||||||
|
covered by contract.
|
||||||
|
|
||||||
|
`keyboardLayout` is the first setting whose value reaches an `hl.config` string,
|
||||||
|
so the schema gained a `pattern` field enforced in `coerce()`. The contract
|
||||||
|
includes a Lua-injection attempt through it; the value is rejected, nothing
|
||||||
|
executes, and the layout is unchanged.
|
||||||
|
|
||||||
|
Two test-hygiene bugs found and fixed along the way, both pre-existing in shape:
|
||||||
|
`settings-window-contract` leaves a Settings window that the compositor destroys
|
||||||
|
asynchronously, which made `settings-pages-contract` see a duplicate when run
|
||||||
|
straight after it — the pages contract now waits for a clean slate. And the new
|
||||||
|
write contract was persisting its deliberately-wrong values into the *real*
|
||||||
|
`~/.config/panama/settings.json`, where the next `hyprctl reload` would faithfully
|
||||||
|
apply them; it now runs against an isolated `XDG_CONFIG_HOME` while still driving
|
||||||
|
the live compositor.
|
||||||
|
|
||||||
|
Verified live: writing `gapsOut`/`windowRounding` into the shared file and running
|
||||||
|
`hyprctl reload` — the path a fresh login takes — applied both, and the compositor
|
||||||
|
and store agree on every key. Full suite green: 29 shell contracts, 3 Python
|
||||||
|
bridge tests, run sequentially.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 3 — Generic rows, then fill the pages
|
||||||
|
|
||||||
|
Per the visual-work rule, this stage stops for a decision before any page is
|
||||||
|
rewritten.
|
||||||
|
|
||||||
|
**Files:** Create `modules/settings/SettingsPage.qml`, `ToggleRow.qml`,
|
||||||
|
`SliderRow.qml`, `ChoiceRow.qml`, `ActionRow.qml`, `TextRow.qml`;
|
||||||
|
Modify all eleven `*Page.qml`; Test `tests/quickshell/settings-rows-contract.sh`
|
||||||
|
|
||||||
|
- [x] **Build static mocks** of the new Appearance page and one rebuilt existing
|
||||||
|
page, serve them over HTTP, report the URL, and **stop for a decision.**
|
||||||
|
Three directions built; Gabriel chose **B, the live preview**.
|
||||||
|
- [x] Implement the row components and `SettingsPage` (the scaffold previously
|
||||||
|
copy-pasted eleven times).
|
||||||
|
- [x] Give Appearance real content, driven by a live preview.
|
||||||
|
- [x] Rewrite Appearance, Desktop & Dock, and Input & Shortcuts on the new rows;
|
||||||
|
delete the dead read-only rows that only existed because a real control was
|
||||||
|
expensive.
|
||||||
|
- [x] Make "Restore defaults" span every store Panama owns.
|
||||||
|
- [x] Run the new contracts and the existing settings contracts to green.
|
||||||
|
- [ ] Promote the remaining hardcoded `Settings.qml` values: weather
|
||||||
|
location/unit/interval, vitals interval, night-light schedule, the four
|
||||||
|
notification timing and history limits, capture directories, and recorder
|
||||||
|
arguments.
|
||||||
|
- [ ] Make the dock pin list editable (reorder, add, remove) instead of a
|
||||||
|
16-entry literal.
|
||||||
|
- [ ] Move the remaining pages (Home, Displays, Connectivity, Sound,
|
||||||
|
Notifications, Screen Intelligence, Services, About) onto `SettingsPage`.
|
||||||
|
|
||||||
|
**Exit criteria:** no shipped behaviour value is reachable only by editing a file.
|
||||||
|
|
||||||
|
**Landed (first pass).** `SettingsPage` plus `ToggleRow`, `SliderRow`,
|
||||||
|
`ChoiceRow`, `ActionRow`, and `TextRow`. A row names a schema key and needs
|
||||||
|
nothing else — `ToggleRow { setting: "blurEnabled" }` pulls its label,
|
||||||
|
explanation, bounds, and unit from the schema, and writes through
|
||||||
|
`SystemSettings.commitPreference`, which routes compositor-backed keys through
|
||||||
|
apply-and-verify and local keys straight to the store. Rows never need to know
|
||||||
|
which kind they hold.
|
||||||
|
|
||||||
|
`DesktopPreview` is the direction-B centrepiece: two tiled windows drawn at the
|
||||||
|
settings actually in effect, scaled by the ratio between the preview's width and
|
||||||
|
the real monitor's, so a 10px gap on a 4500px display looks as small as it is.
|
||||||
|
|
||||||
|
Four things found by building it:
|
||||||
|
|
||||||
|
* `cursor:inactive_timeout` is reported as `float`, not `int`. `readAs` describes
|
||||||
|
what getoption answers with, not what the setting means, and getting it wrong
|
||||||
|
does not fail loudly — it makes every write to that key look rejected. The user
|
||||||
|
saw "Hyprland did not apply Hide pointer after" for a change that worked.
|
||||||
|
`tests/quickshell/schema-hypr-shape-contract.sh` now asks the compositor for
|
||||||
|
the real shape of all 23 mapped options.
|
||||||
|
* The Settings window is a normal tiled window, so `implicitWidth: 1120` is only
|
||||||
|
a hint and rows must survive ~400px. `SliderRow` stacks its control under the
|
||||||
|
label below 520px.
|
||||||
|
* Binding an anchor to `undefined` to switch layouts does not reliably release
|
||||||
|
it. Both row layouts are positioned explicitly now.
|
||||||
|
* Refusing concurrent compositor writes was the wrong policy: the startup replay
|
||||||
|
of 23 preferences routinely overlaps a UI change, and refusing left the store
|
||||||
|
and the compositor disagreeing. Writes queue and merge, later values winning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 4 — Shortcuts from the compositor
|
||||||
|
|
||||||
|
**Files:** Create `services/Keybinds.qml`; Modify `modules/settings/ShortcutsPage.qml`,
|
||||||
|
`config/dot/hypr/keybinds.lua`; Test `tests/quickshell/keybinds-contract.sh`
|
||||||
|
|
||||||
|
- [x] Write a contract asserting the page's bind count matches `hyprctl binds -j`
|
||||||
|
exactly, so it can never drift again.
|
||||||
|
- [x] Implement `Keybinds.qml` reading `hyprctl binds -j`, grouped.
|
||||||
|
- [x] Backfill `description` in `keybinds.lua` for the 29 binds that lacked one.
|
||||||
|
- [x] Rebuild `ShortcutsPage` on the live data; delete the hardcoded array.
|
||||||
|
- [x] Run the contract to green.
|
||||||
|
- [ ] Add rebinding: overrides in the same JSON, applied by `keybinds.lua` after
|
||||||
|
the defaults and live via `eval`, with conflict detection against existing binds.
|
||||||
|
- [ ] Add search over the shortcut list.
|
||||||
|
|
||||||
|
**Exit criteria:** the page shows every real bind, always current, and can change them.
|
||||||
|
|
||||||
|
**Landed (read-only).** The page shows all **113** binds, grouped by what they do,
|
||||||
|
against the hand-typed **19** it had before. Descriptions come from the binds
|
||||||
|
themselves, so a new bind appears with no change to the page.
|
||||||
|
|
||||||
|
Grouping is derived from each bind's own description rather than a table here, so
|
||||||
|
adding a bind puts it in the right section automatically. Hyprland reports
|
||||||
|
Lua-defined binds with dispatcher `__lua` and a bytecode offset as the argument,
|
||||||
|
so a bind without a description has nothing readable beside its chord; the
|
||||||
|
service drops those, and `tests/quickshell/keybinds-contract.sh` fails if any
|
||||||
|
exist so that dropping can never be silent.
|
||||||
|
|
||||||
|
Input settings are on the same page and are now real controls: keyboard repeat,
|
||||||
|
Num Lock, focus-follows-pointer, pointer speed, and the pointer hide timeout.
|
||||||
|
|
||||||
|
Rebinding is not done — that is the remaining half of this stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing note
|
||||||
|
|
||||||
|
Stage 0 is independent — ship it alone. Stages 1 and 2 are the architecture and
|
||||||
|
should land together, since Stage 2 is what makes Stage 1 worth doing. Stage 3
|
||||||
|
is the largest and is gated on a visual decision. Stage 4 is independent of 3 and
|
||||||
|
can run in parallel with it.
|
||||||
|
|
||||||
|
Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work
|
||||||
|
that should get a restore point before Stage 1 begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -88,12 +88,12 @@ Create `tests/quickshell/kdeconnect_bridge_test.py` with import-by-path and thes
|
|||||||
```python
|
```python
|
||||||
class KdeConnectBridgeTest(unittest.TestCase):
|
class KdeConnectBridgeTest(unittest.TestCase):
|
||||||
def test_status_omits_network_and_exposes_supported_ios_actions(self):
|
def test_status_omits_network_and_exposes_supported_ios_actions(self):
|
||||||
listing = "- Gib's iPhone: 90B54E84548E4F109268EC2D0E7D6930 on 192.168.1.7 via LAN (paired and reachable)\n1 device found\n"
|
listing = "- Fixture iPhone: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB on 192.0.2.7 via LAN (paired and reachable)\n1 device found\n"
|
||||||
plugins = ["kdeconnect_clipboard", "kdeconnect_findmyphone", "kdeconnect_ping", "kdeconnect_share"]
|
plugins = ["kdeconnect_clipboard", "kdeconnect_findmyphone", "kdeconnect_ping", "kdeconnect_share"]
|
||||||
device = bridge.normalize_device_line(listing.splitlines()[0], plugins)
|
device = bridge.normalize_device_line(listing.splitlines()[0], plugins)
|
||||||
self.assertEqual(device["name"], "Gib's iPhone")
|
self.assertEqual(device["name"], "Fixture iPhone")
|
||||||
self.assertEqual(device["actions"], ["clipboard", "ping", "ring", "share"])
|
self.assertEqual(device["actions"], ["clipboard", "ping", "ring", "share"])
|
||||||
self.assertNotIn("192.168.1.7", json.dumps(device))
|
self.assertNotIn("192.0.2.7", json.dumps(device))
|
||||||
|
|
||||||
def test_invalid_device_id_is_rejected(self):
|
def test_invalid_device_id_is_rejected(self):
|
||||||
with self.assertRaisesRegex(ValueError, "invalid-device"):
|
with self.assertRaisesRegex(ValueError, "invalid-device"):
|
||||||
@@ -105,8 +105,8 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
|||||||
bridge.validate_file(pathlib.Path(directory))
|
bridge.validate_file(pathlib.Path(directory))
|
||||||
|
|
||||||
def test_action_command_uses_separate_arguments(self):
|
def test_action_command_uses_separate_arguments(self):
|
||||||
command = bridge.action_command("ring", "90B54E84548E4F109268EC2D0E7D6930")
|
command = bridge.action_command("ring", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
||||||
self.assertEqual(command, ["kdeconnect-cli", "-d", "90B54E84548E4F109268EC2D0E7D6930", "--ring"])
|
self.assertEqual(command, ["kdeconnect-cli", "-d", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "--ring"])
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test and verify RED**
|
- [ ] **Step 2: Run the test and verify RED**
|
||||||
|
|||||||
@@ -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"
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user