Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b16fa03f | ||
|
|
60a321e6f4 | ||
|
|
7d5c65be03 | ||
|
|
f8e7512e01 | ||
|
|
b7ce2c6e43 | ||
|
|
1f62256024 | ||
|
|
4ef2f01baa | ||
|
|
6042c0b1c0 | ||
|
|
0c4d132ee1 | ||
|
|
faa9a00716 | ||
|
|
27af4fd443 | ||
|
|
b16834fea6 | ||
|
|
8bfae70284 | ||
|
|
4279da999a | ||
|
|
54b8a82803 | ||
|
|
2e8292599a | ||
|
|
5562323eb8 | ||
|
|
2cc109f5e1 | ||
|
|
52d896b054 | ||
|
|
e4409ed6aa | ||
|
|
f0321432b4 | ||
|
|
d855a26bd9 | ||
|
|
607acb0a2d | ||
|
|
9b2fa80ab7 | ||
|
|
8a0a09dbb4 | ||
|
|
816ea68f9a | ||
|
|
3238623934 | ||
|
|
21aa9223df | ||
|
|
e2e03252e4 | ||
|
|
0ca83f74c7 | ||
|
|
d58c431199 | ||
|
|
3ff414fb1b | ||
|
|
77f8102148 | ||
|
|
0fd59c59e3 | ||
|
|
b1f9891849 | ||
|
|
6026bf308b | ||
|
|
24d8bfd641 | ||
|
|
14529c011f | ||
|
|
7378c18ab9 | ||
|
|
9ce7040b91 | ||
|
|
af0ba13573 | ||
|
|
09e5f1ad6b | ||
|
|
7541a45e77 | ||
|
|
1a91d00f2c | ||
|
|
19063a3e02 | ||
|
|
5e6dc9e533 | ||
|
|
ca64d4b9b6 | ||
|
|
b28edcd01f | ||
|
|
9c574f9eb7 | ||
|
|
d1b2cd2083 | ||
|
|
8c4dee0ca8 | ||
|
|
71ee18048e | ||
|
|
668983e16d | ||
|
|
17e23a6cf4 | ||
|
|
d18fe51553 | ||
|
|
9f9515ffe0 | ||
|
|
94353aa0bd | ||
|
|
3a2295ff53 | ||
|
|
de1b8a7673 | ||
|
|
39a26adcc2 | ||
|
|
eff0bc44ff | ||
|
|
c038c57278 | ||
|
|
420b7aa1a6 | ||
|
|
f22e405b50 | ||
|
|
3cfa592db9 | ||
|
|
81096e2d95 | ||
|
|
c415c4f176 | ||
|
|
86742da63d | ||
|
|
3c521cf5fa | ||
|
|
c087998710 | ||
|
|
b9800bfbab | ||
|
|
7b8270fdd6 | ||
|
|
6d3f888784 | ||
|
|
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 | ||
|
|
dc6da5af34 | ||
|
|
922395d2b4 |
+13
@@ -8,3 +8,16 @@
|
||||
/config/dot/nvim/lazy-lock.json
|
||||
# Local visual-companion sessions and generated design mocks.
|
||||
/.superpowers/
|
||||
# Local linked worktrees used for reviewed feature development.
|
||||
/.worktrees/
|
||||
# Python helper bytecode is local runtime state.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
/config/dot/kitty/current-theme.conf
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
/config/dot/gtk-3.0/settings.ini
|
||||
/config/dot/gtk-4.0/settings.ini
|
||||
/config/dot/tmux/current-theme.conf
|
||||
|
||||
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
|
||||
| Piece | What it is |
|
||||
|---|---|
|
||||
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
||||
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
||||
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
||||
| `config/dot/vicinae/` | Raycast-style launcher, themed |
|
||||
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
|
||||
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Tokyo Night Day for btop, the light counterpart to tokyonight-moon.
|
||||
#
|
||||
# btop ships no Tokyo Night light variant at all, and the nearest stock light
|
||||
# theme (flat-remix-light) is a different palette that happens to have a similar
|
||||
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
|
||||
# and what runs inside it cannot disagree.
|
||||
#
|
||||
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
|
||||
# Day's darker, more saturated accents: the light versions of these hues are too
|
||||
# faint to read as a filled meter.
|
||||
|
||||
theme[main_bg]="#e1e2e7"
|
||||
theme[main_fg]="#3760bf"
|
||||
theme[title]="#3760bf"
|
||||
theme[hi_fg]="#2e7de9"
|
||||
theme[selected_bg]="#c4c8da"
|
||||
theme[selected_fg]="#2e7de9"
|
||||
theme[inactive_fg]="#7079a8"
|
||||
theme[proc_misc]="#587539"
|
||||
|
||||
theme[cpu_box]="#a8aecb"
|
||||
theme[mem_box]="#a8aecb"
|
||||
theme[net_box]="#a8aecb"
|
||||
theme[proc_box]="#a8aecb"
|
||||
theme[div_line]="#c4c8da"
|
||||
|
||||
theme[temp_start]="#2e7de9"
|
||||
theme[temp_mid]="#8c6c3e"
|
||||
theme[temp_end]="#f52a65"
|
||||
|
||||
theme[cpu_start]="#2e7de9"
|
||||
theme[cpu_mid]="#9854f1"
|
||||
theme[cpu_end]="#f52a65"
|
||||
|
||||
theme[free_start]="#c4c8da"
|
||||
theme[free_mid]="#007197"
|
||||
theme[free_end]="#2e7de9"
|
||||
|
||||
theme[cached_start]="#007197"
|
||||
theme[cached_mid]="#2e7de9"
|
||||
theme[cached_end]="#9854f1"
|
||||
|
||||
theme[available_start]="#8c6c3e"
|
||||
theme[available_mid]="#b15c00"
|
||||
theme[available_end]="#f52a65"
|
||||
|
||||
theme[used_start]="#587539"
|
||||
theme[used_mid]="#8c6c3e"
|
||||
theme[used_end]="#f52a65"
|
||||
|
||||
theme[download_start]="#c4c8da"
|
||||
theme[download_mid]="#2e7de9"
|
||||
theme[download_end]="#007197"
|
||||
|
||||
theme[upload_start]="#c4c8da"
|
||||
theme[upload_mid]="#9854f1"
|
||||
theme[upload_end]="#7847bd"
|
||||
@@ -0,0 +1,56 @@
|
||||
# Tokyo Night Moon for btop, matched to Panama's palette.
|
||||
#
|
||||
# btop ships a "tokyo-night" theme, but it is the Night variant (#1a1b26). The
|
||||
# rest of this desktop is Moon (#222436), and two Tokyo Nights side by side read
|
||||
# as a mistake rather than as a choice.
|
||||
#
|
||||
# The *_start/_mid/_end triples are gradients btop draws meters with: low,
|
||||
# middle, and high. They run blue -> yellow -> red so a saturated resource is
|
||||
# obvious at a glance without reading the number.
|
||||
|
||||
theme[main_bg]="#222436"
|
||||
theme[main_fg]="#c8d3f5"
|
||||
theme[title]="#c8d3f5"
|
||||
theme[hi_fg]="#82aaff"
|
||||
theme[selected_bg]="#3b4261"
|
||||
theme[selected_fg]="#82aaff"
|
||||
theme[inactive_fg]="#636da6"
|
||||
theme[proc_misc]="#a5e8b5"
|
||||
|
||||
theme[cpu_box]="#4d5685"
|
||||
theme[mem_box]="#4d5685"
|
||||
theme[net_box]="#4d5685"
|
||||
theme[proc_box]="#4d5685"
|
||||
theme[div_line]="#3b4261"
|
||||
|
||||
theme[temp_start]="#82aaff"
|
||||
theme[temp_mid]="#ffc777"
|
||||
theme[temp_end]="#ff757f"
|
||||
|
||||
theme[cpu_start]="#82aaff"
|
||||
theme[cpu_mid]="#c099ff"
|
||||
theme[cpu_end]="#ff757f"
|
||||
|
||||
theme[free_start]="#3b4261"
|
||||
theme[free_mid]="#589ed7"
|
||||
theme[free_end]="#86e1fc"
|
||||
|
||||
theme[cached_start]="#86e1fc"
|
||||
theme[cached_mid]="#82aaff"
|
||||
theme[cached_end]="#c099ff"
|
||||
|
||||
theme[available_start]="#ffc777"
|
||||
theme[available_mid]="#ff966c"
|
||||
theme[available_end]="#ff757f"
|
||||
|
||||
theme[used_start]="#a5e8b5"
|
||||
theme[used_mid]="#ffc777"
|
||||
theme[used_end]="#ff757f"
|
||||
|
||||
theme[download_start]="#3b4261"
|
||||
theme[download_mid]="#82aaff"
|
||||
theme[download_end]="#86e1fc"
|
||||
|
||||
theme[upload_start]="#3b4261"
|
||||
theme[upload_mid]="#c099ff"
|
||||
theme[upload_end]="#fca7ea"
|
||||
@@ -1,3 +1,12 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# scheme, so this file is regenerated on every switch and is not
|
||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||
# settings daemon, so for GTK3 it is authoritative -- which is why it
|
||||
# has to change with the scheme rather than being pinned to dark.
|
||||
#
|
||||
[Settings]
|
||||
# These values must match `gsettings get org.gnome.desktop.interface ...`.
|
||||
#
|
||||
@@ -6,12 +15,12 @@
|
||||
# this file becomes authoritative for GTK3 — which is why it previously named
|
||||
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
||||
# appearing broken.
|
||||
gtk-theme-name=adw-gtk3-dark
|
||||
gtk-theme-name=@GTK_THEME@
|
||||
gtk-icon-theme-name=Adwaita
|
||||
gtk-font-name=Adwaita Sans 11
|
||||
gtk-cursor-theme-name=oreo_blue_cursors
|
||||
gtk-cursor-theme-size=24
|
||||
gtk-application-prefer-dark-theme=1
|
||||
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||
|
||||
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
||||
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
|
||||
@@ -1,11 +0,0 @@
|
||||
[Settings]
|
||||
# libadwaita apps normally take their dark preference from the
|
||||
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
|
||||
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
|
||||
# window before the portal answers.
|
||||
gtk-application-prefer-dark-theme=1
|
||||
gtk-theme-name=adw-gtk3-dark
|
||||
gtk-icon-theme-name=Adwaita
|
||||
gtk-font-name=Adwaita Sans 11
|
||||
gtk-cursor-theme-name=oreo_blue_cursors
|
||||
gtk-cursor-theme-size=24
|
||||
@@ -0,0 +1,20 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# scheme, so this file is regenerated on every switch and is not
|
||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||
# settings daemon, so for GTK3 it is authoritative -- which is why it
|
||||
# has to change with the scheme rather than being pinned to dark.
|
||||
#
|
||||
[Settings]
|
||||
# libadwaita apps normally take their dark preference from the
|
||||
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
|
||||
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
|
||||
# window before the portal answers.
|
||||
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||
gtk-theme-name=@GTK_THEME@
|
||||
gtk-icon-theme-name=Adwaita
|
||||
gtk-font-name=Adwaita Sans 11
|
||||
gtk-cursor-theme-name=oreo_blue_cursors
|
||||
gtk-cursor-theme-size=24
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
|
||||
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
||||
@@ -35,7 +35,33 @@ 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 |
|
||||
| Removable media | udiskie plus udisks notifications | Live |
|
||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
|
||||
## System health and recovery
|
||||
|
||||
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||
appears only for actionable warnings or errors and opens the same **System
|
||||
Health** page available from Settings and the Vicinae command **Panama: Check
|
||||
System Health**. The terminal summary is available with:
|
||||
|
||||
```bash
|
||||
~/.config/quickshell/scripts/panama-doctor --summary
|
||||
```
|
||||
|
||||
The doctor reports authored, redacted observations about Panama-owned services,
|
||||
tools, links, and configured integrations. It does not read secrets, clipboard
|
||||
or notification contents, calendar events, SSIDs, or device addresses. Repairs
|
||||
are a small allow-list: Panama user services, Panama-owned links and launcher
|
||||
commands, duplicate Panama Caffeine inhibitors, and a confirmed shell restart.
|
||||
They never install packages, invoke `sudo`, delete user data, or rewrite
|
||||
arbitrary configuration.
|
||||
|
||||
Generic Fedora configuration remains with the system tools that own it. The
|
||||
final System Health card hands network settings, users, sharing, colour
|
||||
profiles, and digital wellbeing to their exact GNOME Settings panels rather
|
||||
than presenting inert Hyprland controls.
|
||||
|
||||
## GNOME extension migration
|
||||
|
||||
@@ -52,8 +78,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 |
|
||||
| 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 |
|
||||
| GSConnect | No paired phone profile was present; KDE Connect is installed and awaiting pairing |
|
||||
| Home Assistant | Useful integration, but needs a purpose-built panel and secure credential migration |
|
||||
| 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 | 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 |
|
||||
| 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 |
|
||||
@@ -63,20 +89,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
|
||||
a polished general-purpose desktop can go beyond the current shell.
|
||||
|
||||
1. Home Assistant favourites in a dedicated, secure quick panel. The token must
|
||||
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
|
||||
1. A real external RustDesk test while hyprlock is active. Local process state
|
||||
cannot prove what a remote client sees or whether input is accepted.
|
||||
|
||||
Supporting packages tracked in `setup/packages/hyprland-packages`:
|
||||
|
||||
```sh
|
||||
sudo dnf install kde-connect system-config-printer tesseract zbar
|
||||
```
|
||||
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
||||
tracked in `setup/packages/hyprland-packages`.
|
||||
|
||||
## Global menu decision
|
||||
|
||||
|
||||
+135
-2
@@ -27,6 +27,7 @@ Don't "fix" them.
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
||||
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
|
||||
| `env.lua` | Environment. Note the uwsm caveat below |
|
||||
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
||||
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
||||
@@ -43,6 +44,109 @@ Validate any change without leaving your session:
|
||||
Hyprland --verify-config
|
||||
```
|
||||
|
||||
## Generated configuration
|
||||
|
||||
Two ecosystem tools cannot read the shared settings file, and their configs live
|
||||
in this directory — which is a symlink into the Panama repository, so writing to
|
||||
them at runtime would put machine state into a tracked file. Both are therefore
|
||||
generated elsewhere:
|
||||
|
||||
| Tool | Generated to | Pointed there by |
|
||||
|---|---|---|
|
||||
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
|
||||
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
|
||||
|
||||
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
|
||||
settings store and restarts the daemon. `hypridle.conf` in this directory
|
||||
remains the shipped default and is what runs when the drop-in is not installed;
|
||||
The Settings app 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.
|
||||
- **The Settings app 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
|
||||
the Settings app 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 the Settings app 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
|
||||
|
||||
Tokyo Night Moon, with two accents that come from the tmux theme:
|
||||
@@ -171,7 +275,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + V` | Clipboard history |
|
||||
| `SUPER + .` | Emoji picker |
|
||||
| `SUPER + S` | Quick settings |
|
||||
| `SUPER + I` | Panama Settings |
|
||||
| `SUPER + I` | Settings |
|
||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||
| `SUPER + B` | Notification centre |
|
||||
| `SUPER + grave` | Workspace overview |
|
||||
@@ -184,7 +288,7 @@ The mental model is unchanged from Forge:
|
||||
|
||||
### Apps
|
||||
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
|
||||
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
|
||||
`E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
|
||||
Settings remains searchable in Vicinae for hardware and account panels.
|
||||
|
||||
## Calendar and notifications
|
||||
@@ -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
|
||||
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 **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
|
||||
|
||||
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
-- Check: Hyprland --verify-config
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Shared preferences first: looks/input/monitors read their defaults through it.
|
||||
-- It never raises, so a missing or malformed settings file costs customisations
|
||||
-- and nothing else.
|
||||
require("prefs")
|
||||
|
||||
require("env")
|
||||
require("monitors")
|
||||
require("looks")
|
||||
|
||||
@@ -5,28 +5,38 @@
|
||||
-- acceleration changes. The goal is that muscle memory transfers untouched.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
hl.config({
|
||||
input = {
|
||||
kb_layout = "us",
|
||||
kb_layout = prefs.get("keyboardLayout", "us"),
|
||||
kb_variant = "",
|
||||
kb_model = "",
|
||||
kb_options = "",
|
||||
kb_rules = "",
|
||||
|
||||
numlock_by_default = true,
|
||||
numlock_by_default = prefs.get("numlockByDefault", true),
|
||||
|
||||
-- GNOME's defaults are 500ms delay / 33Hz repeat.
|
||||
repeat_delay = 500,
|
||||
repeat_rate = 33,
|
||||
repeat_delay = prefs.get("keyRepeatDelay", 500),
|
||||
repeat_rate = prefs.get("keyRepeatRate", 33),
|
||||
|
||||
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
|
||||
follow_mouse = 1,
|
||||
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
|
||||
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
|
||||
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
|
||||
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
|
||||
-- so click-to-focus is 0. Changing the shipped value is a behaviour
|
||||
-- decision rather than a correction, so the value is left alone and
|
||||
-- only the description is fixed; Settings exposes all four.
|
||||
follow_mouse = prefs.getInt("followMouse", 1),
|
||||
|
||||
-- Don't refocus on mouse move alone -- only on click.
|
||||
-- Softens follow_mouse: with this off, focus changes only when the
|
||||
-- pointer crosses a window boundary, not on every movement inside one.
|
||||
-- Still focus-follows-pointer, just less twitchy.
|
||||
mouse_refocus = false,
|
||||
|
||||
-- Flat pointer response, no acceleration. Matters for gaming.
|
||||
sensitivity = 0,
|
||||
sensitivity = prefs.get("pointerSensitivity", 0),
|
||||
accel_profile = "flat",
|
||||
|
||||
-- Clicking a floating window raises and focuses it.
|
||||
|
||||
+136
-95
@@ -11,6 +11,8 @@
|
||||
-- SUPER + CTRL -> change layout structure (split, float, swap)
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
local mod = "SUPER"
|
||||
|
||||
-- Programs, matched to the GNOME custom keybindings and media-key settings.
|
||||
@@ -28,114 +30,153 @@ local sysmonitor = "flatpak run io.missioncenter.MissionCenter"
|
||||
-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
|
||||
-- window against the already-running server (started in autostart.lua).
|
||||
local launcher = "vicinae toggle"
|
||||
local osd = function(action)
|
||||
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
||||
end
|
||||
|
||||
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
|
||||
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
||||
|
||||
-- ── Applications ────────────────────────────────────────────────────────────
|
||||
hl.bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
|
||||
hl.bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
|
||||
hl.bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
||||
hl.bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
||||
hl.bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||
hl.bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||
hl.bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
|
||||
hl.bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
||||
-- ── User rebinding ──────────────────────────────────────────────────────────
|
||||
-- Every bind below goes through `bind` rather than `hl.bind` directly, so a
|
||||
-- chord can be replaced from Panama Settings without this file changing.
|
||||
--
|
||||
-- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is 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 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 = "Settings" })
|
||||
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
||||
|
||||
-- ── Launcher ────────────────────────────────────────────────────────────────
|
||||
-- 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
|
||||
-- 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.
|
||||
hl.bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||
hl.bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||
hl.bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||
bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||
bind(mod .. " + R", 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
|
||||
-- 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.
|
||||
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.
|
||||
-- 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" })
|
||||
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" })
|
||||
|
||||
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
|
||||
-- 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
|
||||
-- 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
|
||||
-- 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",
|
||||
-- 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.
|
||||
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
|
||||
-- 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.
|
||||
hl.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("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
|
||||
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
|
||||
-- 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" })
|
||||
|
||||
-- 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 ───────────────────────────────────────────────────────
|
||||
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||
hl.bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
|
||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
|
||||
|
||||
-- Forge: window-toggle-float / window-toggle-always-float.
|
||||
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
|
||||
-- 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" })
|
||||
hl.bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
|
||||
bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
|
||||
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.
|
||||
hl.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" })
|
||||
hl.bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
|
||||
bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
|
||||
bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
|
||||
bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
|
||||
|
||||
-- Forge: window-shrink / window-expand / window-reset-sizes.
|
||||
hl.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" })
|
||||
hl.bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
|
||||
bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
|
||||
bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
|
||||
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.
|
||||
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
|
||||
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
|
||||
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
|
||||
hl.bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
|
||||
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" }))
|
||||
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" }))
|
||||
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" }))
|
||||
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" }))
|
||||
bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
|
||||
bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
|
||||
bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
|
||||
bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
|
||||
bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
|
||||
bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
|
||||
bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
|
||||
bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
|
||||
|
||||
-- Move (Forge: window-move-*).
|
||||
hl.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" })
|
||||
hl.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 + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
|
||||
bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
|
||||
bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
|
||||
bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
|
||||
|
||||
-- Swap (Forge: window-swap-*).
|
||||
hl.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" })
|
||||
hl.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 + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
|
||||
bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
|
||||
bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
|
||||
bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
|
||||
|
||||
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
||||
--
|
||||
@@ -145,24 +186,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,
|
||||
-- and "increase" always grows while "decrease" always shrinks.
|
||||
local step = 60
|
||||
hl.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" })
|
||||
hl.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" })
|
||||
hl.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" })
|
||||
hl.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 + Y", 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" })
|
||||
bind(mod .. " + SHIFT + B", 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" })
|
||||
bind(mod .. " + SHIFT + I", 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" })
|
||||
bind(mod .. " + SHIFT + P", 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).
|
||||
hl.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 .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous 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.
|
||||
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
|
||||
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
|
||||
bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
|
||||
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
|
||||
|
||||
-- ── Workspaces ──────────────────────────────────────────────────────────────
|
||||
-- ALT is the workspace modifier, matching the GNOME setup.
|
||||
@@ -170,27 +211,27 @@ hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
|
||||
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
||||
-- moving right past the last workspace creates a new one, and moving left from
|
||||
-- the first clamps instead of wrapping.
|
||||
hl.bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
hl.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" })
|
||||
hl.bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
|
||||
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
||||
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.
|
||||
hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }))
|
||||
hl.bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }))
|
||||
bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||
|
||||
-- Direct jump. ALT+0 is workspace 10.
|
||||
for i = 1, 10 do
|
||||
local key = i % 10
|
||||
hl.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 + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
|
||||
bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
|
||||
end
|
||||
|
||||
-- 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
|
||||
-- in quickshell/modules/bar/Workspaces.qml.)
|
||||
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }))
|
||||
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
|
||||
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
|
||||
-- Minimise, as far as Hyprland has one.
|
||||
--
|
||||
@@ -202,42 +243,42 @@ hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
|
||||
--
|
||||
-- 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.
|
||||
hl.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 .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
|
||||
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
|
||||
|
||||
-- ── Session ─────────────────────────────────────────────────────────────────
|
||||
-- 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.
|
||||
hl.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 + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
|
||||
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
|
||||
|
||||
-- ── Media and volume ────────────────────────────────────────────────────────
|
||||
-- locked = true keeps these working on the lock screen, as they do in GNOME.
|
||||
-- 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 })
|
||||
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true })
|
||||
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
|
||||
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
|
||||
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
|
||||
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
|
||||
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
|
||||
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
|
||||
|
||||
-- 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 })
|
||||
hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true })
|
||||
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
|
||||
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
|
||||
|
||||
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
|
||||
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
|
||||
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
||||
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
||||
hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true })
|
||||
bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
|
||||
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
|
||||
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
|
||||
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
|
||||
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
|
||||
|
||||
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
|
||||
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
|
||||
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
|
||||
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
|
||||
|
||||
-- Hardware keys GNOME mapped that have obvious equivalents.
|
||||
hl.bind("XF86Tools", hl.dsp.exec_cmd(settings))
|
||||
hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator))
|
||||
hl.bind("XF86Explorer", hl.dsp.exec_cmd(files))
|
||||
hl.bind("XF86WWW", hl.dsp.exec_cmd(browser))
|
||||
hl.bind("XF86Mail", hl.dsp.exec_cmd(mail))
|
||||
hl.bind("XF86Search", hl.dsp.exec_cmd(launcher))
|
||||
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||
bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
|
||||
bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
||||
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||
|
||||
return true
|
||||
|
||||
+29
-19
@@ -12,12 +12,14 @@
|
||||
-- display.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
gaps_in = 5,
|
||||
gaps_out = 10,
|
||||
gaps_in = prefs.get("gapsIn", 5),
|
||||
gaps_out = prefs.get("gapsOut", 10),
|
||||
|
||||
border_size = 2,
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
||||
@@ -27,7 +29,11 @@ hl.config({
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- Unfocused windows get no colour at all. The gradient only means
|
||||
-- something if exactly one window on screen is wearing it.
|
||||
inactive_border = "rgba(3b426199)",
|
||||
-- Follows the colour scheme: a dark neutral is invisible against a
|
||||
-- light desktop. services/ColorScheme.qml applies changes live;
|
||||
-- this is the value a fresh session starts from.
|
||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||
},
|
||||
|
||||
resize_on_border = true,
|
||||
@@ -44,16 +50,16 @@ hl.config({
|
||||
decoration = {
|
||||
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
||||
-- next to each other read as the same object family.
|
||||
rounding = 18,
|
||||
rounding = prefs.get("windowRounding", 18),
|
||||
rounding_power = 2,
|
||||
|
||||
active_opacity = 1.0,
|
||||
inactive_opacity = 1.0,
|
||||
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
||||
|
||||
blur = {
|
||||
enabled = true,
|
||||
size = 8,
|
||||
passes = 3,
|
||||
enabled = prefs.get("blurEnabled", true),
|
||||
size = prefs.get("blurSize", 8),
|
||||
passes = prefs.get("blurPasses", 3),
|
||||
|
||||
-- Required for blur to be affordable. Never turn this off.
|
||||
new_optimizations = true,
|
||||
@@ -75,8 +81,8 @@ hl.config({
|
||||
},
|
||||
|
||||
shadow = {
|
||||
enabled = true,
|
||||
range = 20,
|
||||
enabled = prefs.get("shadowEnabled", true),
|
||||
range = prefs.get("shadowRange", 20),
|
||||
render_power = 3,
|
||||
sharp = false,
|
||||
color = "rgba(15161eee)",
|
||||
@@ -88,8 +94,8 @@ hl.config({
|
||||
-- border is the signature, and a strong halo would compete with it.
|
||||
-- This is just enough to lift the focused window off the wallpaper.
|
||||
glow = {
|
||||
enabled = true,
|
||||
range = 8,
|
||||
enabled = prefs.get("glowEnabled", true),
|
||||
range = prefs.get("glowRange", 8),
|
||||
render_power = 2,
|
||||
color = "rgba(82aaff33)",
|
||||
color_inactive = "rgba(00000000)",
|
||||
@@ -99,7 +105,7 @@ hl.config({
|
||||
motion_blur = { enabled = false },
|
||||
},
|
||||
|
||||
animations = { enabled = true },
|
||||
animations = { enabled = prefs.get("animationsEnabled", true) },
|
||||
|
||||
dwindle = {
|
||||
-- Keep the split orientation a window was created with. Closest match
|
||||
@@ -113,12 +119,16 @@ hl.config({
|
||||
disable_hyprland_logo = true,
|
||||
disable_splash_rendering = true,
|
||||
|
||||
font_family = "Adwaita Sans",
|
||||
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
||||
-- followed the preference, the compositor and the shell would disagree
|
||||
-- about the interface font and nobody would notice until a tooltip
|
||||
-- rendered in a different typeface.
|
||||
font_family = prefs.get("interfaceFont", "Adwaita Sans"),
|
||||
|
||||
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
|
||||
-- content type is "video" or "game" -- the tag rules.lua applies to
|
||||
-- games. Keeps VRR off the desktop, where it causes visible flicker.
|
||||
vrr = 3,
|
||||
vrr = prefs.getInt("vrrPolicy", 3),
|
||||
|
||||
-- Blur behind the lock screen.
|
||||
session_lock_blur = true,
|
||||
@@ -137,12 +147,12 @@ hl.config({
|
||||
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
||||
-- that asks for it, and back out afterwards. This is how games get HDR
|
||||
-- without the desktop paying the screencopy cost. See monitors.lua.
|
||||
cm_auto_hdr = 1,
|
||||
cm_auto_hdr = prefs.getInt("autoHdr", 1),
|
||||
|
||||
-- 2 = direct scanout only for windows tagged content = "game"
|
||||
-- (set by the rules in rules.lua). Bypasses compositing for real
|
||||
-- fullscreen games.
|
||||
direct_scanout = 2,
|
||||
direct_scanout = prefs.getInt("directScanoutPolicy", 2),
|
||||
},
|
||||
|
||||
cursor = {
|
||||
@@ -158,7 +168,7 @@ hl.config({
|
||||
sync_gsettings_theme = true,
|
||||
|
||||
-- Fade the cursor out after 4s of no movement, like GNOME does.
|
||||
inactive_timeout = 4,
|
||||
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
|
||||
},
|
||||
|
||||
ecosystem = {
|
||||
|
||||
@@ -17,11 +17,88 @@
|
||||
-- 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({
|
||||
output = "DP-2",
|
||||
mode = "4500x3000@60",
|
||||
mode = dp2 and dp2.mode or shipped_mode,
|
||||
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
|
||||
-- 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",
|
||||
})
|
||||
|
||||
-- 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.
|
||||
hl.monitor({
|
||||
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
|
||||
@@ -183,7 +183,6 @@ font_size 14.0
|
||||
|
||||
#: Cursor customization {{{
|
||||
|
||||
cursor #c8d3f5
|
||||
|
||||
#: Default cursor color. If set to the special value none the cursor
|
||||
#: will be rendered with a "reverse video" effect. It's color will be
|
||||
@@ -193,7 +192,6 @@ cursor #c8d3f5
|
||||
#: precedence. Also, the cursor colors are modified if the cell
|
||||
#: background and foreground colors have very low contrast.
|
||||
|
||||
cursor_text_color #111325
|
||||
|
||||
#: The color of text under the cursor. If you want it rendered with
|
||||
#: the background color of the cell underneath instead, use the
|
||||
@@ -312,7 +310,6 @@ cursor_shape block
|
||||
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
||||
#: much effort.
|
||||
|
||||
url_color #4fd6be
|
||||
# url_style curly
|
||||
|
||||
#: The color and style for highlighting URLs on mouse-over. url_style
|
||||
@@ -734,12 +731,10 @@ window_border_width 1pt
|
||||
#: placed centrally. A value of top-left means the padding will be
|
||||
#: only at the bottom and right edges.
|
||||
|
||||
active_border_color #82aaff
|
||||
|
||||
#: The color for the border of the active window. Set this to none to
|
||||
#: not draw borders around the active window.
|
||||
|
||||
inactive_border_color #2f334d
|
||||
|
||||
#: The color for the border of inactive windows.
|
||||
|
||||
@@ -960,16 +955,11 @@ tab_powerline_style round
|
||||
#: Template to use for active tabs. If not specified falls back to
|
||||
#: tab_title_template.
|
||||
|
||||
active_tab_foreground #1e2030
|
||||
active_tab_background #82aaff
|
||||
# active_tab_font_style bold-italic
|
||||
inactive_tab_foreground #c8d3f5
|
||||
inactive_tab_background #42465a
|
||||
# inactive_tab_font_style normal
|
||||
|
||||
#: Tab bar colors and styles.
|
||||
|
||||
tab_bar_background #222436
|
||||
|
||||
#: Background color for the tab bar. Defaults to using the terminal
|
||||
#: background color.
|
||||
@@ -983,8 +973,8 @@ tab_bar_margin_color none
|
||||
|
||||
#: Color scheme {{{
|
||||
|
||||
foreground #c8d3f5
|
||||
background #222436
|
||||
|
||||
include current-theme.conf
|
||||
|
||||
#: The foreground and background colors.
|
||||
|
||||
@@ -1043,8 +1033,6 @@ dim_opacity 1
|
||||
#: How much to dim text that has the DIM/FAINT attribute set. One
|
||||
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
||||
|
||||
selection_foreground #2d3f76
|
||||
selection_background #c8d3f5
|
||||
|
||||
#: The foreground and background colors for text selected with the
|
||||
#: mouse. Setting both of these to none will cause a "reverse video"
|
||||
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
|
||||
#: dull and bright version, for the first 16 colors. You can set the
|
||||
#: remaining 240 colors as color16 to color255.
|
||||
|
||||
color0 #1b1d2b
|
||||
color8 #444a73
|
||||
|
||||
#: black
|
||||
|
||||
color1 #ff757f
|
||||
color9 #ff757f
|
||||
|
||||
#: red
|
||||
|
||||
color2 #a5e8b5
|
||||
color10 #a5e8b5
|
||||
|
||||
#: green
|
||||
color3 #ffc777
|
||||
color11 #ffc777
|
||||
|
||||
#: yellow
|
||||
|
||||
color4 #82aaff
|
||||
color12 #82aaff
|
||||
|
||||
#: blue
|
||||
|
||||
color5 #c099ff
|
||||
color13 #c099ff
|
||||
|
||||
#: magenta
|
||||
|
||||
color6 #86e1fc
|
||||
color14 #86e1fc
|
||||
|
||||
#: cyan
|
||||
|
||||
color7 #828bb8
|
||||
color15 #c8d3f5
|
||||
|
||||
#: white
|
||||
|
||||
color16 #ff966c
|
||||
color17 #c53b53
|
||||
|
||||
# mark1_foreground black
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Tokyo Night Day — the palette's own light variant.
|
||||
#
|
||||
# The same hues as Moon at a different lightness, so a terminal in light mode
|
||||
# still belongs to this desktop rather than looking like a different machine.
|
||||
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
|
||||
|
||||
cursor #3760bf
|
||||
cursor_text_color #e1e2e7
|
||||
url_color #118c74
|
||||
active_border_color #2e7de9
|
||||
inactive_border_color #c4c8da
|
||||
active_tab_foreground #e1e2e7
|
||||
active_tab_background #2e7de9
|
||||
inactive_tab_foreground #3760bf
|
||||
inactive_tab_background #c4c8da
|
||||
tab_bar_background #e1e2e7
|
||||
foreground #3760bf
|
||||
background #e1e2e7
|
||||
selection_foreground #e1e2e7
|
||||
selection_background #99a7df
|
||||
color0 #e9e9ed
|
||||
color8 #a1a6c5
|
||||
color1 #f52a65
|
||||
color9 #f52a65
|
||||
color2 #587539
|
||||
color10 #587539
|
||||
color3 #8c6c3e
|
||||
color11 #8c6c3e
|
||||
color4 #2e7de9
|
||||
color12 #2e7de9
|
||||
color5 #9854f1
|
||||
color13 #9854f1
|
||||
color6 #007197
|
||||
color14 #007197
|
||||
color7 #6172b0
|
||||
color15 #3760bf
|
||||
color16 #b15c00
|
||||
color17 #c64343
|
||||
@@ -0,0 +1,42 @@
|
||||
# Tokyo Night Moon — the dark theme Panama ships.
|
||||
#
|
||||
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||
# setting; that generated file is gitignored because it is machine state.
|
||||
#
|
||||
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||
# remote control. Without it a scheme change would only reach terminals opened
|
||||
# afterwards.
|
||||
|
||||
cursor #c8d3f5
|
||||
cursor_text_color #111325
|
||||
url_color #4fd6be
|
||||
active_border_color #82aaff
|
||||
inactive_border_color #2f334d
|
||||
active_tab_foreground #1e2030
|
||||
active_tab_background #82aaff
|
||||
inactive_tab_foreground #c8d3f5
|
||||
inactive_tab_background #42465a
|
||||
tab_bar_background #222436
|
||||
foreground #c8d3f5
|
||||
background #222436
|
||||
selection_foreground #2d3f76
|
||||
selection_background #c8d3f5
|
||||
color0 #1b1d2b
|
||||
color8 #444a73
|
||||
color1 #ff757f
|
||||
color9 #ff757f
|
||||
color2 #a5e8b5
|
||||
color10 #a5e8b5
|
||||
color3 #ffc777
|
||||
color11 #ffc777
|
||||
color4 #82aaff
|
||||
color12 #82aaff
|
||||
color5 #c099ff
|
||||
color13 #c099ff
|
||||
color6 #86e1fc
|
||||
color14 #86e1fc
|
||||
color7 #828bb8
|
||||
color15 #c8d3f5
|
||||
color16 #ff966c
|
||||
color17 #c53b53
|
||||
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
|
||||
vim.fn.winrestview(view)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Follow the desktop's light/dark setting while running, not only at startup.
|
||||
require("config.panama").watch()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Panama desktop integration.
|
||||
--
|
||||
-- Neovim is the one application here that neither reads the desktop portal nor
|
||||
-- has a control socket open by default, so it reads the shared settings file
|
||||
-- directly -- the same ~/.config/panama/settings.json that the shell and the
|
||||
-- Hyprland config read.
|
||||
--
|
||||
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||
-- user their colour scheme preference and nothing else; editing text is more
|
||||
-- important than matching the desktop.
|
||||
|
||||
local M = {}
|
||||
|
||||
local function settings_path()
|
||||
local config_home = os.getenv("XDG_CONFIG_HOME")
|
||||
if config_home == nil or config_home == "" then
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then
|
||||
return nil
|
||||
end
|
||||
config_home = home .. "/.config"
|
||||
end
|
||||
return config_home .. "/panama/settings.json"
|
||||
end
|
||||
|
||||
-- "dark" or "light". Defaults to dark, which is what Panama ships.
|
||||
function M.color_scheme()
|
||||
local path = settings_path()
|
||||
if not path then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
local ok, contents = pcall(function()
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
return nil
|
||||
end
|
||||
local text = file:read("*a")
|
||||
file:close()
|
||||
return text
|
||||
end)
|
||||
if not ok or not contents or contents == "" then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
local decoded_ok, decoded = pcall(vim.json.decode, contents)
|
||||
if not decoded_ok or type(decoded) ~= "table" then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
return decoded.colorScheme == "light" and "light" or "dark"
|
||||
end
|
||||
|
||||
function M.is_light()
|
||||
return M.color_scheme() == "light"
|
||||
end
|
||||
|
||||
-- tokyonight ships a light variant in the same family, so light mode stays the
|
||||
-- same identity rather than becoming a different editor theme.
|
||||
function M.tokyonight_style()
|
||||
return M.is_light() and "day" or "moon"
|
||||
end
|
||||
|
||||
-- Re-apply the scheme when the window regains focus.
|
||||
--
|
||||
-- Neovim reads the setting once at startup and has no control socket open by
|
||||
-- default, so an editor already running when the desktop scheme flips would
|
||||
-- otherwise stay on the old palette until it was restarted. FocusGained is
|
||||
-- cheap, happens exactly when you would notice the mismatch, and does nothing
|
||||
-- at all unless the scheme actually changed.
|
||||
function M.watch()
|
||||
local applied = M.color_scheme()
|
||||
|
||||
vim.api.nvim_create_autocmd("FocusGained", {
|
||||
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
|
||||
callback = function()
|
||||
local current = M.color_scheme()
|
||||
if current == applied then
|
||||
return
|
||||
end
|
||||
applied = current
|
||||
vim.o.background = current
|
||||
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,31 +1,68 @@
|
||||
-- Tokyo Night, following the desktop's colour scheme.
|
||||
--
|
||||
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||
-- becoming a different-looking application when the scheme flips.
|
||||
--
|
||||
-- The readability fixes below are deliberately dark-only. They were written
|
||||
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||
-- applying them to Day would put light grey text on a light background, which
|
||||
-- is exactly the legibility problem they exist to solve, inverted.
|
||||
|
||||
local panama = require("config.panama")
|
||||
|
||||
return {
|
||||
{
|
||||
"folke/tokyonight.nvim",
|
||||
opts = {
|
||||
style = "moon",
|
||||
opts = function()
|
||||
local light = panama.is_light()
|
||||
|
||||
return {
|
||||
style = panama.tokyonight_style(),
|
||||
light_style = "day",
|
||||
transparent = true,
|
||||
|
||||
on_colors = function(colors)
|
||||
if light then
|
||||
-- Day's defaults are tuned for a light ground and mostly need no
|
||||
-- help. Comments are the exception: the shipped #848cb5 measures
|
||||
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
|
||||
-- for secondary text. This is 3.25:1 -- readable, and still clearly
|
||||
-- dimmer than Normal's 4.52:1 so it does not compete with code.
|
||||
colors.comment = "#7079a8"
|
||||
return
|
||||
end
|
||||
|
||||
colors.comment = "#a0a7c5"
|
||||
colors.fg_gutter = "#787f93"
|
||||
colors.terminal_black = "#828bb8"
|
||||
end,
|
||||
|
||||
on_highlights = function(highlights, colors)
|
||||
-- Fix inline code visibility in markdown
|
||||
-- Inline code in markdown is invisible at both lightnesses without
|
||||
-- an explicit background, because the theme leaves it unset.
|
||||
highlights["@markup.raw.markdown_inline"] = {
|
||||
bg = colors.terminal_black,
|
||||
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["RenderMarkdownCodeInline"] = {
|
||||
bg = colors.terminal_black,
|
||||
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||
-- imports, making text nearly invisible against LspReferenceText's background
|
||||
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
|
||||
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||
-- imports, making text nearly invisible against LspReferenceText's
|
||||
-- background.
|
||||
local reference = {
|
||||
bg = light and colors.bg_visual or colors.fg_gutter,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["LspReferenceText"] = reference
|
||||
highlights["LspReferenceRead"] = reference
|
||||
highlights["LspReferenceWrite"] = reference
|
||||
end,
|
||||
}
|
||||
end,
|
||||
},
|
||||
},
|
||||
{
|
||||
"LazyVim/LazyVim",
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
pragma Singleton
|
||||
|
||||
// User choices that Panama Settings may change at runtime. This is deliberately
|
||||
// separate from Settings.qml: the latter remains the stable public surface used
|
||||
// by the shell, while this object owns durable mutation and shipped defaults.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
|
||||
//
|
||||
// This object owns reading, validating, and writing. It knows nothing about
|
||||
// which settings exist -- that is the schema's job -- so adding a setting never
|
||||
// requires touching this file. That is the point: the previous implementation
|
||||
// restated every key four times (a property alias, a JSON adapter property, a
|
||||
// change handler, and a line in reset), and each omission failed silently.
|
||||
//
|
||||
// Reads go through get(), writes through set(). Typed accessors for the values
|
||||
// the shell reads on every frame live in Settings.qml, which remains the stable
|
||||
// public surface.
|
||||
//
|
||||
// The store lives at ~/.config/panama/settings.json rather than inside
|
||||
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
|
||||
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
|
||||
// able to back it up, diff it, or keep it in a dotfiles repo.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -11,110 +26,152 @@ import QtQuick
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property alias use24Hour: values.use24Hour
|
||||
property alias showSeconds: values.showSeconds
|
||||
property alias showWeekday: values.showWeekday
|
||||
property alias showCpu: values.showCpu
|
||||
property alias showMemory: values.showMemory
|
||||
property alias showGpu: values.showGpu
|
||||
property alias dockAutohide: values.dockAutohide
|
||||
property alias dockRevealDelayMs: values.dockRevealDelayMs
|
||||
property alias dockHideDelayMs: values.dockHideDelayMs
|
||||
property alias focusDurationMinutes: values.focusDurationMinutes
|
||||
property alias autoHdr: values.autoHdr
|
||||
property alias vrrPolicy: values.vrrPolicy
|
||||
property alias directScanoutPolicy: values.directScanoutPolicy
|
||||
property alias nightLightEnabled: values.nightLightEnabled
|
||||
property alias nightLightAutomatic: values.nightLightAutomatic
|
||||
property alias nightLightTemperature: values.nightLightTemperature
|
||||
property alias lastPage: values.lastPage
|
||||
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
|
||||
|
||||
// Bumped on every accepted change. get() reads it so bindings built on get()
|
||||
// have something to invalidate; a bare function call would otherwise capture
|
||||
// no dependency and every reader would silently go stale.
|
||||
property int revision: 0
|
||||
|
||||
// Everything currently on disk, including keys this build does not know
|
||||
// about. Unknown keys are carried through untouched so that rolling back to
|
||||
// an older Panama does not discard a newer version's settings.
|
||||
property var values: ({})
|
||||
|
||||
property bool loaded: false
|
||||
|
||||
function get(key: string): var {
|
||||
root.revision;
|
||||
const stored = root.values[key];
|
||||
if (stored === undefined)
|
||||
return PreferenceSchema.defaultFor(key);
|
||||
const coerced = PreferenceSchema.coerce(key, stored);
|
||||
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
|
||||
}
|
||||
|
||||
// Returns false when the key is unknown or the value cannot be represented,
|
||||
// so a caller can surface the rejection instead of assuming it took.
|
||||
function set(key: string, value: var): bool {
|
||||
if (!PreferenceSchema.has(key))
|
||||
return false;
|
||||
const coerced = PreferenceSchema.coerce(key, value);
|
||||
if (coerced === undefined)
|
||||
return false;
|
||||
if (root.values[key] === coerced)
|
||||
return true;
|
||||
|
||||
// Reassign rather than mutate: QML does not notify on in-place changes
|
||||
// to a var property's contents.
|
||||
const next = Object.assign({}, root.values);
|
||||
next[key] = coerced;
|
||||
root.values = next;
|
||||
root.revision++;
|
||||
persistTimer.restart();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Restores every schema default in one write. Complete by construction --
|
||||
// there is no hand-maintained list to fall out of sync with the schema.
|
||||
function resetDesktopDefaults(): void {
|
||||
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
|
||||
root.values = next;
|
||||
root.revision++;
|
||||
persistTimer.restart();
|
||||
}
|
||||
|
||||
// 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 = {};
|
||||
}
|
||||
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
||||
|
||||
// Upgrade before anything reads a value. A stored key the current
|
||||
// schema no longer recognises is carried through untouched and silently
|
||||
// stops taking effect, so the conversion has to happen here rather than
|
||||
// being noticed later by whoever owns that setting.
|
||||
const result = Migrations.apply(raw);
|
||||
root.values = result.values;
|
||||
|
||||
if (result.migrated)
|
||||
console.info("Settings migrated from version", result.from, "to", result.to + ":",
|
||||
result.applied.join("; "));
|
||||
|
||||
// Write whenever the version moved, which includes stamping a file
|
||||
// written before versioning existed. Left unwritten, the stamp lives
|
||||
// only in memory and is redone on every launch, and a migration that is
|
||||
// not idempotent would compound.
|
||||
if (result.changed)
|
||||
persistTimer.restart();
|
||||
|
||||
root.revision++;
|
||||
root.loaded = true;
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: preferencesFile
|
||||
|
||||
path: Quickshell.stateDir + "/panama-settings.json"
|
||||
path: root.path
|
||||
blockLoading: true
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
|
||||
JsonAdapter {
|
||||
id: values
|
||||
|
||||
property bool use24Hour: false
|
||||
property bool showSeconds: true
|
||||
property bool showWeekday: true
|
||||
property bool showCpu: true
|
||||
property bool showMemory: true
|
||||
property bool showGpu: true
|
||||
property bool dockAutohide: true
|
||||
property int dockRevealDelayMs: 0
|
||||
property int dockHideDelayMs: 250
|
||||
property int focusDurationMinutes: 45
|
||||
property bool autoHdr: true
|
||||
property int vrrPolicy: 3
|
||||
property int directScanoutPolicy: 2
|
||||
property bool nightLightEnabled: false
|
||||
property bool nightLightAutomatic: false
|
||||
property int nightLightTemperature: 3500
|
||||
property string lastPage: "home"
|
||||
}
|
||||
onLoaded: root.load()
|
||||
// No file yet is the normal first-run case, not an error.
|
||||
onLoadFailed: root.load()
|
||||
}
|
||||
|
||||
// Listen after the adapter has been constructed instead of writing from
|
||||
// FileView.onAdapterUpdated. The latter also fires for default-property
|
||||
// initialization, which can overwrite a valid file before it is loaded.
|
||||
Connections {
|
||||
target: values
|
||||
function onUse24HourChanged(): void { persistTimer.restart(); }
|
||||
function onShowSecondsChanged(): void { persistTimer.restart(); }
|
||||
function onShowWeekdayChanged(): void { persistTimer.restart(); }
|
||||
function onShowCpuChanged(): void { persistTimer.restart(); }
|
||||
function onShowMemoryChanged(): void { persistTimer.restart(); }
|
||||
function onShowGpuChanged(): void { persistTimer.restart(); }
|
||||
function onDockAutohideChanged(): void { persistTimer.restart(); }
|
||||
function onDockRevealDelayMsChanged(): void { persistTimer.restart(); }
|
||||
function onDockHideDelayMsChanged(): void { persistTimer.restart(); }
|
||||
function onFocusDurationMinutesChanged(): void { persistTimer.restart(); }
|
||||
function onAutoHdrChanged(): void { persistTimer.restart(); }
|
||||
function onVrrPolicyChanged(): void { persistTimer.restart(); }
|
||||
function onDirectScanoutPolicyChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightEnabledChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightAutomaticChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightTemperatureChanged(): void { persistTimer.restart(); }
|
||||
function onLastPageChanged(): void { persistTimer.restart(); }
|
||||
Component.onCompleted: {
|
||||
migration.adopt();
|
||||
root.load();
|
||||
}
|
||||
|
||||
// Singleton construction can happen after FileView's preload phase when a
|
||||
// test or a lazy page first references it. An explicit reload makes the
|
||||
// durable state authoritative in that case as well as in the main shell.
|
||||
Component.onCompleted: preferencesFile.reload()
|
||||
|
||||
// Coalesce a group of UI changes into one atomic write. Writing from the
|
||||
// adapter's change signal itself can race a second property assignment and
|
||||
// reload the older value before the batch has finished.
|
||||
// Coalesce a burst of changes into one atomic write. Writing on every
|
||||
// assignment races a second assignment and can reload the older value.
|
||||
Timer {
|
||||
id: persistTimer
|
||||
interval: 0
|
||||
onTriggered: preferencesFile.writeAdapter()
|
||||
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
|
||||
}
|
||||
|
||||
function resetDesktopDefaults(): void {
|
||||
values.use24Hour = false;
|
||||
values.showSeconds = true;
|
||||
values.showWeekday = true;
|
||||
values.showCpu = true;
|
||||
values.showMemory = true;
|
||||
values.showGpu = true;
|
||||
values.dockAutohide = true;
|
||||
values.dockRevealDelayMs = 0;
|
||||
values.dockHideDelayMs = 250;
|
||||
values.focusDurationMinutes = 45;
|
||||
values.autoHdr = true;
|
||||
values.vrrPolicy = 3;
|
||||
values.directScanoutPolicy = 2;
|
||||
values.nightLightEnabled = false;
|
||||
values.nightLightAutomatic = false;
|
||||
values.nightLightTemperature = 3500;
|
||||
values.lastPage = "home";
|
||||
// One-time move from the pre-Stage-1 location inside Quickshell's state
|
||||
// directory. Reads the old file only when the new one does not exist yet, so
|
||||
// it can never overwrite newer settings, and never deletes the original.
|
||||
QtObject {
|
||||
id: migration
|
||||
|
||||
function adopt(): void {
|
||||
if (preferencesFile.text())
|
||||
return;
|
||||
try {
|
||||
const legacy = legacyFile.text();
|
||||
if (legacy && legacy.trim().length > 0)
|
||||
preferencesFile.setText(legacy);
|
||||
} catch (error) {
|
||||
// Nothing to migrate.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: legacyFile
|
||||
|
||||
path: Quickshell.stateDir + "/panama-settings.json"
|
||||
blockLoading: true
|
||||
printErrors: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,121 @@
|
||||
pragma Singleton
|
||||
|
||||
// Versioned upgrades for the settings file.
|
||||
//
|
||||
// The schema is the single source of truth for what a setting IS, but it cannot
|
||||
// describe what a setting USED to be. Renaming a key, changing its units, or
|
||||
// splitting one setting into two all leave a stored value that the new schema
|
||||
// does not recognise -- and an unrecognised key is silently carried through
|
||||
// untouched, so the user's choice simply stops taking effect with nothing to
|
||||
// say why. That is the failure this exists to prevent.
|
||||
//
|
||||
// HOW IT WORKS
|
||||
//
|
||||
// settings.json carries a schemaVersion. On load, every migration with a
|
||||
// version ABOVE the stored one runs in order, then the file is stamped with
|
||||
// `current`. A file with no schemaVersion at all is a file written before this
|
||||
// existed; it is stamped at `baseline` and NOT migrated, because those
|
||||
// migrations were never written for it.
|
||||
//
|
||||
// WRITING ONE
|
||||
//
|
||||
// { version: 2, describe: "rename dockDelay to dockHideDelayMs",
|
||||
// migrate: values => { ... return values; } }
|
||||
//
|
||||
// Rules that make this safe to run against a real user's file:
|
||||
//
|
||||
// * migrate() receives the whole values object and returns it. Mutating and
|
||||
// returning the same object is fine.
|
||||
// * NEVER delete a key you are not replacing. Unknown keys are deliberately
|
||||
// preserved so that rolling back to an older Panama does not discard a
|
||||
// newer version's settings, and a migration is the one place that promise
|
||||
// could quietly be broken.
|
||||
// * A migration must tolerate its input being absent or the wrong type. It
|
||||
// runs against files written by every previous version, including ones
|
||||
// that were hand-edited.
|
||||
// * Migrations never run twice: the stored version only moves forward.
|
||||
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
// What a file written today is stamped with. Bump this when adding a
|
||||
// migration, to the version of the migration you added.
|
||||
readonly property int current: 1
|
||||
|
||||
// Files predating versioning are stamped here without being migrated.
|
||||
readonly property int baseline: 1
|
||||
|
||||
readonly property string versionKey: "schemaVersion"
|
||||
|
||||
// Ordered by version. Empty is the correct state until the first breaking
|
||||
// schema change -- this exists so that change is a routine edit rather than
|
||||
// an emergency.
|
||||
readonly property var steps: []
|
||||
|
||||
function storedVersion(values: var): int {
|
||||
const raw = values ? values[root.versionKey] : undefined;
|
||||
return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0;
|
||||
}
|
||||
|
||||
// Returns { values, migrated, changed, from, to, applied }.
|
||||
//
|
||||
// `applied` names each step that ran, so the caller can log something
|
||||
// meaningful rather than "settings changed somehow". `changed` is the one
|
||||
// the caller should write on: stamping a pre-versioning file changes it
|
||||
// without running any step, and left unwritten the stamp would live only in
|
||||
// memory and be redone on every launch.
|
||||
function apply(values: var): var {
|
||||
return root.applyWith(values, root.steps, root.current, root.baseline);
|
||||
}
|
||||
|
||||
// The same logic with the step list injected, so the machinery can be
|
||||
// tested against fixture migrations. The real list is empty until the first
|
||||
// breaking schema change, and a mechanism that has never run against a
|
||||
// failing step is not one to find out about during an upgrade.
|
||||
function applyWith(values: var, steps: var, current: int, baseline: int): var {
|
||||
const safe = (values && typeof values === "object") ? values : {};
|
||||
const from = root.storedVersion(safe);
|
||||
|
||||
// No version: written before versioning existed. Stamp it and stop.
|
||||
// Running the migration list against it would apply upgrades designed
|
||||
// for schemas this file never had.
|
||||
if (from === 0) {
|
||||
safe[root.versionKey] = baseline;
|
||||
return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] };
|
||||
}
|
||||
|
||||
// A file from a NEWER Panama. Left completely alone: downgrading its
|
||||
// keys is not something this can do correctly, and unknown keys are
|
||||
// already preserved, so the older build simply ignores what it does not
|
||||
// understand.
|
||||
if (from > current)
|
||||
return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] };
|
||||
|
||||
const applied = [];
|
||||
let working = safe;
|
||||
for (const step of steps) {
|
||||
if (step.version <= from || step.version > current)
|
||||
continue;
|
||||
try {
|
||||
const result = step.migrate(working);
|
||||
if (result && typeof result === "object")
|
||||
working = result;
|
||||
applied.push(step.version + ": " + step.describe);
|
||||
} catch (error) {
|
||||
// One bad migration must not cost the user every setting. Stop
|
||||
// at the last good version so the next launch retries from
|
||||
// here rather than skipping the failed step forever.
|
||||
console.warn("Migrations: step", step.version, "failed:", error);
|
||||
working[root.versionKey] = step.version - 1;
|
||||
return { values: working, migrated: applied.length > 0, changed: applied.length > 0,
|
||||
from: from, to: step.version - 1, applied: applied };
|
||||
}
|
||||
}
|
||||
|
||||
working[root.versionKey] = current;
|
||||
return { values: working, migrated: applied.length > 0, changed: from !== current,
|
||||
from: from, to: current, applied: applied };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
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: "Matches the display's refresh rate to what is on screen",
|
||||
// All four the compositor publishes, rather than the two that were
|
||||
// here. Always-on VRR is a legitimate choice on a panel that
|
||||
// handles it well, and it was simply unreachable -- as was
|
||||
// fullscreen-only, which is what someone wanting VRR for video
|
||||
// rather than games wants.
|
||||
options: [
|
||||
{ value: 0, label: "Off",
|
||||
detail: "The display runs at a fixed refresh rate" },
|
||||
{ value: 1, label: "Always on",
|
||||
detail: "Best on panels that handle low refresh rates without flicker" },
|
||||
{ value: 2, label: "Fullscreen only",
|
||||
detail: "Any fullscreen window, including video" },
|
||||
{ value: 3, label: "Fullscreen games",
|
||||
detail: "Only fullscreen games, which is the safest default" }
|
||||
],
|
||||
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",
|
||||
detail: "Everything goes through the compositor" },
|
||||
{ value: 1, label: "Always on",
|
||||
detail: "Forced rather than decided per surface; can drop frames on some drivers" },
|
||||
{ value: 2, label: "Automatic",
|
||||
detail: "The compositor decides per surface, which is the safe default" }
|
||||
],
|
||||
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: "keyboardVariant", type: "string", def: "", group: "input",
|
||||
// Same shape as the layout list and for the same reason: this is
|
||||
// serialised into an hl.config string.
|
||||
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
|
||||
label: "Layout variant",
|
||||
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
|
||||
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
||||
},
|
||||
{
|
||||
key: "keyboardOptions", type: "string", def: "", group: "input",
|
||||
// XKB option names are colon-separated pairs in a comma-separated
|
||||
// list, e.g. "compose:ralt,caps:escape".
|
||||
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
||||
label: "Keyboard options",
|
||||
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
|
||||
hypr: { path: ["input", "kb_options"], option: "input:kb_options", 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: "Pointer focus",
|
||||
detail: "What moving the pointer does to which window is focused",
|
||||
// These labels were wrong, and wrong in the worst way: value 1 was
|
||||
// shown as "Click to focus" while Hyprland's 1 means the opposite.
|
||||
// The compositor publishes the authoritative mapping itself --
|
||||
// `hyprctl descriptions` gives
|
||||
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
|
||||
// -- so a desktop labelled "Click to focus" was in fact following
|
||||
// the pointer, and the way to actually get click-to-focus was to
|
||||
// choose "Never". Value 3 was missing entirely.
|
||||
//
|
||||
// enum-hypr-map-contract now pins every mapped enum against that
|
||||
// published map, so this cannot drift again.
|
||||
options: [
|
||||
{ value: 0, label: "Click to focus",
|
||||
detail: "Moving the pointer never changes focus" },
|
||||
{ value: 1, label: "Focus follows pointer",
|
||||
detail: "The window under the pointer takes focus as you move" },
|
||||
{ value: 2, label: "Pointer detached",
|
||||
detail: "The pointer highlights windows on its own; clicking moves keyboard focus" },
|
||||
{ value: 3, label: "Pointer fully separate",
|
||||
detail: "Clicking does not move keyboard focus at all" }
|
||||
],
|
||||
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" }
|
||||
},
|
||||
|
||||
// ── Pointer ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every `readAs` below was read back off the running compositor rather
|
||||
// than guessed. Two are not what they look like: touchpad drag lock is
|
||||
// an int with three states, not a switch, and scroll factors are floats
|
||||
// even at their default of exactly 1.
|
||||
{
|
||||
key: "naturalScroll", type: "bool", def: false, group: "pointer",
|
||||
label: "Natural scrolling",
|
||||
detail: "Content follows the direction of your fingers, as on a phone",
|
||||
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
|
||||
label: "Acceleration",
|
||||
detail: "Flat moves the pointer the same distance however fast you move",
|
||||
options: [
|
||||
{ value: "flat", label: "Flat" },
|
||||
{ value: "adaptive", label: "Adaptive" }
|
||||
],
|
||||
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
|
||||
},
|
||||
{
|
||||
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
|
||||
group: "pointer",
|
||||
label: "Scroll speed",
|
||||
detail: "Multiplies how far one notch of the wheel scrolls",
|
||||
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
|
||||
},
|
||||
{
|
||||
key: "leftHanded", type: "bool", def: false, group: "pointer",
|
||||
label: "Left-handed",
|
||||
detail: "Swap the primary and secondary buttons",
|
||||
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Touchpad ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Shown only on machines that have one. These are separate from the
|
||||
// pointer settings above because libinput keeps them separate: a mouse
|
||||
// and a touchpad on the same machine can scroll in opposite directions,
|
||||
// and usually should.
|
||||
{
|
||||
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
|
||||
label: "Tap to click",
|
||||
detail: "A tap counts as a click without pressing down",
|
||||
// The Lua config key and the hyprctl option name genuinely differ
|
||||
// here: hl.config wants input.touchpad.tap_to_click, getoption
|
||||
// answers to input:touchpad:tap-to-click. Using either spelling for
|
||||
// both fails -- a hyphen is not a Lua identifier, and the
|
||||
// underscored name is not a known option to getoption. This is what
|
||||
// the two separate fields are for.
|
||||
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
|
||||
label: "Natural scrolling",
|
||||
detail: "Content follows the direction of your fingers",
|
||||
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
|
||||
label: "Disable while typing",
|
||||
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
|
||||
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
|
||||
group: "touchpad",
|
||||
label: "Scroll speed",
|
||||
detail: "Multiplies how far a two-finger scroll travels",
|
||||
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
|
||||
},
|
||||
{
|
||||
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
|
||||
label: "Drag lock",
|
||||
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
|
||||
// An int with three states rather than a switch, which is why this
|
||||
// is an enum: reported as `int` by getoption, not `bool`.
|
||||
options: [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 1, label: "On" },
|
||||
{ value: 2, label: "On, until you tap again" }
|
||||
],
|
||||
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
|
||||
label: "Middle-click by pressing both buttons",
|
||||
detail: "Pressing left and right together acts as a middle click",
|
||||
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Multitasking ────────────────────────────────────────────────────
|
||||
//
|
||||
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
|
||||
// described the first two of these as read-only facts ("Layout:
|
||||
// Tiling"), which was never true -- they are ordinary settings that
|
||||
// happened not to have controls.
|
||||
//
|
||||
// Defaults here are Panama's shipped values from hypr/looks.lua, not
|
||||
// Hyprland's own, so restoring defaults returns the desktop to how it
|
||||
// ships rather than to how Hyprland would behave with no config.
|
||||
{
|
||||
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
|
||||
label: "Tiling layout",
|
||||
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
|
||||
options: [
|
||||
{ value: "dwindle", label: "Dwindle" },
|
||||
{ value: "master", label: "Master and stack" }
|
||||
],
|
||||
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
|
||||
},
|
||||
{
|
||||
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
|
||||
label: "Keep split direction",
|
||||
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
|
||||
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
|
||||
label: "New windows open",
|
||||
detail: "Where a new window lands relative to the one that was focused",
|
||||
options: [
|
||||
{ value: 0, label: "Where the pointer is" },
|
||||
{ value: 1, label: "Always left or above" },
|
||||
{ value: 2, label: "Always right or below" }
|
||||
],
|
||||
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
|
||||
label: "Snap floating windows",
|
||||
detail: "Floating windows stick to screen edges and to each other as you drag them",
|
||||
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
|
||||
label: "Switch back and forth",
|
||||
detail: "Selecting the workspace you are already on returns you to the previous one",
|
||||
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
|
||||
label: "Wrap around at the ends",
|
||||
detail: "Moving past the last workspace continues from the first",
|
||||
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
|
||||
label: "Let applications take focus",
|
||||
detail: "An application asking for attention is switched to, rather than only highlighted",
|
||||
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
||||
label: "Pointer changes active display",
|
||||
detail: "Moving the pointer to another display makes it the active one",
|
||||
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Accessibility ───────────────────────────────────────────────────
|
||||
//
|
||||
// Only what Hyprland can actually deliver. GNOME's sticky keys, slow
|
||||
// keys, bounce keys and mouse keys are AccessX, an X11 server feature:
|
||||
// XKB under Wayland has no accessx option group at all (verified
|
||||
// against evdev.lst), and Hyprland does not implement it. The
|
||||
// compositor will happily STORE "accessx:enable" as a keyboard option
|
||||
// and nothing will ever act on it, which is exactly the kind of switch
|
||||
// this app refuses to ship.
|
||||
{
|
||||
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
|
||||
group: "accessibility",
|
||||
label: "Magnifier",
|
||||
detail: "Magnifies the screen around the pointer. 1.0 is off",
|
||||
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
|
||||
},
|
||||
{
|
||||
key: "magnifierRigid", type: "bool", def: false, group: "accessibility",
|
||||
label: "Magnifier follows in steps",
|
||||
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
||||
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "dimInactive", type: "bool", def: false, group: "accessibility",
|
||||
label: "Dim inactive windows",
|
||||
detail: "Darkens every window except the focused one, so the active window is unmistakable",
|
||||
hypr: { path: ["decoration", "dim_inactive"], option: "decoration:dim_inactive", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "dimStrength", type: "real", def: 0.5, min: 0.05, max: 0.9, step: 0.05,
|
||||
group: "accessibility",
|
||||
label: "Dim amount",
|
||||
detail: "How much darker unfocused windows are",
|
||||
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", 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"
|
||||
},
|
||||
|
||||
// ── Night light schedule ────────────────────────────────────────────
|
||||
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
|
||||
// is normal here and is what the shipped values do: on at 17:00, off at
|
||||
// 10:00 the following morning.
|
||||
{
|
||||
key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
|
||||
group: "nightLight",
|
||||
label: "Turns on at",
|
||||
detail: "Only used when Night Light follows a schedule"
|
||||
},
|
||||
{
|
||||
key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
|
||||
group: "nightLight",
|
||||
label: "Turns off at",
|
||||
detail: "A time earlier than the start simply means the next morning"
|
||||
},
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────
|
||||
// Light is Tokyo Night Day, the official light variant, rather than a
|
||||
// palette invented to merely not be dark. Both share the same hues at
|
||||
// different lightness, which is what keeps the Prism identity intact
|
||||
// across the switch.
|
||||
//
|
||||
// services/ColorScheme.qml pushes the choice to GTK and to the
|
||||
// compositor's border colours, because an application toolbar or a
|
||||
// window border still wearing the other scheme is more jarring than
|
||||
// either scheme on its own.
|
||||
{
|
||||
key: "colorScheme", type: "enum", def: "dark", group: "appearance",
|
||||
label: "Appearance",
|
||||
detail: "Light and dark share one identity, not two themes",
|
||||
options: [
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "light", label: "Light" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Typography ──────────────────────────────────────────────────────
|
||||
// The single largest thing in this desktop that used to be changeable
|
||||
// only by editing Theme.qml.
|
||||
//
|
||||
// interfaceFont is every piece of text a person reads. iconFont is used
|
||||
// ONLY to draw glyphs -- workspace pills, the status cluster, search
|
||||
// icons -- so it must carry Nerd Font glyphs; a plain monospace family
|
||||
// there replaces every icon in the shell with tofu, which is why the
|
||||
// picker offers them as a separate, filtered list.
|
||||
{
|
||||
key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||
// A family name, matched by fontconfig. Constrained because it also
|
||||
// reaches hl.config as a string in hypr/looks.lua.
|
||||
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
|
||||
label: "Interface font",
|
||||
detail: "Used for every piece of text in the shell"
|
||||
},
|
||||
{
|
||||
key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
|
||||
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
|
||||
label: "Icon font",
|
||||
detail: "Draws the shell's glyphs, so it must be a Nerd Font"
|
||||
},
|
||||
{
|
||||
key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1,
|
||||
unit: "px", group: "typography",
|
||||
label: "Interface text size",
|
||||
detail: "The base size the rest of the shell's type scales from"
|
||||
},
|
||||
|
||||
// ── 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"
|
||||
},
|
||||
|
||||
// ── Which GPU the vitals readout tracks ─────────────────────────────
|
||||
// A sysfs path rather than a card number, because the number is neither
|
||||
// stable across machines nor meaningful. Constrained to the one shape
|
||||
// that can be read for utilisation; VitalsWidget hides itself when the
|
||||
// path is unreadable, so a stale value degrades to no readout rather
|
||||
// than a wrong one.
|
||||
{
|
||||
key: "gpuBusyPath", type: "string",
|
||||
def: "/sys/class/drm/card1/device/gpu_busy_percent",
|
||||
group: "vitals", internal: true,
|
||||
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
|
||||
label: "Graphics device",
|
||||
detail: "Which GPU the graphics readout in the bar measures"
|
||||
},
|
||||
|
||||
// ── Weather location ────────────────────────────────────────────────
|
||||
// Coordinates rather than a place name, because that is what Open-Meteo
|
||||
// takes and it needs no API key. weatherLocation is only the label shown
|
||||
// in the UI; it is never sent anywhere, so it can say whatever makes the
|
||||
// reading recognisable.
|
||||
{
|
||||
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
label: "Latitude",
|
||||
detail: "Set by choosing a location"
|
||||
},
|
||||
{
|
||||
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
label: "Longitude",
|
||||
detail: "Set by choosing a location"
|
||||
},
|
||||
{
|
||||
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
|
||||
internal: true,
|
||||
// Display only -- never sent to the weather service.
|
||||
pattern: "^[^\\n]{1,64}$",
|
||||
label: "Weather location",
|
||||
detail: "The place the weather reading is for"
|
||||
},
|
||||
|
||||
// ── 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, with one
|
||||
// deliberate substitution: Panama Settings takes the first slot rather
|
||||
// than GNOME Settings. Panama now covers what GNOME Settings did for
|
||||
// this desktop and delegates the remainder to it by name, so pinning
|
||||
// the thing it delegates TO put the fallback in front of the real one.
|
||||
// GNOME Settings stays installed and searchable in the launcher.
|
||||
{
|
||||
key: "dockPinned", type: "json", group: "dock",
|
||||
label: "Pinned applications",
|
||||
detail: "Applications that stay in the Dock whether or not they are running",
|
||||
def: [
|
||||
"panama-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"
|
||||
},
|
||||
|
||||
// ── Per-application notification rules ──────────────────────────────
|
||||
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
|
||||
//
|
||||
// Absent means "no rule", which is not the same as a rule that allows
|
||||
// everything: a new application must be able to notify without needing
|
||||
// an entry written for it first. services/Notifs.qml treats a missing
|
||||
// entry as permissive and Do Not Disturb remains an override on top,
|
||||
// rather than being duplicated per application.
|
||||
{
|
||||
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
||||
internal: true,
|
||||
label: "Application notification rules",
|
||||
detail: "Per-application notification and lock-screen visibility preferences"
|
||||
},
|
||||
|
||||
// ── 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,90 +15,73 @@ Singleton {
|
||||
|
||||
// ── Clock ───────────────────────────────────────────────────────────────
|
||||
// Carried over from GNOME: 12-hour, weekday + date shown, seconds on.
|
||||
readonly property bool use24Hour: DesktopPreferences.use24Hour
|
||||
readonly property bool showSeconds: DesktopPreferences.showSeconds
|
||||
readonly property bool showWeekday: DesktopPreferences.showWeekday
|
||||
readonly property bool use24Hour: DesktopPreferences.get("use24Hour")
|
||||
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
|
||||
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────────
|
||||
// Coordinates taken from the GNOME night-light setting, which had already
|
||||
// resolved the location. Uses Open-Meteo, which needs no API key.
|
||||
readonly property real latitude: 27.7375
|
||||
readonly property real longitude: -82.6861
|
||||
readonly property real latitude: DesktopPreferences.get("weatherLatitude")
|
||||
readonly property real longitude: DesktopPreferences.get("weatherLongitude")
|
||||
// Open-Meteo returns coordinates but no friendly place name. Keep the
|
||||
// label deliberately general rather than exposing precise coordinates in
|
||||
// the UI or guessing at a city from them.
|
||||
readonly property string weatherLocation: "Local weather"
|
||||
readonly property string temperatureUnit: "fahrenheit"
|
||||
readonly property int weatherRefreshMinutes: 20
|
||||
readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
|
||||
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
|
||||
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
|
||||
|
||||
// ── Vitals ──────────────────────────────────────────────────────────────
|
||||
// The GNOME Vitals extension showed processor usage, memory usage and GPU
|
||||
// usage, in that order. Same here.
|
||||
readonly property int vitalsIntervalMs: 2000
|
||||
readonly property bool showCpu: DesktopPreferences.showCpu
|
||||
readonly property bool showMemory: DesktopPreferences.showMemory
|
||||
readonly property bool showGpu: DesktopPreferences.showGpu
|
||||
readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
|
||||
readonly property bool showCpu: DesktopPreferences.get("showCpu")
|
||||
readonly property bool showMemory: DesktopPreferences.get("showMemory")
|
||||
readonly property bool showGpu: DesktopPreferences.get("showGpu")
|
||||
|
||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||
// widget hides itself if the path is missing rather than showing zeros.
|
||||
readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent"
|
||||
readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath")
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────────
|
||||
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
|
||||
readonly property int nightLightTemperature: DesktopPreferences.nightLightTemperature
|
||||
readonly property real nightLightFrom: 17.0
|
||||
readonly property real nightLightTo: 10.0
|
||||
readonly property bool nightLightEnabledByDefault: DesktopPreferences.nightLightEnabled
|
||||
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
|
||||
readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
|
||||
readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
|
||||
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────────
|
||||
readonly property int notificationTimeoutMs: 5000
|
||||
readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire
|
||||
readonly property int notificationHistoryLimit: 100
|
||||
readonly property int maxVisibleToasts: 4
|
||||
readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
|
||||
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
||||
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
||||
|
||||
// ── Focus ──────────────────────────────────────────────────────────────
|
||||
// One deliberate default rather than a preset picker: quick settings and
|
||||
// the keyboard shortcut should start a useful session in one action.
|
||||
readonly property int focusDurationMinutes: DesktopPreferences.focusDurationMinutes
|
||||
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────────
|
||||
// Pinned apps, in order, taken from the GNOME dash favourites.
|
||||
readonly property list<string> 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"
|
||||
]
|
||||
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||
|
||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||
// when any window would overlap it, and comes back on hover.
|
||||
readonly property bool dockAutohide: DesktopPreferences.dockAutohide
|
||||
readonly property bool dockAutohide: DesktopPreferences.get("dockAutohide")
|
||||
|
||||
// 0: reveal the instant the pointer reaches the bottom edge. A reveal delay
|
||||
// is indistinguishable from lag, because the user has already committed to
|
||||
// the gesture by the time the strip is hit.
|
||||
readonly property int dockRevealDelayMs: DesktopPreferences.dockRevealDelayMs
|
||||
readonly property int dockRevealDelayMs: DesktopPreferences.get("dockRevealDelayMs")
|
||||
|
||||
// Hiding keeps a delay, so brushing past the bottom edge or crossing the
|
||||
// gap between two icons doesn't make the dock flicker.
|
||||
readonly property int dockHideDelayMs: DesktopPreferences.dockHideDelayMs
|
||||
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
|
||||
|
||||
// ── Capture ─────────────────────────────────────────────────────────────
|
||||
readonly property string screenshotDir: "Pictures/Screenshots"
|
||||
readonly property string recordingDir: "Videos/Recordings"
|
||||
readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
|
||||
readonly property string recordingDir: DesktopPreferences.get("recordingDir")
|
||||
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
|
||||
// cost CPU while gaming.
|
||||
readonly property string recorderArgs: "-c h264_vaapi -d /dev/dri/renderD128"
|
||||
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
|
||||
}
|
||||
|
||||
@@ -18,36 +18,47 @@ import QtQuick
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────────
|
||||
// Tokyo Night ships an official light variant (Day), so light mode is that
|
||||
// rather than a palette invented to merely not be dark. The two share the
|
||||
// same hues at different lightness, which is what lets the Prism identity
|
||||
// survive the switch: blue still leads into orchid, it is simply a darker
|
||||
// blue on a lighter ground.
|
||||
//
|
||||
// Every token below is a binding on this, so flipping it repaints the whole
|
||||
// shell without anything needing to know it happened.
|
||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||
|
||||
// ── Palette ─────────────────────────────────────────────────────────────
|
||||
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
|
||||
readonly property color bg: "#222436"
|
||||
readonly property color bgDark: "#1e2030"
|
||||
readonly property color bgHighlight: "#2f334d"
|
||||
readonly property color bgPanel: "#2e2f3d" // dock glass surface
|
||||
readonly property color bgPopover: "#21212f" // Openbar submenu background
|
||||
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
|
||||
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
|
||||
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
|
||||
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
|
||||
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
|
||||
|
||||
readonly property color fg: "#c8d3f5"
|
||||
readonly property color fgDim: "#828bb8"
|
||||
readonly property color fgMuted: "#636da6"
|
||||
readonly property color gutter: "#3b4261"
|
||||
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
|
||||
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
|
||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||
|
||||
// The pair. `accent` is the primary and carries every state meaning
|
||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
||||
// theme — it never appears alone, only as the far end of a gradient. That
|
||||
// restraint is the whole point: the two colours meeting is the signature,
|
||||
// so the pink stops being special the moment it's used as a flat fill.
|
||||
readonly property color accent: "#82aaff" // blue
|
||||
readonly property color accentSecondary: "#b172b0" // orchid, from tmux
|
||||
readonly property color accentAlt: "#65bcff" // blue1, a lighter blue
|
||||
readonly property color cyan: "#86e1fc"
|
||||
readonly property color teal: "#4fd6be"
|
||||
readonly property color green: "#c3e88d"
|
||||
readonly property color yellow: "#ffc777"
|
||||
readonly property color orange: "#ff966c"
|
||||
readonly property color red: "#ff757f"
|
||||
readonly property color redDeep: "#c53b53"
|
||||
readonly property color magenta: "#c099ff"
|
||||
readonly property color pink: "#fca7ea"
|
||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||
readonly property color green: root.dark ? "#c3e88d" : "#587539"
|
||||
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
|
||||
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
|
||||
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
|
||||
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
|
||||
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
|
||||
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
|
||||
|
||||
// Semantic aliases — prefer these in widgets so intent survives a repaint.
|
||||
readonly property color ok: green
|
||||
@@ -59,11 +70,14 @@ Singleton {
|
||||
// The dock sits on compositor blur, so its fill is deliberately very
|
||||
// translucent. Popovers are near-opaque because they carry text that must
|
||||
// stay legible. The bar itself has no surface or alpha token.
|
||||
readonly property real dockAlpha: 0.34
|
||||
readonly property real popoverAlpha: 0.92
|
||||
readonly property real overlayAlpha: 0.55
|
||||
readonly property real hoverAlpha: 0.14
|
||||
readonly property real activeAlpha: 0.24
|
||||
// Light surfaces need more opacity for the same sense of a solid panel: the
|
||||
// same 0.34 that reads as glass over a dark desktop reads as haze over a
|
||||
// light one, and text on it stops being legible.
|
||||
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
|
||||
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
|
||||
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
||||
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
||||
|
||||
// ── Geometry ────────────────────────────────────────────────────────────
|
||||
readonly property int barHeight: 36
|
||||
@@ -78,6 +92,8 @@ Singleton {
|
||||
readonly property int popoverRadius: 18
|
||||
readonly property int popoverPadding: 14
|
||||
readonly property int popoverWidth: 380
|
||||
readonly property int controlCenterWidth: 430
|
||||
readonly property int controlCenterTopGap: 2
|
||||
|
||||
readonly property int cardRadius: 12
|
||||
readonly property int pillRadius: 999
|
||||
@@ -88,12 +104,12 @@ Singleton {
|
||||
// Adwaita Sans for everything the user reads as text. No exceptions: a
|
||||
// monospaced clock or percentage reads as a terminal readout pasted into a
|
||||
// UI, which is the opposite of the intent here.
|
||||
readonly property string fontFamily: "Adwaita Sans"
|
||||
readonly property string fontFamily: DesktopPreferences.get("interfaceFont")
|
||||
|
||||
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
|
||||
// pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||||
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
|
||||
readonly property string fontMono: "VictorMono Nerd Font"
|
||||
readonly property string fontMono: DesktopPreferences.get("iconFont")
|
||||
|
||||
// Apply to any text whose digits change in place — clocks, percentages,
|
||||
// elapsed timers, dimension readouts. Tabular figures all share one
|
||||
@@ -105,10 +121,13 @@ Singleton {
|
||||
readonly property var tabularFigures: ({
|
||||
"tnum": 1
|
||||
})
|
||||
readonly property int fontSize: 13
|
||||
readonly property int fontSizeSmall: 11
|
||||
readonly property int fontSizeLarge: 16
|
||||
readonly property int fontSizeTitle: 20
|
||||
// The four sizes are a scale, not four independent numbers: they move
|
||||
// together so the relationship between body, caption, heading and title
|
||||
// survives a change to the base rather than drifting apart.
|
||||
readonly property int fontSize: DesktopPreferences.get("interfaceFontSize")
|
||||
readonly property int fontSizeSmall: Math.max(9, root.fontSize - 2)
|
||||
readonly property int fontSizeLarge: root.fontSize + 3
|
||||
readonly property int fontSizeTitle: root.fontSize + 7
|
||||
|
||||
// ── Motion ──────────────────────────────────────────────────────────────
|
||||
// Event-driven only. Nothing in this shell animates while idle — no pulse,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
module qs.config
|
||||
singleton DesktopPreferences 1.0 DesktopPreferences.qml
|
||||
singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
||||
singleton HomePreferences 1.0 HomePreferences.qml
|
||||
singleton Settings 1.0 Settings.qml
|
||||
singleton Theme 1.0 Theme.qml
|
||||
singleton Migrations 1.0 Migrations.qml
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "connectivity-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
|
||||
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
|
||||
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
|
||||
networks: Connectivity.networks.length,
|
||||
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
|
||||
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
|
||||
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
|
||||
adapter: Connectivity.adapter ? true : false,
|
||||
btDevices: Connectivity.bluetoothDevices.length
|
||||
});
|
||||
}
|
||||
|
||||
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
|
||||
function setActive(on: bool): void { Connectivity.active = on; }
|
||||
}
|
||||
}
|
||||
@@ -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,35 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
ShellRoot {
|
||||
// DesktopEntries populates asynchronously. Reading `applications.values`
|
||||
// here rather than calling byId() blind is the documented way to wait for
|
||||
// it -- and is exactly the trap noted in modules/settings/README.md.
|
||||
readonly property var ids: {
|
||||
const out = {};
|
||||
for (const entry of DesktopEntries.applications.values)
|
||||
out[entry.id] = entry;
|
||||
return out;
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "dock-pin-test"
|
||||
|
||||
function count(): int { return DesktopEntries.applications.values.length; }
|
||||
|
||||
function resolve(): string {
|
||||
const pinned = DesktopPreferences.get("dockPinned");
|
||||
const missing = [];
|
||||
let first = null;
|
||||
for (const id of pinned) {
|
||||
const entry = DesktopEntries.byId(id);
|
||||
if (!entry) missing.push(id);
|
||||
if (first === null)
|
||||
first = { id: id, found: !!entry, name: entry ? entry.name : "", icon: entry ? entry.icon : "" };
|
||||
}
|
||||
return JSON.stringify({ total: pinned.length, first: first, missing: missing });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
// The production singleton still performs its delayed startup scan. The
|
||||
// harness turns it off before its 2200 ms deadline so each fixture drives
|
||||
// only the state transition it is asserting.
|
||||
Component.onCompleted: Health.startupScanEnabled = false
|
||||
|
||||
IpcHandler {
|
||||
target: "health-test"
|
||||
|
||||
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
|
||||
function queue(): void { Health.refresh(); Health.refresh(); }
|
||||
function status(): string { return JSON.stringify(Health.diagnostics()); }
|
||||
function repair(id: string): bool { return Health.repair(id, false); }
|
||||
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
|
||||
function copy(): bool { return Health.copyReport(); }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
|
||||
// case. Run by tests/quickshell/migrations-contract.sh.
|
||||
//
|
||||
// Fixture steps rather than the real list: the real one is empty until the
|
||||
// first breaking schema change, and a mechanism that has never been run against
|
||||
// a failing step is not one to discover the behaviour of during an upgrade.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
ShellRoot {
|
||||
Component.onCompleted: {
|
||||
const steps = [
|
||||
{ version: 2, describe: "add b", migrate: v => { v.b = (v.a ?? 0) + 1; return v; } },
|
||||
{ version: 3, describe: "add c", migrate: v => { v.c = "three"; return v; } },
|
||||
{ version: 4, describe: "explode", migrate: v => { throw new Error("boom"); } }
|
||||
];
|
||||
const results = {};
|
||||
|
||||
// No version at all: a file written before versioning. Stamped, never
|
||||
// migrated.
|
||||
let r = Migrations.applyWith({ a: 1 }, steps, 3, 1);
|
||||
results.unversioned = { v: r.values.schemaVersion, migrated: r.migrated,
|
||||
changed: r.changed, untouched: r.values.b === undefined };
|
||||
|
||||
// Older file: every step above its version runs, in order.
|
||||
r = Migrations.applyWith({ a: 1, schemaVersion: 1 }, steps, 3, 1);
|
||||
results.upgrade = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
|
||||
count: r.applied.length, migrated: r.migrated };
|
||||
|
||||
// Already current: nothing runs.
|
||||
r = Migrations.applyWith({ schemaVersion: 3, keep: "me" }, steps, 3, 1);
|
||||
results.current = { v: r.values.schemaVersion, migrated: r.migrated,
|
||||
kept: r.values.keep === "me", b: r.values.b === undefined };
|
||||
|
||||
// From the future: left completely alone, including its unknown keys.
|
||||
r = Migrations.applyWith({ schemaVersion: 9, futureKey: "x" }, steps, 3, 1);
|
||||
results.future = { v: r.values.schemaVersion, migrated: r.migrated,
|
||||
changed: r.changed, kept: r.values.futureKey === "x" };
|
||||
|
||||
// A failing step stops at the last good version rather than losing the
|
||||
// file or skipping past the failure forever.
|
||||
r = Migrations.applyWith({ schemaVersion: 1, a: 5 }, steps, 4, 1);
|
||||
results.failure = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
|
||||
kept: r.values.a === 5, count: r.applied.length };
|
||||
|
||||
// Unknown keys survive a migration: rolling back to an older Panama
|
||||
// must not discard a newer version's settings.
|
||||
r = Migrations.applyWith({ schemaVersion: 1, unknownFromFuture: true }, steps, 3, 1);
|
||||
results.preserved = { kept: r.values.unknownFromFuture === true };
|
||||
|
||||
console.info("PANAMA-MIGRATIONS " + JSON.stringify(results));
|
||||
Qt.callLater(() => Qt.quit());
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,10 @@ PanelWindow {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
HealthIndicator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
ActivityIndicator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// A deliberately absent-until-needed health affordance. Healthy and merely
|
||||
// unconfigured systems leave no ornament or layout residue in the bar.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal activated
|
||||
|
||||
readonly property int issueCount: Health.summary.warnings + Health.summary.errors
|
||||
readonly property color tone: Health.status === "error"
|
||||
? Theme.danger
|
||||
: (Health.status === "warning" ? Theme.warn : "transparent")
|
||||
readonly property string statusText: root.issueCount === 1
|
||||
? "1 system health issue"
|
||||
: root.issueCount + " system health issues"
|
||||
readonly property string accessibleLabel: Health.status === "error"
|
||||
? "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue requires action" : " issues require action")
|
||||
: "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue needs attention" : " issues need attention")
|
||||
readonly property string tooltipText: root.accessibleLabel
|
||||
|
||||
visible: Health.actionable
|
||||
implicitWidth: visible ? content.implicitWidth + 16 : 0
|
||||
implicitHeight: visible ? 24 : 0
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
radius: 9
|
||||
color: visible ? Theme.alpha(root.tone, 0.09) : "transparent"
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: visible ? Theme.alpha(root.tone, activeFocus ? 0.72 : 0.20) : "transparent"
|
||||
|
||||
activeFocusOnTab: visible
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.accessibleLabel
|
||||
Accessible.description: "Open System Health"
|
||||
Accessible.onPressAction: root.activated()
|
||||
|
||||
onActivated: {
|
||||
ShellState.openSettings("services");
|
||||
Health.refresh();
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: root.activated()
|
||||
Keys.onEnterPressed: root.activated()
|
||||
Keys.onSpacePressed: root.activated()
|
||||
|
||||
Row {
|
||||
id: content
|
||||
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F0ECD}" // md-shield-alert-outline
|
||||
color: root.tone
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 14
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(root.issueCount)
|
||||
color: root.tone
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: pointer
|
||||
|
||||
anchors.fill: parent
|
||||
enabled: root.visible
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.activated()
|
||||
}
|
||||
|
||||
ToolTip.visible: pointer.containsMouse && root.visible
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: root.tooltipText
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import Quickshell.Networking
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
@@ -106,4 +107,10 @@ Pill {
|
||||
glyph: "\u{F0176}" // md-coffee
|
||||
color: Theme.warn
|
||||
}
|
||||
|
||||
StatusGlyph {
|
||||
visible: KdeConnect.phoneReachable
|
||||
glyph: "\u{F03F2}" // md-cellphone-link
|
||||
color: Theme.cyan
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Compact, click-through system feedback. Repeated hardware-key presses update
|
||||
// this one surface in place, avoiding a stack of notification cards.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
property var modelData: null
|
||||
readonly property var presentation: OsdState.state
|
||||
readonly property bool belongsHere: !OsdState.monitorName || !root.modelData
|
||||
|| root.modelData.name === OsdState.monitorName
|
||||
readonly property bool requestedVisible: OsdState.active && root.belongsHere
|
||||
readonly property int horizontalPadding: 18
|
||||
readonly property int contentGap: 14
|
||||
readonly property int progressWidth: 156
|
||||
readonly property int progressLabelWidth: 48
|
||||
readonly property int messageWidth: Math.min(300, Math.max(52, Math.ceil(labelMetrics.advanceWidth)))
|
||||
readonly property int desiredWidth: root.horizontalPadding * 2 + 24 + root.contentGap
|
||||
+ (root.presentation.progress
|
||||
? root.progressWidth + root.contentGap + root.progressLabelWidth
|
||||
: root.messageWidth)
|
||||
|
||||
screen: root.modelData
|
||||
anchors.bottom: true
|
||||
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
implicitWidth: root.desiredWidth
|
||||
implicitHeight: 64
|
||||
color: "transparent"
|
||||
mask: Region {}
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-osd"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
property bool mapped: false
|
||||
visible: root.mapped
|
||||
|
||||
onRequestedVisibleChanged: {
|
||||
if (root.requestedVisible) {
|
||||
unmapTimer.stop();
|
||||
root.mapped = true;
|
||||
entrance.restart();
|
||||
} else if (root.mapped) {
|
||||
unmapTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.requestedVisible)
|
||||
root.mapped = true;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: unmapTimer
|
||||
interval: Theme.durNormal
|
||||
onTriggered: root.mapped = false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.9)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
opacity: root.requestedVisible ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
transform: Translate {
|
||||
id: slide
|
||||
y: root.requestedVisible ? 0 : 8
|
||||
Behavior on y {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: root.contentGap
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
icon: root.presentation.icon
|
||||
iconFallback: "dialog-information-symbolic"
|
||||
tint: Theme.fg
|
||||
}
|
||||
|
||||
Item {
|
||||
visible: root.presentation.progress
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: visible ? root.progressWidth : 0
|
||||
height: 8
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: Theme.alpha(Theme.fg, 0.14)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * root.presentation.ratio
|
||||
radius: height / 2
|
||||
color: Theme.accent
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.presentation.progress ? root.progressLabelWidth : root.messageWidth
|
||||
text: root.presentation.label
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
horizontalAlignment: root.presentation.progress ? Text.AlignRight : Text.AlignLeft
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextMetrics {
|
||||
id: labelMetrics
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
text: root.presentation.label
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: entrance
|
||||
NumberAnimation {
|
||||
target: surface
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: slide
|
||||
property: "y"
|
||||
from: 8
|
||||
to: 0
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function iconFor(kind, ratio) {
|
||||
var name = String(kind || "").toLowerCase();
|
||||
if (name === "volume-muted")
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (name === "volume") {
|
||||
if (ratio <= 0)
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (ratio < 0.34)
|
||||
return "audio-volume-low-symbolic";
|
||||
if (ratio < 0.67)
|
||||
return "audio-volume-medium-symbolic";
|
||||
return "audio-volume-high-symbolic";
|
||||
}
|
||||
if (name === "microphone-muted")
|
||||
return "microphone-sensitivity-muted-symbolic";
|
||||
if (name === "microphone")
|
||||
return "audio-input-microphone-symbolic";
|
||||
if (name === "brightness")
|
||||
return "display-brightness-symbolic";
|
||||
if (name === "media-play" || name === "media-playing")
|
||||
return "media-playback-start-symbolic";
|
||||
if (name === "media-pause" || name === "media-paused")
|
||||
return "media-playback-pause-symbolic";
|
||||
if (name === "media-next")
|
||||
return "media-skip-forward-symbolic";
|
||||
if (name === "media-previous")
|
||||
return "media-skip-backward-symbolic";
|
||||
if (name === "media-stop")
|
||||
return "media-playback-stop-symbolic";
|
||||
return name || "dialog-information-symbolic";
|
||||
}
|
||||
|
||||
function normalizedDuration(value) {
|
||||
var parsed = finiteNumber(value, 1400);
|
||||
return Math.max(0, Math.round(parsed));
|
||||
}
|
||||
|
||||
function progressState(kind, rawValue, rawMaximum, rawLabel, rawDuration) {
|
||||
var maximum = Math.max(1, finiteNumber(rawMaximum, 100));
|
||||
var value = clamp(finiteNumber(rawValue, 0), 0, maximum);
|
||||
var ratio = value / maximum;
|
||||
var label = String(rawLabel || "");
|
||||
if (!label)
|
||||
label = Math.round(ratio * 100) + "%";
|
||||
|
||||
return {
|
||||
kind: String(kind || ""),
|
||||
value: value,
|
||||
maximum: maximum,
|
||||
ratio: ratio,
|
||||
label: label,
|
||||
icon: iconFor(kind, ratio),
|
||||
duration: normalizedDuration(rawDuration),
|
||||
progress: true
|
||||
};
|
||||
}
|
||||
|
||||
function messageState(kind, rawLabel, rawDuration) {
|
||||
return {
|
||||
kind: String(kind || ""),
|
||||
value: 0,
|
||||
maximum: 100,
|
||||
ratio: 0,
|
||||
label: String(rawLabel || ""),
|
||||
icon: iconFor(kind, 0),
|
||||
duration: normalizedDuration(rawDuration),
|
||||
progress: false
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
clamp: clamp,
|
||||
iconFor: iconFor,
|
||||
progressState: progressState,
|
||||
messageState: messageState
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
module qs.modules.osd
|
||||
Osd 1.0 Osd.qml
|
||||
@@ -6,6 +6,7 @@ import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -16,23 +17,12 @@ Item {
|
||||
|
||||
implicitHeight: list.implicitHeight
|
||||
|
||||
readonly property var nodes: {
|
||||
return Pipewire.nodes.values.filter(n => {
|
||||
if (n.isStream)
|
||||
return false;
|
||||
// Sources have to be filtered on the type flags: !isSink also
|
||||
// matches video nodes (webcams show up here otherwise).
|
||||
return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource;
|
||||
});
|
||||
}
|
||||
readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs
|
||||
|
||||
readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource
|
||||
readonly property var current: AudioDevices.current(root.output)
|
||||
|
||||
function select(node): void {
|
||||
if (root.output)
|
||||
Pipewire.preferredDefaultAudioSink = node;
|
||||
else
|
||||
Pipewire.preferredDefaultAudioSource = node;
|
||||
AudioDevices.select(root.output, node)
|
||||
}
|
||||
|
||||
ScrollColumn {
|
||||
@@ -52,7 +42,7 @@ Item {
|
||||
implicitHeight: 38
|
||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-card-symbolic"
|
||||
label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name
|
||||
label: AudioDevices.label(nodeRow.modelData)
|
||||
selected: nodeRow.modelData === root.current
|
||||
onClicked: root.select(nodeRow.modelData)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
// Backlight slider, via brightnessctl.
|
||||
// Brightness, from whichever source this machine actually has.
|
||||
//
|
||||
// This machine drives an external DisplayPort monitor and has no backlight
|
||||
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
|
||||
// row removes itself rather than sitting there as a dead control. Probed once
|
||||
// at startup — backlight devices do not appear and disappear.
|
||||
// Two exist and they are not interchangeable:
|
||||
//
|
||||
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
|
||||
//
|
||||
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
|
||||
// only brightness an external display has, and it is per-monitor.
|
||||
//
|
||||
// A machine may have neither, either, or both, so this renders a row per source
|
||||
// found and removes itself entirely when there are none, rather than sitting
|
||||
// there as a dead control.
|
||||
//
|
||||
// Connector labels appear only when there is more than one row. A single
|
||||
// slider needs no explanation of which screen it dims.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool available: false
|
||||
property real value: 0
|
||||
property bool backlightAvailable: false
|
||||
property real backlightValue: 0
|
||||
|
||||
visible: root.available
|
||||
implicitHeight: root.available ? 32 : 0
|
||||
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||
readonly property bool labelled: root.rowCount > 1
|
||||
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
|
||||
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||
// is actually on screen rather than running at shell startup. Monitors do
|
||||
// not come and go, so once is enough.
|
||||
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||
|
||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||
Process {
|
||||
@@ -34,18 +54,85 @@ Item {
|
||||
const fields = line.split(",");
|
||||
if (fields.length < 5 || fields[1] !== "backlight")
|
||||
continue;
|
||||
root.available = true;
|
||||
root.value = parseInt(fields[3]) / 100;
|
||||
root.backlightAvailable = true;
|
||||
root.backlightValue = parseInt(fields[3]) / 100;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function apply(v: real): void {
|
||||
root.value = v;
|
||||
function applyBacklight(v: real): void {
|
||||
root.backlightValue = v;
|
||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
||||
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||
}
|
||||
|
||||
Column {
|
||||
id: rows
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 4
|
||||
|
||||
BrightnessRow {
|
||||
width: rows.width
|
||||
visible: root.backlightAvailable
|
||||
label: "Built-in"
|
||||
value: root.backlightValue
|
||||
onMoved: v => root.applyBacklight(v)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Brightness.displays
|
||||
|
||||
BrightnessRow {
|
||||
required property var modelData
|
||||
width: rows.width
|
||||
// Hyprland already knows what each output is called, so the
|
||||
// name comes from there rather than from a second source that
|
||||
// could disagree with the Displays page. The connector is the
|
||||
// fallback, so a display is never an unlabelled slider.
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
value: modelData.value / 100
|
||||
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component BrightnessRow: Item {
|
||||
id: row
|
||||
|
||||
property string label: ""
|
||||
property real value: 0
|
||||
signal moved(real value)
|
||||
|
||||
implicitHeight: caption.height + control.height
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.top: parent.top
|
||||
visible: root.labelled
|
||||
height: visible ? implicitHeight + 2 : 0
|
||||
text: row.label
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// The slider and its glyph share one strip so the two stay aligned
|
||||
// whether or not a caption sits above them. Anchoring the glyph to
|
||||
// both a caption and a centre line instead would conflict, and an
|
||||
// anchor set to undefined is not released.
|
||||
Item {
|
||||
id: control
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: caption.bottom
|
||||
height: 32
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
@@ -63,7 +150,9 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.value
|
||||
onMoved: v => root.apply(v)
|
||||
value: row.value
|
||||
onMoved: v => row.moved(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
margins.top: Theme.barHeight + Theme.controlCenterTopGap
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: Theme.popoverWidth
|
||||
implicitWidth: Theme.controlCenterWidth
|
||||
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.
|
||||
WlrLayershell.namespace: "qs-popover-quicksettings"
|
||||
@@ -36,11 +42,14 @@ PanelWindow {
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
if (root.visible) {
|
||||
KdeConnect.refresh();
|
||||
HomeAssistant.refresh();
|
||||
openAnim.restart();
|
||||
else
|
||||
} else {
|
||||
panel.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Closes the panel when a click lands anywhere else on the desktop.
|
||||
HyprlandFocusGrab {
|
||||
|
||||
@@ -18,7 +18,7 @@ import qs.widgets
|
||||
Item {
|
||||
id: root
|
||||
|
||||
implicitWidth: Theme.popoverWidth
|
||||
implicitWidth: Theme.controlCenterWidth
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
|
||||
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
||||
@@ -233,6 +233,19 @@ Item {
|
||||
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 {
|
||||
width: content.width
|
||||
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,111 @@ import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
title: "About"
|
||||
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
||||
|
||||
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 }
|
||||
Component.onCompleted: {
|
||||
if (!MachineInfo.scanned)
|
||||
MachineInfo.refresh();
|
||||
if (GraphicsDevices.devices.length === 0)
|
||||
GraphicsDevices.refresh();
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Panama Desktop"
|
||||
title: "Desktop"
|
||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
||||
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
|
||||
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
|
||||
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
|
||||
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
|
||||
|
||||
TextRow {
|
||||
label: "Hyprland"
|
||||
value: SystemSettings.hyprlandVersion || "Detecting…"
|
||||
}
|
||||
TextRow {
|
||||
label: "Quickshell"
|
||||
value: SystemSettings.quickshellVersion
|
||||
}
|
||||
TextRow {
|
||||
label: "Display"
|
||||
value: SystemSettings.monitorName || "Detecting…"
|
||||
}
|
||||
TextRow {
|
||||
label: "Configuration"
|
||||
detail: Quickshell.shellDir
|
||||
value: "Local"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// What GNOME's About panel answers and this page did not: what am I running
|
||||
// on. Rows come from MachineInfo, except graphics, which is joined from
|
||||
// GraphicsDevices rather than read a second time -- two readouts of the same
|
||||
// hardware are two things that can disagree.
|
||||
//
|
||||
// The join splices the GPU rows in directly after Processor rather than
|
||||
// appending them, so the card reads the way fastfetch does: what the system
|
||||
// is, then what is installed on it, then the hardware underneath. A GPU
|
||||
// listed after "Disk" reads as an afterthought.
|
||||
readonly property var machineRows: {
|
||||
const rows = (MachineInfo.facts ?? []).slice();
|
||||
const gpus = GraphicsDevices.devices ?? [];
|
||||
if (gpus.length === 0)
|
||||
return rows;
|
||||
|
||||
const graphics = gpus.map((device, index) => ({
|
||||
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
|
||||
value: device.name
|
||||
}));
|
||||
|
||||
const after = rows.findIndex(row => row.label === "Processor");
|
||||
if (after < 0)
|
||||
return rows.concat(graphics);
|
||||
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "This machine"
|
||||
subtitle: "Hardware and system, as the kernel reports it."
|
||||
|
||||
Repeater {
|
||||
model: root.machineRows
|
||||
|
||||
TextRow {
|
||||
id: machineRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: machineRow.modelData.label
|
||||
value: machineRow.modelData.value
|
||||
divider: machineRow.index < root.machineRows.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: MachineInfo.scanned && root.machineRows.length === 0
|
||||
label: "Hardware"
|
||||
detail: "The system did not report anything readable"
|
||||
value: "Unavailable"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Design principles"
|
||||
SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" }
|
||||
SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" }
|
||||
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false }
|
||||
|
||||
TextRow {
|
||||
label: "Curated by default"
|
||||
detail: "Strong choices instead of an incoherent matrix of switches"
|
||||
}
|
||||
TextRow {
|
||||
label: "Quiet while idle"
|
||||
detail: "No continuous decorative repaint loops"
|
||||
}
|
||||
TextRow {
|
||||
label: "Real system boundaries"
|
||||
detail: "Every control either works or clearly hands off to its owner"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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. The shell's own panels are drawn at their design size, so they are unaffected."
|
||||
|
||||
SliderRow { setting: "textScale"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Motion"
|
||||
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
|
||||
|
||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||
}
|
||||
|
||||
// Zoom, done by the compositor rather than handed to GNOME. Hyprland has a
|
||||
// real magnifier (cursor:zoom_factor) that follows the pointer, so there is
|
||||
// no reason to send someone to another application for it.
|
||||
SettingsCard {
|
||||
title: "Magnifier"
|
||||
subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
|
||||
|
||||
SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
|
||||
ToggleRow { setting: "magnifierRigid"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Contrast"
|
||||
subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read."
|
||||
|
||||
SliderRow { setting: "inactiveOpacity" }
|
||||
ToggleRow { setting: "dimInactive" }
|
||||
SliderRow { setting: "dimStrength"; divider: false }
|
||||
}
|
||||
|
||||
// What this session genuinely cannot do, said plainly.
|
||||
//
|
||||
// Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is
|
||||
// an X11 SERVER feature. XKB under Wayland has no accessx option group at
|
||||
// all, and Hyprland does not implement one. The compositor will accept
|
||||
// "accessx:enable" as a keyboard option and store it, and nothing will ever
|
||||
// act on it -- so there is no switch here, and pointing at GNOME's panel
|
||||
// would be no better, since the daemon that applies those keys is not
|
||||
// running either.
|
||||
SettingsCard {
|
||||
title: "Keyboard accessibility"
|
||||
subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on."
|
||||
|
||||
ActionRow {
|
||||
label: "Screen reader"
|
||||
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
|
||||
action: "Start Orca"
|
||||
onTriggered: SystemSettings.openApplication("orca")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "GNOME accessibility settings"
|
||||
detail: "For the parts GNOME's own stack still owns"
|
||||
action: "Open"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("universal-access")
|
||||
}
|
||||
}
|
||||
|
||||
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,192 @@
|
||||
// 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 qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Appearance"
|
||||
lede: "Drag anything below. The preview above is your real geometry, to scale."
|
||||
|
||||
header: Component {
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
spacing: 9
|
||||
|
||||
Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
DesktopPreview {
|
||||
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 {
|
||||
title: "Theme"
|
||||
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
|
||||
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
||||
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
||||
title: "Background"
|
||||
subtitle: Wallpaper.lastError !== ""
|
||||
? Wallpaper.lastError
|
||||
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||
|
||||
WallpaperPicker {
|
||||
id: wallpapers
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: wallpapers.hidden > 0 || wallpapers.expanded
|
||||
label: wallpapers.expanded ? "Showing every image" : wallpapers.hidden + " more available"
|
||||
detail: "The grid is kept short so the rest of this page stays reachable"
|
||||
action: wallpapers.expanded ? "Show fewer" : "Show all"
|
||||
onTriggered: wallpapers.expanded = !wallpapers.expanded
|
||||
}
|
||||
|
||||
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: "Colour scheme"
|
||||
subtitle: ColorScheme.lastError !== ""
|
||||
? ColorScheme.lastError
|
||||
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
|
||||
|
||||
ChoiceRow { setting: "colorScheme"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Typography"
|
||||
subtitle: Fonts.lastError !== ""
|
||||
? Fonts.lastError
|
||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
||||
|
||||
SliderRow { setting: "interfaceFontSize" }
|
||||
|
||||
TextRow {
|
||||
label: "Interface font"
|
||||
detail: Fonts.interfaceMissing
|
||||
? "Not installed on this machine — fontconfig is substituting something else"
|
||||
: "Used for all shell text"
|
||||
value: Fonts.interfaceFont
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
width: parent.width
|
||||
families: Fonts.interfaceFonts
|
||||
current: Fonts.interfaceFont
|
||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No interface fonts found"
|
||||
onPicked: family => Fonts.setInterface(family)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Icon font"
|
||||
detail: Fonts.iconMissing
|
||||
? "Not installed — the shell's glyphs will not draw correctly"
|
||||
: "Draws the shell's glyphs, so only Nerd Fonts are offered"
|
||||
value: Fonts.iconFont
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
width: parent.width
|
||||
families: Fonts.iconFonts
|
||||
current: Fonts.iconFont
|
||||
emptyText: "No Nerd Fonts installed"
|
||||
onPicked: family => Fonts.setIcon(family)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
title: "Clock"
|
||||
SettingRow {
|
||||
label: "24-hour time"
|
||||
detail: "Use 18:30 instead of 6:30 PM"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.use24Hour; onToggled: value => DesktopPreferences.use24Hour = value }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
ToggleRow { setting: "use24Hour" }
|
||||
ToggleRow { setting: "showSeconds" }
|
||||
ToggleRow { setting: "showWeekday"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Choose what appears beside the workspace indicator."
|
||||
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value } }
|
||||
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showMemory; onToggled: value => DesktopPreferences.showMemory = value } }
|
||||
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showGpu; onToggled: value => DesktopPreferences.showGpu = value } }
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
|
||||
width: parent.width
|
||||
label: "Graphics device"
|
||||
detail: GraphicsDevices.selectionMissing
|
||||
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
|
||||
: "Which GPU the graphics readout measures."
|
||||
options: GraphicsDevices.devices.map(device => ({
|
||||
value: device.path,
|
||||
label: GraphicsDevices.shortName(device.name)
|
||||
}))
|
||||
current: GraphicsDevices.selectedPath
|
||||
divider: false
|
||||
onPicked: value => GraphicsDevices.select(value)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Theme"
|
||||
subtitle: "This desktop 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,251 @@
|
||||
// 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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GNOME's Search panel, answered honestly.
|
||||
//
|
||||
// It configures which applications provide results in gnome-shell's
|
||||
// overview and which folders are indexed. gnome-shell does not run here,
|
||||
// so those settings would do nothing. Under Panama searching is the
|
||||
// launcher's job, and Vicinae carries its own preferences -- reimplementing
|
||||
// them here would give two places to change one thing.
|
||||
SettingsCard {
|
||||
title: "Search"
|
||||
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
|
||||
|
||||
TextRow {
|
||||
label: "Launcher"
|
||||
detail: SystemSettings.vicinaeActive
|
||||
? "Running as a user service"
|
||||
: "Not running — Super+Shift+R opens the fallback launcher"
|
||||
value: "Vicinae"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Open search"
|
||||
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
|
||||
value: "Super+Space"
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Change these shortcuts"
|
||||
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
|
||||
action: "Open keyboard"
|
||||
divider: false
|
||||
onTriggered: ShellState.openSettings("shortcuts")
|
||||
}
|
||||
}
|
||||
|
||||
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: "These are started from the 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,72 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property var node: null
|
||||
|
||||
label: "Balance"
|
||||
detail: "Adjust the left and right channels"
|
||||
controlWidth: 270
|
||||
visible: root.available
|
||||
divider: false
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
}
|
||||
|
||||
function channelIndex(channel): int {
|
||||
if (!root.node?.audio)
|
||||
return -1;
|
||||
const channels = root.node.audio.channels;
|
||||
for (let index = 0; index < channels.length; index++) {
|
||||
if (channels[index] === channel)
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
||||
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
|
||||
readonly property bool available: root.node?.audio
|
||||
&& root.leftIndex >= 0 && root.rightIndex >= 0
|
||||
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
||||
|
||||
readonly property real position: {
|
||||
if (!root.available)
|
||||
return 0.5;
|
||||
const left = root.node.audio.volumes[root.leftIndex];
|
||||
const right = root.node.audio.volumes[root.rightIndex];
|
||||
const level = Math.max(left, right);
|
||||
if (level <= 0.001)
|
||||
return 0.5;
|
||||
return right >= left ? 0.5 + (1 - left / level) * 0.5
|
||||
: 0.5 - (1 - right / level) * 0.5;
|
||||
}
|
||||
|
||||
function setBalance(value: real): void {
|
||||
if (!root.available)
|
||||
return;
|
||||
const next = Array.from(root.node.audio.volumes);
|
||||
const level = Math.max(next[root.leftIndex], next[root.rightIndex], 0.001);
|
||||
if (value < 0.5) {
|
||||
next[root.leftIndex] = level;
|
||||
next[root.rightIndex] = level * value * 2;
|
||||
} else {
|
||||
next[root.leftIndex] = level * (1 - value) * 2;
|
||||
next[root.rightIndex] = level;
|
||||
}
|
||||
root.node.audio.volumes = next;
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.fill: parent
|
||||
value: root.position
|
||||
icon: "audio-speakers-symbolic"
|
||||
onMoved: value => root.setBalance(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var entity
|
||||
signal addRequested(string id)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 62
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: stateCopy.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.entity.sourceName
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.entity.id
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: stateCopy
|
||||
anchors.right: addButton.left
|
||||
anchors.rightMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 92
|
||||
text: root.entity.available === false ? "Unavailable" : String(root.entity.state || "Unknown")
|
||||
color: root.entity.available === false ? Theme.fgMuted : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: addButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Add"
|
||||
tone: "accent"
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 0
|
||||
border.color: activeFocus ? Theme.fg : Theme.alpha(Theme.fg, 0)
|
||||
onClicked: root.addRequested(root.entity.id)
|
||||
Keys.onReturnPressed: root.addRequested(root.entity.id)
|
||||
Keys.onSpacePressed: root.addRequested(root.entity.id)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Bluetooth, at page size.
|
||||
//
|
||||
// Paired devices first, because reconnecting to something you already own is
|
||||
// what you are here for nine times out of ten; discovered devices follow.
|
||||
// Battery is shown where BlueZ reports it, which is the one thing people
|
||||
// routinely open a terminal for.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
function primaryAction(device: var): void {
|
||||
if (device.connected) {
|
||||
device.disconnect();
|
||||
return;
|
||||
}
|
||||
if (device.paired) {
|
||||
device.connect();
|
||||
return;
|
||||
}
|
||||
device.pair();
|
||||
}
|
||||
|
||||
function stateLabel(device: var): string {
|
||||
if (device.pairing)
|
||||
return "Pairing…";
|
||||
if (device.connected)
|
||||
return device.batteryAvailable
|
||||
? `Connected · ${Math.round(device.battery * 100)}% battery`
|
||||
: "Connected";
|
||||
if (device.paired)
|
||||
return "Paired";
|
||||
return device.address || "Not paired";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Connectivity.bluetoothDevices
|
||||
|
||||
SettingRow {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: entry.modelData.name || entry.modelData.address || "Unknown device"
|
||||
detail: root.stateLabel(entry.modelData)
|
||||
divider: entry.index < Connectivity.bluetoothDevices.length - 1
|
||||
controlWidth: 200
|
||||
activatable: !entry.modelData.pairing
|
||||
onActivated: root.primaryAction(entry.modelData)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: !entry.modelData.pairing
|
||||
text: entry.modelData.connected
|
||||
? "Disconnect"
|
||||
: (entry.modelData.paired ? "Connect" : "Pair")
|
||||
onClicked: root.primaryAction(entry.modelData)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.paired
|
||||
text: "Forget"
|
||||
onClicked: entry.modelData.forget()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Connectivity.bluetoothDevices.length === 0
|
||||
label: !Connectivity.adapter
|
||||
? "No Bluetooth adapter"
|
||||
: (Connectivity.adapter.enabled ? "Looking for devices…" : "Bluetooth is off")
|
||||
detail: Connectivity.adapter && !Connectivity.adapter.enabled
|
||||
? "Turn it on above to discover devices"
|
||||
: "Put the device into pairing mode to make it appear"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,192 @@
|
||||
// Network & Devices.
|
||||
//
|
||||
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
|
||||
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
|
||||
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
|
||||
// the founding requirement for this desktop: never having to drop to a terminal
|
||||
// to join a network.
|
||||
//
|
||||
// Scanning follows this page being on screen. Wi-Fi scanning and especially
|
||||
// Bluetooth discovery hold the radio, and doing either for a list nobody is
|
||||
// looking at is battery and airtime spent on nothing.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Networking
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
readonly property var wifiDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wifi)
|
||||
return device;
|
||||
|
||||
title: "Network & Devices"
|
||||
lede: Connectivity.activeNetwork
|
||||
? "Connected to " + Connectivity.activeNetwork.name
|
||||
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
||||
|
||||
// Drive the scanners only while this page is the one being shown.
|
||||
Component.onCompleted: {
|
||||
Connectivity.active = true;
|
||||
if (!WifiShare.scanned)
|
||||
WifiShare.refresh();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
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 }
|
||||
Component.onDestruction: Connectivity.active = false
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi‑Fi"
|
||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
||||
title: "Wired"
|
||||
visible: Connectivity.wiredDevice !== null
|
||||
|
||||
TextRow {
|
||||
label: "Ethernet"
|
||||
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
|
||||
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi-Fi"
|
||||
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
|
||||
// contradiction; with no radio the card simply does not belong.
|
||||
visible: Connectivity.wifiDevice !== null
|
||||
subtitle: "Networks are re-scanned while this page is open."
|
||||
|
||||
SettingRow {
|
||||
label: "Wi‑Fi"
|
||||
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
||||
label: "Wi-Fi"
|
||||
detail: Connectivity.wifiEnabled ? "On" : "Off"
|
||||
controlWidth: 48
|
||||
divider: Connectivity.wifiEnabled
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Networking.wifiEnabled
|
||||
enabled: Networking.wifiHardwareEnabled
|
||||
checked: Connectivity.wifiEnabled
|
||||
enabled: Connectivity.wifiAvailable
|
||||
onToggled: value => Networking.wifiEnabled = value
|
||||
}
|
||||
}
|
||||
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
|
||||
SettingRow {
|
||||
label: "Advanced network settings"
|
||||
detail: "VPN, wired profiles, DNS, and connection details"
|
||||
divider: false
|
||||
controlWidth: 104
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
|
||||
|
||||
WifiPanel {
|
||||
width: parent.width
|
||||
visible: Connectivity.wifiEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
|
||||
// alternative is reading a passphrase out loud.
|
||||
//
|
||||
// The image holds the password in machine-readable form, so it is generated
|
||||
// on demand rather than up front, and the helper writes it to tmpfs under
|
||||
// XDG_RUNTIME_DIR instead of anywhere persistent.
|
||||
SettingsCard {
|
||||
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
|
||||
title: "Share a network"
|
||||
subtitle: WifiShare.sharing !== ""
|
||||
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
|
||||
: "Shows a QR code a phone can scan to join, without reading the password out."
|
||||
|
||||
Repeater {
|
||||
model: WifiShare.shareable
|
||||
|
||||
ActionRow {
|
||||
id: shareRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: shareRow.modelData.ssid
|
||||
detail: WifiShare.sharing === shareRow.modelData.name
|
||||
? "Showing a code below — anyone who can see the screen can join"
|
||||
: "Saved network"
|
||||
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
|
||||
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
|
||||
onTriggered: WifiShare.sharing === shareRow.modelData.name
|
||||
? WifiShare.stopSharing()
|
||||
: WifiShare.share(shareRow.modelData.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn at its natural size on a white plate: a QR code inverted or
|
||||
// tinted to match a dark theme is unreliable to scan, and this one has
|
||||
// exactly one job.
|
||||
Item {
|
||||
width: parent.width
|
||||
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
|
||||
implicitHeight: visible ? plate.height + 20 : 0
|
||||
|
||||
Rectangle {
|
||||
id: plate
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: 10
|
||||
width: 208
|
||||
height: 208
|
||||
radius: 10
|
||||
color: "white"
|
||||
|
||||
Image {
|
||||
anchors.centerIn: parent
|
||||
width: 184
|
||||
height: 184
|
||||
smooth: false
|
||||
fillMode: Image.PreserveAspectFit
|
||||
cache: false
|
||||
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Bluetooth"
|
||||
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
|
||||
visible: Connectivity.adapter !== null
|
||||
subtitle: "Discovery runs while this page is open."
|
||||
|
||||
SettingRow {
|
||||
label: "Bluetooth"
|
||||
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
|
||||
detail: Connectivity.adapter
|
||||
? (Connectivity.adapter.enabled ? "On" : "Off")
|
||||
: "Unavailable"
|
||||
controlWidth: 48
|
||||
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.bluetoothAdapter?.enabled ?? false
|
||||
enabled: root.bluetoothAdapter !== null
|
||||
onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; }
|
||||
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
enabled: Connectivity.adapter !== null
|
||||
onToggled: value => {
|
||||
if (Connectivity.adapter)
|
||||
Connectivity.adapter.enabled = value;
|
||||
}
|
||||
}
|
||||
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
|
||||
SettingRow {
|
||||
label: "Advanced Bluetooth settings"
|
||||
detail: "Device details and system-level options"
|
||||
}
|
||||
|
||||
BluetoothPanel {
|
||||
width: parent.width
|
||||
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Owned by Fedora"
|
||||
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
|
||||
|
||||
ActionRow {
|
||||
label: "Network connections"
|
||||
detail: "VPN, proxies, and per-connection settings"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Printers"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("printers")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Online accounts"
|
||||
action: "Open"
|
||||
divider: false
|
||||
controlWidth: 104
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") }
|
||||
}
|
||||
}
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,134 @@
|
||||
// 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 qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
Column {
|
||||
id: content
|
||||
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 }
|
||||
title: "Desktop & Dock"
|
||||
lede: "Keep the shell instant, spatial, and out of your way."
|
||||
|
||||
SettingsCard {
|
||||
title: "Dock"
|
||||
SettingRow {
|
||||
label: "Automatically hide the Dock"
|
||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.dockAutohide; onToggled: value => DesktopPreferences.dockAutohide = value }
|
||||
|
||||
ToggleRow { setting: "dockAutohide" }
|
||||
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
||||
}
|
||||
|
||||
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 {
|
||||
title: "Window layout"
|
||||
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" }
|
||||
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
|
||||
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
|
||||
|
||||
// These were two read-only rows reporting "Tiling" and "Dynamic", which
|
||||
// described settings rather than facts -- both are ordinary Hyprland
|
||||
// options that simply had no controls. TextRow's own documentation says
|
||||
// a setting the user could reasonably change does not belong in it.
|
||||
ChoiceRow { setting: "windowLayout" }
|
||||
ToggleRow { setting: "preserveSplit" }
|
||||
ChoiceRow { setting: "forceSplit" }
|
||||
ToggleRow { setting: "windowSnapping" }
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||
SettingsCard {
|
||||
title: "Workspaces & focus"
|
||||
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||
|
||||
ToggleRow { setting: "workspaceBackAndForth" }
|
||||
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||
ToggleRow { setting: "focusOnActivate" }
|
||||
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
||||
ChoiceRow { setting: "followMouse"; divider: false }
|
||||
}
|
||||
|
||||
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 {
|
||||
title: "Reset"
|
||||
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults."
|
||||
SettingRow {
|
||||
label: "Desktop preferences"
|
||||
detail: "Your pinned applications and files are not changed"
|
||||
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||
|
||||
ActionRow {
|
||||
label: "Restore defaults"
|
||||
detail: "Applies immediately, including to the compositor"
|
||||
action: "Restore defaults"
|
||||
divider: false
|
||||
controlWidth: 122
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
|
||||
}
|
||||
}
|
||||
onTriggered: SystemSettings.restoreDefaults()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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.
|
||||
// Follows the colour scheme. A preview that stays dark while the shell
|
||||
// around it is light does not read as "your desktop" -- it reads as a
|
||||
// screenshot of someone else's.
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Vertical
|
||||
GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
|
||||
GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The real bar carries a clock here, not a name. Standing in with the
|
||||
// actual time keeps the preview a picture of this desktop rather than
|
||||
// a picture of a product.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Qt.formatTime(new Date(), "h:mm")
|
||||
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(Theme.dark ? "#15161e" : "#6172b0", Theme.dark ? 0.85 : 0.45)
|
||||
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,278 @@
|
||||
// Displays.
|
||||
//
|
||||
// Resolution, refresh rate, scale, and rotation, plus panel brightness and 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 qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Displays"
|
||||
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||
|
||||
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 : "";
|
||||
}
|
||||
|
||||
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||||
// it runs when this page is opened rather than at shell startup. Monitors
|
||||
// do not appear while you are looking at a settings page, so once is enough.
|
||||
Component.onCompleted: {
|
||||
root.syncSelectedOutput();
|
||||
if (!Brightness.scanned)
|
||||
Brightness.refresh();
|
||||
}
|
||||
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 {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
width: parent.width - keepButton.width - revertButton.width - 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
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 }
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Keep this display setting?"
|
||||
color: Theme.fg
|
||||
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
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
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 {
|
||||
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"
|
||||
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
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
|
||||
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 shipped resolution and scale"
|
||||
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: "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() }
|
||||
subtitle: NightLight.active
|
||||
? "On now, warming the display to reduce blue light."
|
||||
: "Warms the display in the evening to reduce blue light."
|
||||
|
||||
ToggleRow { setting: "nightLightEnabled" }
|
||||
ToggleRow { setting: "nightLightAutomatic" }
|
||||
TimeOfDayRow { setting: "nightLightFrom" }
|
||||
TimeOfDayRow { setting: "nightLightTo" }
|
||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
||||
}
|
||||
|
||||
// Panel brightness, over DDC/CI.
|
||||
//
|
||||
// This is hardware state rather than a stored preference: the monitor
|
||||
// remembers it, the bezel buttons change it behind Panama's back, and
|
||||
// writing it into settings.json would mean restoring a value the panel had
|
||||
// already moved on from. So there is no schema key here and no SliderRow --
|
||||
// the rows read and write the display directly.
|
||||
SettingsCard {
|
||||
visible: Brightness.available || Brightness.lastError !== ""
|
||||
title: "Brightness"
|
||||
subtitle: Brightness.available
|
||||
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
|
||||
: Brightness.lastError
|
||||
|
||||
Repeater {
|
||||
model: Brightness.displays
|
||||
|
||||
SettingRow {
|
||||
label: "Color temperature"
|
||||
detail: `${NightLight.temperature} K`
|
||||
divider: false
|
||||
controlWidth: 230
|
||||
id: brightnessRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
|
||||
: modelData.value + "%"
|
||||
divider: brightnessRow.index < Brightness.displays.length - 1
|
||||
controlWidth: 190
|
||||
|
||||
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
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
width: parent.width
|
||||
value: brightnessRow.modelData.value / 100
|
||||
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
id: warningText
|
||||
anchors.fill: parent
|
||||
anchors.margins: 15
|
||||
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
|
||||
color: Theme.mix(Theme.fg, Theme.warn, 0.25)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "Applied immediately and restored when the session 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,80 @@
|
||||
// Choosing a font family.
|
||||
//
|
||||
// Each candidate is rendered IN the font it names. A list of family names set
|
||||
// in the current font tells you nothing about what you are choosing, and the
|
||||
// whole point of picking a typeface is seeing it.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
property var families: []
|
||||
property string current: ""
|
||||
property string emptyText: "No fonts found"
|
||||
|
||||
signal picked(string family)
|
||||
|
||||
// Filtered rather than listing all 139: a scrolling wall of family names
|
||||
// inside a page that is already scrolling is worse than a search box.
|
||||
readonly property var matches: {
|
||||
const needle = filter.text.trim().toLowerCase();
|
||||
const list = root.families.filter(family =>
|
||||
needle === "" ? true : family.toLowerCase().indexOf(needle) >= 0);
|
||||
// Always show what is currently selected, even when it does not match.
|
||||
if (needle === "" && list.indexOf(root.current) >= 0)
|
||||
return [root.current].concat(list.filter(f => f !== root.current)).slice(0, 12);
|
||||
return list.slice(0, 12);
|
||||
}
|
||||
|
||||
SearchField {
|
||||
id: filter
|
||||
width: parent.width
|
||||
placeholder: "Search installed fonts"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.matches
|
||||
|
||||
SettingRow {
|
||||
id: candidate
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool selected: candidate.modelData === root.current
|
||||
|
||||
label: candidate.modelData
|
||||
detail: candidate.selected ? "Currently in use" : ""
|
||||
controlWidth: 210
|
||||
divider: candidate.index < root.matches.length - 1
|
||||
activatable: !candidate.selected
|
||||
onActivated: root.picked(candidate.modelData)
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 200
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideRight
|
||||
// The sample is drawn in the candidate family, which is the
|
||||
// entire reason this is a list rather than a text field.
|
||||
text: "Handgloves 0123"
|
||||
font.family: candidate.modelData
|
||||
font.pixelSize: Theme.fontSize + 1
|
||||
color: candidate.selected ? Theme.accent : Theme.fgDim
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.matches.length === 0
|
||||
label: root.emptyText
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var check
|
||||
property bool issue: false
|
||||
property bool divider: true
|
||||
property int actionActivationCount: 0
|
||||
signal actionRequested(var check)
|
||||
|
||||
readonly property bool repairWorking: Health.repairingId === root.check.id
|
||||
readonly property bool repairFailed: root.check.status !== "ok"
|
||||
&& Health.lastRepair.checkId === root.check.id
|
||||
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0)
|
||||
|
||||
objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||
implicitHeight: 62
|
||||
|
||||
// The isolated contract uses the same signal path as a pointer or keyboard
|
||||
// activation instead of calling HealthPage's action handler directly.
|
||||
function activateAction(): bool {
|
||||
if (!actionButton.visible || !actionButton.enabled)
|
||||
return false;
|
||||
root.actionActivationCount += 1;
|
||||
actionButton.clicked();
|
||||
return true;
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === "ok") return "Healthy";
|
||||
if (status === "warning") return "Needs attention";
|
||||
if (status === "error") return "Action required";
|
||||
return "Not set up";
|
||||
}
|
||||
|
||||
function statusColor(status: string): color {
|
||||
if (status === "ok") return Theme.ok;
|
||||
if (status === "warning") return Theme.warn;
|
||||
if (status === "error") return Theme.danger;
|
||||
return Theme.fgMuted;
|
||||
}
|
||||
|
||||
function displayedStatus(): string {
|
||||
if (root.repairWorking)
|
||||
return "Working…";
|
||||
if (root.repairFailed)
|
||||
return "Repair failed";
|
||||
return root.statusLabel(root.check.status);
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: issueMark
|
||||
|
||||
visible: root.issue
|
||||
width: 7
|
||||
height: 7
|
||||
radius: 4
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: root.statusColor(root.check.status)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.bgPanel, 0.9)
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: root.issue ? 22 : 0
|
||||
anchors.right: trailing.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.check.title
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.check.detail
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: trailing
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.displayedStatus()
|
||||
color: root.statusColor(root.check.status)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: actionButton
|
||||
|
||||
objectName: `health-row-action:${root.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||
visible: root.check.action !== undefined
|
||||
text: root.check.action?.label ?? ""
|
||||
enabled: visible && !root.repairWorking && !Health.busy
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: root.actionRequested(root.check)
|
||||
Keys.onReturnPressed: if (enabled) root.actionRequested(root.check)
|
||||
Keys.onSpacePressed: if (enabled) root.actionRequested(root.check)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: root.divider
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "system-health-page"
|
||||
|
||||
title: "System Health"
|
||||
lede: "Checks the parts of the desktop this app owns, and explains what needs attention."
|
||||
|
||||
property var pendingConfirmation: null
|
||||
property string instructionTarget: ""
|
||||
|
||||
readonly property var issueChecks: Health.checks.filter(check => check.status === "warning" || check.status === "error")
|
||||
readonly property var groups: [
|
||||
{
|
||||
group: "desktop-foundation",
|
||||
title: "Desktop foundation",
|
||||
subtitle: "Compositor, shell, portals, wallpaper, idle policy, and launcher."
|
||||
},
|
||||
{
|
||||
group: "input-media",
|
||||
title: "Input & media",
|
||||
subtitle: "Sound, clipboard, capture, OCR, and display controls."
|
||||
},
|
||||
{
|
||||
group: "integrations",
|
||||
title: "Integrations",
|
||||
subtitle: "Only configured integrations affect health."
|
||||
},
|
||||
{
|
||||
group: "panama-tools",
|
||||
title: "Desktop tools",
|
||||
subtitle: "Tracked links, launcher commands, apps, and inhibitors."
|
||||
}
|
||||
]
|
||||
readonly property var applicationTargets: ({
|
||||
"integration.nextcloud": "nextcloud",
|
||||
"integration.rustdesk": "rustdesk",
|
||||
"integration.kdeconnect": "kdeconnect",
|
||||
"integration.bluebubbles": "bluebubbles"
|
||||
})
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === "ok") return "Healthy";
|
||||
if (status === "warning") return "Needs attention";
|
||||
if (status === "error") return "Action required";
|
||||
return "Not set up";
|
||||
}
|
||||
|
||||
function checksForGroup(group: string): var {
|
||||
return Health.checks.filter(check => check.group === group
|
||||
&& (check.status === "ok" || check.status === "unconfigured"));
|
||||
}
|
||||
|
||||
function handleAction(check: var): void {
|
||||
if (!check || !check.action)
|
||||
return;
|
||||
|
||||
if (check.action.kind === "open") {
|
||||
if (check.action.target) {
|
||||
ShellState.openSettings(check.action.target);
|
||||
return;
|
||||
}
|
||||
const application = root.applicationTargets[check.id];
|
||||
if (application)
|
||||
SystemSettings.openApplication(application);
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.action.kind === "instructions") {
|
||||
root.instructionTarget = check.action.target || "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.action.kind !== "repair")
|
||||
return;
|
||||
if (check.action.confirm) {
|
||||
root.pendingConfirmation = check;
|
||||
return;
|
||||
}
|
||||
Health.repair(check.id, false);
|
||||
}
|
||||
|
||||
function confirmRepair(): void {
|
||||
const check = root.pendingConfirmation;
|
||||
root.pendingConfirmation = null;
|
||||
if (check)
|
||||
Health.repair(check.id, false);
|
||||
}
|
||||
|
||||
function descendants(item: var, prefix: string): var {
|
||||
let matches = [];
|
||||
if (!item)
|
||||
return matches;
|
||||
if (String(item.objectName || "").indexOf(prefix) === 0)
|
||||
matches.push(item);
|
||||
for (const child of item.children || [])
|
||||
matches = matches.concat(root.descendants(child, prefix));
|
||||
return matches;
|
||||
}
|
||||
|
||||
function activateRenderedAction(id: string): bool {
|
||||
const suffix = `:${id}`;
|
||||
const rows = root.descendants(root, "health-check-row:").filter(row =>
|
||||
row.visible && String(row.objectName).endsWith(suffix));
|
||||
return rows.length === 1 && rows[0].activateAction();
|
||||
}
|
||||
|
||||
function renderedFocusChain(): var {
|
||||
// Repeater delegates enter Qt's tab chain lazily after their enabled
|
||||
// binding changes at the end of a scan. Touch each rendered action's
|
||||
// real next-focus link before traversing from the first hero control.
|
||||
const renderedActions = root.descendants(root, "health-row-action:").filter(item =>
|
||||
item.visible && item.enabled && item.activeFocusOnTab);
|
||||
for (const action of renderedActions)
|
||||
action.nextItemInFocusChain(true);
|
||||
|
||||
const starts = root.descendants(root, "health-copy-report-button").filter(item => item.visible && item.enabled);
|
||||
if (starts.length !== 1)
|
||||
return [];
|
||||
|
||||
const names = [];
|
||||
const start = starts[0];
|
||||
let current = start;
|
||||
for (let index = 0; index < 128; index++) {
|
||||
const name = String(current.objectName || "");
|
||||
if (name !== "" && names.indexOf(name) < 0)
|
||||
names.push(name);
|
||||
current = current.nextItemInFocusChain(true);
|
||||
if (!current || current === start)
|
||||
break;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// Deterministic, read-only fixture seam used by the offscreen contract.
|
||||
function uiDiagnostics(): var {
|
||||
const summaries = root.descendants(root, "health-summary");
|
||||
const rows = root.descendants(root, "health-check-row:").filter(row => row.visible);
|
||||
const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.visible);
|
||||
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
|
||||
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
|
||||
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
|
||||
return {
|
||||
renderedRows: rows.map(row => {
|
||||
const objectName = String(row.objectName);
|
||||
const parts = objectName.split(":");
|
||||
const statusTexts = root.descendants(row, "health-status-text:").filter(text => text.visible);
|
||||
return {
|
||||
objectName: objectName,
|
||||
id: parts.slice(2).join(":"),
|
||||
section: parts[1],
|
||||
statusText: statusTexts.length === 1 ? statusTexts[0].text : ""
|
||||
};
|
||||
}),
|
||||
summaryHeight: summaries.length > 0 ? summaries[0].height : 0,
|
||||
rowHeights: rows.map(row => row.height),
|
||||
checking: Health.busy,
|
||||
checkingText: checkingLabels.length > 0 ? checkingLabels[0].text : "",
|
||||
focusChain: root.renderedFocusChain(),
|
||||
activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)),
|
||||
emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)),
|
||||
fedoraHandoffs: fedoraHandoffs.map(row => ({
|
||||
id: String(row.objectName).slice("health-fedora-handoff:".length),
|
||||
label: row.label,
|
||||
action: row.action
|
||||
})),
|
||||
confirmationVisible: confirmationSheets.length === 1,
|
||||
confirmationId: confirmationSheets.length === 1
|
||||
? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length)
|
||||
: ""
|
||||
};
|
||||
}
|
||||
|
||||
Component.onCompleted: Health.refresh()
|
||||
|
||||
header: Component {
|
||||
Rectangle {
|
||||
objectName: `health-confirmation-sheet:${root.pendingConfirmation ? root.pendingConfirmation.id : ""}`
|
||||
visible: root.pendingConfirmation !== null
|
||||
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.1)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.warn, 0.34)
|
||||
|
||||
Row {
|
||||
id: confirmRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.margins: 16
|
||||
spacing: 14
|
||||
|
||||
Column {
|
||||
width: parent.width - cancelButton.width - repairButton.width - 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.pendingConfirmation
|
||||
? `${root.pendingConfirmation.action.label}?`
|
||||
: "Restart the shell?"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "The desktop chrome will disappear briefly and return when Quickshell restarts."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: cancelButton
|
||||
text: "Cancel"
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: root.pendingConfirmation = null
|
||||
Keys.onReturnPressed: root.pendingConfirmation = null
|
||||
Keys.onSpacePressed: root.pendingConfirmation = null
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: repairButton
|
||||
text: root.pendingConfirmation ? root.pendingConfirmation.action.label : "Restart the shell"
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: root.confirmRepair()
|
||||
Keys.onReturnPressed: root.confirmRepair()
|
||||
Keys.onSpacePressed: root.confirmRepair()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HealthSummary {}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.issueChecks.length > 0
|
||||
title: Health.status === "error" ? "Action required" : "Needs attention"
|
||||
subtitle: Health.status === "error"
|
||||
? "Resolve these items first. Healthy systems remain listed below."
|
||||
: "Nothing here prevents you from using the desktop."
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: issueRows.implicitHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 25
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 25
|
||||
width: 1
|
||||
visible: root.issueChecks.length > 1
|
||||
color: Theme.alpha(Health.status === "error" ? Theme.danger : Theme.warn, 0.44)
|
||||
}
|
||||
|
||||
Column {
|
||||
id: issueRows
|
||||
width: parent.width
|
||||
|
||||
Repeater {
|
||||
id: issueRepeater
|
||||
model: root.issueChecks
|
||||
|
||||
HealthCheckRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: issueRows.width
|
||||
check: modelData
|
||||
issue: true
|
||||
divider: index < issueRepeater.count - 1
|
||||
onActionRequested: check => root.handleAction(check)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.instructionTarget === "ddc-permissions"
|
||||
title: "External monitor brightness"
|
||||
subtitle: "The monitor reports DDC/CI support, but this session cannot reach the monitor bus."
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(instructionCopy.implicitHeight, doneButton.implicitHeight) + 8
|
||||
|
||||
Text {
|
||||
id: instructionCopy
|
||||
anchors.left: parent.left
|
||||
anchors.right: doneButton.left
|
||||
anchors.rightMargin: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Reload the installed udev rules and trigger the i2c-dev and DRM devices. Sign out and back in if monitor access is still unavailable."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: doneButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Done"
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: root.instructionTarget = ""
|
||||
Keys.onReturnPressed: root.instructionTarget = ""
|
||||
Keys.onSpacePressed: root.instructionTarget = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: groupGrid
|
||||
|
||||
width: parent.width
|
||||
columns: width >= 700 ? 2 : 1
|
||||
columnSpacing: 16
|
||||
rowSpacing: 16
|
||||
|
||||
Repeater {
|
||||
model: root.groups
|
||||
|
||||
SettingsCard {
|
||||
required property var modelData
|
||||
readonly property var quietChecks: root.checksForGroup(modelData.group)
|
||||
|
||||
width: groupGrid.columns === 2
|
||||
? (groupGrid.width - groupGrid.columnSpacing) / 2
|
||||
: groupGrid.width
|
||||
title: modelData.title
|
||||
subtitle: modelData.subtitle
|
||||
|
||||
Column {
|
||||
id: groupRows
|
||||
width: parent.width
|
||||
|
||||
Text {
|
||||
objectName: `health-empty-group:${modelData.group}`
|
||||
width: parent.width
|
||||
visible: quietChecks.length === 0
|
||||
text: "Items needing attention are listed above."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
topPadding: 8
|
||||
bottomPadding: 8
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: groupRepeater
|
||||
model: quietChecks
|
||||
|
||||
HealthCheckRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: groupRows.width
|
||||
check: modelData
|
||||
divider: index < groupRepeater.count - 1
|
||||
onActionRequested: check => root.handleAction(check)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Fedora system settings"
|
||||
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: 42
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: gnomeSettingsButton.left
|
||||
anchors.rightMargin: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Use GNOME Settings for the parts of the system this app does not manage."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: gnomeSettingsButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Open GNOME Settings"
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: SystemSettings.openGnomePanel("network")
|
||||
Keys.onReturnPressed: SystemSettings.openGnomePanel("network")
|
||||
Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:users"
|
||||
label: "Users"
|
||||
detail: "Accounts, passwords, and automatic login"
|
||||
action: "Open users"
|
||||
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:sharing"
|
||||
label: "Sharing"
|
||||
detail: "Remote desktop, media sharing, and remote login"
|
||||
action: "Open sharing"
|
||||
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:color"
|
||||
label: "Colour profiles"
|
||||
detail: "ICC profiles for displays, printers, and scanners"
|
||||
action: "Open colour"
|
||||
onTriggered: SystemSettings.openGnomePanel("color")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:wellbeing"
|
||||
label: "Digital wellbeing"
|
||||
detail: "Screen time and break reminders"
|
||||
action: "Open wellbeing"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("wellbeing")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsCard {
|
||||
id: root
|
||||
|
||||
objectName: "health-summary"
|
||||
implicitHeight: 126
|
||||
|
||||
readonly property int observationCount: Health.summary.warnings + Health.summary.errors
|
||||
readonly property string heroTitle: {
|
||||
if (Health.diagnosticUnavailable)
|
||||
return "Health check unavailable";
|
||||
if (Health.checks.length === 0)
|
||||
return "Checking the desktop";
|
||||
if (Health.status === "error")
|
||||
return "Action required";
|
||||
if (Health.status === "warning")
|
||||
return "Needs attention";
|
||||
return "Healthy";
|
||||
}
|
||||
readonly property string heroDetail: {
|
||||
if (Health.diagnosticUnavailable)
|
||||
return Health.lastError || "The latest health check could not be completed.";
|
||||
if (Health.checks.length === 0)
|
||||
return "Checking the desktop services, tools, and integrations this app owns.";
|
||||
if (Health.status === "error")
|
||||
return root.observationCount === 1
|
||||
? "One part of the desktop needs action."
|
||||
: `${root.observationCount} parts of the desktop need action.`;
|
||||
if (Health.status === "warning")
|
||||
return root.observationCount === 1
|
||||
? "Your desktop is working. One feature needs a decision."
|
||||
: `Your desktop is working. ${root.observationCount} features need a decision.`;
|
||||
return "Desktop services and tools are working normally.";
|
||||
}
|
||||
readonly property color statusColor: {
|
||||
if (Health.diagnosticUnavailable || Health.status === "error")
|
||||
return Theme.danger;
|
||||
if (Health.status === "warning")
|
||||
return Theme.warn;
|
||||
if (Health.checks.length === 0)
|
||||
return Theme.fgMuted;
|
||||
return Theme.ok;
|
||||
}
|
||||
|
||||
function scanTime(): string {
|
||||
const generatedAt = String(Health.snapshot.generatedAt || "");
|
||||
if (generatedAt === "")
|
||||
return "No completed check yet";
|
||||
const date = new Date(generatedAt);
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return "Last check completed";
|
||||
return `Last checked ${date.toLocaleTimeString(Qt.locale(), Locale.ShortFormat)}`;
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: 96
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: actions.left
|
||||
anchors.rightMargin: 22
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: Health.diagnosticUnavailable
|
||||
? "DIAGNOSTICS"
|
||||
: root.observationCount > 0
|
||||
? root.observationCount + (root.observationCount === 1 ? " OBSERVATION" : " OBSERVATIONS")
|
||||
: "PANAMA DESKTOP"
|
||||
color: root.statusColor
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 1.15
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.heroTitle
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 24
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.heroDetail
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
text: root.scanTime()
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Text {
|
||||
objectName: "health-checking-label"
|
||||
visible: Health.busy
|
||||
text: "Checking…"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: Health.lastCopyResult !== ""
|
||||
text: Health.lastCopyResult
|
||||
color: Health.lastCopyResult === "Report copied." ? Theme.ok : Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actions
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
id: copyButton
|
||||
|
||||
objectName: "health-copy-report-button"
|
||||
visible: !Health.diagnosticUnavailable
|
||||
text: "Copy report"
|
||||
enabled: Health.checks.length > 0
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: Health.copyReport()
|
||||
Keys.onReturnPressed: if (enabled) Health.copyReport()
|
||||
Keys.onSpacePressed: if (enabled) Health.copyReport()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: refreshButton
|
||||
|
||||
objectName: "health-refresh-button"
|
||||
text: Health.diagnosticUnavailable ? "Retry" : "Refresh"
|
||||
tone: Health.diagnosticUnavailable ? "normal" : "accent"
|
||||
enabled: !Health.busy
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: Health.refresh()
|
||||
Keys.onReturnPressed: if (enabled) Health.refresh()
|
||||
Keys.onSpacePressed: if (enabled) Health.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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
|
||||
required property bool canMoveEarlier
|
||||
required property bool canMoveLater
|
||||
|
||||
signal aliasCommitted(string id, string alias)
|
||||
signal removeRequested(string id)
|
||||
signal moveRequested(string id, int targetIndex)
|
||||
|
||||
implicitHeight: 108
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.7)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: root.radius
|
||||
opacity: 0.2
|
||||
}
|
||||
|
||||
Column {
|
||||
id: reorderControls
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 30
|
||||
spacing: 4
|
||||
|
||||
SettingsButton {
|
||||
width: 30
|
||||
height: 29
|
||||
text: "↑"
|
||||
enabled: root.canMoveEarlier
|
||||
activeFocusOnTab: enabled
|
||||
onClicked: root.moveRequested(root.favorite.id, root.index - 1)
|
||||
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
|
||||
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
width: 30
|
||||
height: 29
|
||||
text: "↓"
|
||||
enabled: root.canMoveLater
|
||||
activeFocusOnTab: enabled
|
||||
onClicked: root.moveRequested(root.favorite.id, root.index + 1)
|
||||
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
|
||||
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: aliasFrame
|
||||
anchors.left: reorderControls.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)
|
||||
}
|
||||
}
|
||||
@@ -2,54 +2,32 @@ import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
readonly property int openedHour: new Date().getHours()
|
||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text {
|
||||
text: `${root.greeting}, Gabriel`
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 27
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Your Panama desktop is configured and ready."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
bottomPadding: 6
|
||||
}
|
||||
title: `${root.greeting}, Gabriel`
|
||||
lede: "Your desktop is configured and ready."
|
||||
|
||||
SettingsCard {
|
||||
title: SystemSettings.monitorDescription || "Active display"
|
||||
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
||||
|
||||
Row {
|
||||
Grid {
|
||||
id: monitorLayout
|
||||
|
||||
width: parent.width
|
||||
height: 164
|
||||
spacing: 28
|
||||
columns: width >= 620 ? 2 : 1
|
||||
columnSpacing: 28
|
||||
rowSpacing: 16
|
||||
|
||||
Item {
|
||||
width: parent.width * 0.47
|
||||
height: parent.height
|
||||
width: monitorLayout.columns === 2
|
||||
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||
: monitorLayout.width
|
||||
height: 164
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(parent.width - 24, 260)
|
||||
@@ -85,8 +63,10 @@ Item {
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width * 0.47
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: monitorLayout.columns === 2
|
||||
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||
: monitorLayout.width
|
||||
height: monitorLayout.columns === 2 ? 164 : implicitHeight
|
||||
spacing: 13
|
||||
|
||||
Text {
|
||||
@@ -120,12 +100,41 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
SettingsCard {
|
||||
title: "Weather"
|
||||
subtitle: "Local conditions in the date menu"
|
||||
TextRow {
|
||||
label: "Location"
|
||||
detail: "Only the search term is sent; the name below is a label kept on this machine"
|
||||
value: Settings.weatherLocation
|
||||
}
|
||||
|
||||
LocationPicker {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
}
|
||||
|
||||
ChoiceRow { setting: "temperatureUnit" }
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: (parent.width - parent.spacing) / 2
|
||||
title: "System vitals"
|
||||
subtitle: "Processor, memory, and graphics activity in the bar"
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: false }
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: summaryCards
|
||||
|
||||
width: parent.width
|
||||
columns: width >= 720 ? 2 : 1
|
||||
columnSpacing: 16
|
||||
rowSpacing: 16
|
||||
|
||||
SettingsCard {
|
||||
width: summaryCards.columns === 2
|
||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||
: summaryCards.width
|
||||
title: "Quiet focus"
|
||||
subtitle: "Notifications and focused work"
|
||||
|
||||
@@ -144,11 +153,13 @@ Item {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: (parent.width - parent.spacing) / 2
|
||||
width: summaryCards.columns === 2
|
||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||
: summaryCards.width
|
||||
title: "Desktop services"
|
||||
subtitle: "The essentials are running"
|
||||
|
||||
SettingRow {
|
||||
TextRow {
|
||||
label: "Sync & remote access"
|
||||
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
||||
divider: false
|
||||
@@ -157,5 +168,3 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
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";
|
||||
}
|
||||
|
||||
function saveHomeAssistantConfig(): void {
|
||||
HomeAssistantConfig.save(
|
||||
homeUrlInput.text,
|
||||
homeEntitiesInput.text,
|
||||
homeTokenInput.text
|
||||
);
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: HomeAssistantConfig
|
||||
|
||||
function onConfigurationSaved(): void {
|
||||
homeTokenInput.clear();
|
||||
homeUrlInput.text = HomeAssistantConfig.url;
|
||||
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Home Assistant"
|
||||
subtitle: root.homeStatus()
|
||||
|
||||
SettingRow {
|
||||
label: "Connection"
|
||||
detail: HomeAssistantConfig.tokenConfigured
|
||||
? "A long-lived access token is stored privately"
|
||||
: "Paste a long-lived access token to connect"
|
||||
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Server URL"
|
||||
detail: "The local or remote address of Home Assistant"
|
||||
controlWidth: 330
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: homeUrlInput.activeFocus ? 2 : 1
|
||||
border.color: homeUrlInput.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||
|
||||
TextInput {
|
||||
id: homeUrlInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
activeFocusOnTab: true
|
||||
text: HomeAssistantConfig.url
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: homeUrlInput.text === ""
|
||||
text: "https://homeassistant.local:8123"
|
||||
color: Theme.fgMuted
|
||||
font: homeUrlInput.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Access token"
|
||||
detail: HomeAssistantConfig.tokenConfigured
|
||||
? "Stored · leave blank to keep it"
|
||||
: "Create one in your Home Assistant profile"
|
||||
controlWidth: 330
|
||||
|
||||
PasswordField {
|
||||
id: homeTokenInput
|
||||
anchors.fill: parent
|
||||
placeholder: HomeAssistantConfig.tokenConfigured
|
||||
? "Stored token" : "Long-lived access token"
|
||||
onAccepted: root.saveHomeAssistantConfig()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 7
|
||||
topPadding: 10
|
||||
bottomPadding: 12
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Light entities"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Comma-separated entity IDs. These define the discoverable light catalog."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 72
|
||||
radius: 10
|
||||
color: Theme.alpha(Theme.fg, 0.055)
|
||||
border.width: homeEntitiesInput.activeFocus ? 2 : 1
|
||||
border.color: homeEntitiesInput.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
|
||||
|
||||
TextEdit {
|
||||
id: homeEntitiesInput
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
activeFocusOnTab: true
|
||||
text: HomeAssistantConfig.entities.join(", ")
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: TextEdit.Wrap
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: homeEntitiesInput.text === ""
|
||||
text: "light.living_room, light.kitchen"
|
||||
color: Theme.fgMuted
|
||||
font: homeEntitiesInput.font
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: HomeAssistantConfig.lastError !== ""
|
||||
text: HomeAssistantConfig.lastError
|
||||
color: Theme.danger
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
bottomPadding: 9
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Private configuration"
|
||||
detail: "Saved with owner-only permissions in a private environment file"
|
||||
controlWidth: 216
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
id: clearTokenButton
|
||||
text: "Clear token"
|
||||
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: HomeAssistantConfig.clearToken()
|
||||
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
|
||||
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: saveHomeConfigButton
|
||||
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
|
||||
tone: "accent"
|
||||
enabled: !HomeAssistantConfig.busy
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 0
|
||||
border.color: activeFocus ? Theme.fg : "transparent"
|
||||
onClicked: root.saveHomeAssistantConfig()
|
||||
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
|
||||
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Light catalog"
|
||||
detail: "Light state is read 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
|
||||
canMoveEarlier: index > 0
|
||||
canMoveLater: index < favoritesGrid.count - 1
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Choosing where the weather reading is for.
|
||||
//
|
||||
// A search box rather than latitude and longitude fields: nobody knows their
|
||||
// own coordinates, and a control that demands them is one nobody ever uses. The
|
||||
// coordinates are what actually get stored -- the name is only a label.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
SearchField {
|
||||
id: query
|
||||
width: parent.width
|
||||
placeholder: "Search for a town or city"
|
||||
onTextChanged: Geocoding.search(query.text)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Geocoding.results
|
||||
|
||||
SettingRow {
|
||||
id: place
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: place.modelData.name
|
||||
detail: [place.modelData.admin, place.modelData.country].filter(part => !!part).join(", ")
|
||||
value: place.modelData.latitude.toFixed(2) + ", " + place.modelData.longitude.toFixed(2)
|
||||
controlWidth: 150
|
||||
divider: place.index < Geocoding.results.length - 1
|
||||
activatable: true
|
||||
onActivated: {
|
||||
if (Geocoding.choose(place.modelData))
|
||||
query.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Geocoding.searching || Geocoding.lastError !== ""
|
||||
label: Geocoding.searching ? "Searching…" : "No result"
|
||||
detail: Geocoding.searching ? "" : Geocoding.lastError
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Mouse & Touchpad.
|
||||
//
|
||||
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
|
||||
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
|
||||
// where it was before. These all reach Hyprland's input section, which until now
|
||||
// could only be changed by editing hypr/input.lua by hand.
|
||||
//
|
||||
// The touchpad card renders only when a touchpad is attached. On a desktop it
|
||||
// would be worse than useless -- every switch on it appears to work, because the
|
||||
// preference is stored and the compositor accepts an option for a device class
|
||||
// with no members, so the user would be toggling settings that can never affect
|
||||
// anything with nothing to say so.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Mouse & Touchpad"
|
||||
lede: InputDevices.hasTouchpad
|
||||
? "Pointer behaviour for your mouse and touchpad."
|
||||
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
|
||||
|
||||
SettingsCard {
|
||||
title: "Mouse"
|
||||
subtitle: "Applied to every pointing device that is not a touchpad."
|
||||
|
||||
SliderRow { setting: "pointerSensitivity" }
|
||||
ChoiceRow { setting: "accelProfile" }
|
||||
ToggleRow { setting: "naturalScroll" }
|
||||
SliderRow { setting: "scrollFactor" }
|
||||
ToggleRow { setting: "leftHanded"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: InputDevices.hasTouchpad
|
||||
title: "Touchpad"
|
||||
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
|
||||
|
||||
ToggleRow { setting: "touchpadTapToClick" }
|
||||
ToggleRow { setting: "touchpadNaturalScroll" }
|
||||
ToggleRow { setting: "touchpadDisableWhileTyping" }
|
||||
SliderRow { setting: "touchpadScrollFactor" }
|
||||
ChoiceRow { setting: "touchpadDragLock" }
|
||||
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Pointer"
|
||||
|
||||
ChoiceRow { setting: "followMouse" }
|
||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
|
||||
SliderRow { setting: "cursorSize"; divider: false }
|
||||
}
|
||||
}
|
||||
@@ -2,69 +2,169 @@ import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Notifications & Focus"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
SettingsPage {
|
||||
title: "Notifications & Focus"
|
||||
lede: "Control interruptions without losing useful history."
|
||||
|
||||
SettingsCard {
|
||||
title: "Notifications"
|
||||
|
||||
SettingRow {
|
||||
label: "Do Not Disturb"
|
||||
detail: "Keep notifications in the center but suppress banners"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value }
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.doNotDisturb
|
||||
onToggled: value => Notifs.doNotDisturb = value
|
||||
}
|
||||
SettingRow { label: "Notification history"; detail: "Live notifications retained by Panama"; value: `${Notifs.history.length} items` }
|
||||
SettingRow {
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Notification history"
|
||||
detail: "Live notifications retained by the shell"
|
||||
value: `${Notifs.history.length} items`
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Clear notification history"
|
||||
detail: "Dismiss every item currently in the notification center"
|
||||
divider: false
|
||||
controlWidth: 94
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
|
||||
action: "Clear all"
|
||||
enabled: Notifs.history.length > 0
|
||||
onTriggered: Notifs.dismissAll()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Banner behavior"
|
||||
|
||||
SliderRow { setting: "notificationTimeoutMs" }
|
||||
SliderRow {
|
||||
setting: "notificationTimeoutCriticalMs"
|
||||
zeroLabel: "Never"
|
||||
}
|
||||
SliderRow { setting: "notificationHistoryLimit" }
|
||||
SliderRow { setting: "maxVisibleToasts"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Application rules"
|
||||
subtitle: "Apps appear here after they send a notification."
|
||||
|
||||
TextRow {
|
||||
visible: Notifs.applications.length === 0
|
||||
label: "No applications remembered yet"
|
||||
detail: "Application controls will appear after the first notification arrives."
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Notifs.applications
|
||||
|
||||
Column {
|
||||
required property var modelData
|
||||
|
||||
readonly property var app: modelData
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
label: app.name
|
||||
detail: app.id
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).enabled
|
||||
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Show on lock screen"
|
||||
detail: "Allow this app's notifications on the lock screen"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).showOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Show content on lock screen"
|
||||
detail: "Show message details when this app is visible there"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).showContentOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus sessions"
|
||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
||||
|
||||
SettingRow {
|
||||
label: "Keep the screen awake"
|
||||
detail: Caffeine.enabled
|
||||
? "The display will not blank or lock while this is on"
|
||||
: "Idle timings on Power & Lock apply normally"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Caffeine.enabled
|
||||
onToggled: value => Caffeine.enabled = value
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
id: durationRow
|
||||
|
||||
label: "Default duration"
|
||||
detail: "Used by Super+Shift+F and Quick Settings"
|
||||
controlWidth: 264
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: [25, 45, 60, 90]
|
||||
|
||||
SettingsButton {
|
||||
required property int modelData
|
||||
|
||||
text: `${modelData}m`
|
||||
tone: DesktopPreferences.focusDurationMinutes === modelData ? "accent" : "normal"
|
||||
onClicked: DesktopPreferences.focusDurationMinutes = modelData
|
||||
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
||||
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
||||
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
||||
divider: false
|
||||
controlWidth: 120
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -75,5 +175,3 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Online Accounts.
|
||||
//
|
||||
// GNOME's panel of the same name, except for one step. The accounts are GNOME
|
||||
// Online Accounts objects on D-Bus, the daemon already runs here, and listing,
|
||||
// per-service toggles and removal are all done natively on this page.
|
||||
//
|
||||
// Signing in to an OAuth provider is handed to GNOME's panel, because the
|
||||
// credentials are OAuth tokens and the code that obtains them ships without a
|
||||
// scriptable binding. That is stated on the page rather than hidden behind a
|
||||
// button that looks native, because a hand-off the user does not expect reads
|
||||
// as a bug.
|
||||
//
|
||||
// Accounts needing attention are surfaced first. GOA knows when a token has
|
||||
// expired and nothing outside its own panel says so, which is how an account
|
||||
// quietly stops syncing for weeks.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Online Accounts"
|
||||
lede: OnlineAccounts.attentionCount > 0
|
||||
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
|
||||
? " account needs you to sign in again."
|
||||
: " accounts need you to sign in again.")
|
||||
: "Accounts your mail, calendar, contacts, and files come from."
|
||||
|
||||
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh()
|
||||
|
||||
SettingsCard {
|
||||
visible: !OnlineAccounts.available
|
||||
title: "Accounts unavailable"
|
||||
subtitle: OnlineAccounts.lastError
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
|
||||
title: "No accounts yet"
|
||||
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
|
||||
|
||||
ActionRow {
|
||||
label: "Add an account"
|
||||
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||
action: "Add account"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: OnlineAccounts.accounts
|
||||
|
||||
SettingsCard {
|
||||
id: accountCard
|
||||
required property var modelData
|
||||
|
||||
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
||||
subtitle: accountCard.modelData.needsAttention
|
||||
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
||||
: accountCard.modelData.providerName
|
||||
|
||||
// Only when it is true, because it is the one thing on this page
|
||||
// that needs acting on.
|
||||
ActionRow {
|
||||
visible: accountCard.modelData.needsAttention
|
||||
label: "Sign in again"
|
||||
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
|
||||
action: "Sign in"
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: accountCard.modelData.services
|
||||
|
||||
SettingRow {
|
||||
id: serviceRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: serviceRow.modelData.label
|
||||
detail: serviceRow.modelData.enabled
|
||||
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
|
||||
: "Hidden from applications"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: serviceRow.modelData.enabled
|
||||
onToggled: value => OnlineAccounts.setService(
|
||||
accountCard.modelData.path, serviceRow.modelData.key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Remove this account"
|
||||
detail: "Signs out and removes it from every application that was using it"
|
||||
action: "Remove"
|
||||
divider: false
|
||||
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
|
||||
title: "Add another account"
|
||||
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
|
||||
|
||||
ActionRow {
|
||||
label: "Add an account"
|
||||
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||
action: "Add account"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// A password entry with a reveal toggle.
|
||||
//
|
||||
// Deliberately not the clipboard popover's SearchField with different text: a
|
||||
// Wi-Fi key typed into a field that echoes it is readable by anyone behind you,
|
||||
// and a search glyph in front of a password prompt is simply wrong. Masked by
|
||||
// default, revealable while held, because the reason people want to see it is
|
||||
// to check a character they just typed.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias text: input.text
|
||||
property string placeholder: "Password"
|
||||
property bool revealed: false
|
||||
|
||||
signal accepted
|
||||
|
||||
implicitHeight: 32
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
// Not left at 0 so the focus ring has something to animate. See the note in
|
||||
// modules/clipboard/SearchField.qml.
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durFast }
|
||||
}
|
||||
|
||||
function grab(): void {
|
||||
input.forceActiveFocus();
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
input.text = "";
|
||||
root.revealed = false;
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: input
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 13
|
||||
anchors.right: revealButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
activeFocusOnTab: true
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
echoMode: root.revealed ? TextInput.Normal : TextInput.Password
|
||||
passwordCharacter: "•"
|
||||
clip: true
|
||||
|
||||
onAccepted: root.accepted()
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
visible: input.text === ""
|
||||
text: root.placeholder
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: revealButton
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 24
|
||||
radius: 7
|
||||
color: revealHover.hovered ? Theme.alpha(Theme.fg, 0.12) : "transparent"
|
||||
border.width: 0
|
||||
visible: input.text !== ""
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
// Nerd Font eye / eye-slash. fontMono is used for icon glyphs only.
|
||||
text: root.revealed ? "\u{F070}" : "\u{F06E}"
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 12
|
||||
color: root.revealed ? Theme.accent : Theme.fgMuted
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: revealHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.revealed = !root.revealed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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
|
||||
|
||||
// Probing the power daemon is a D-Bus round trip, so it happens when the
|
||||
// page opens rather than at shell startup.
|
||||
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
|
||||
|
||||
title: "Power & Lock"
|
||||
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
||||
|
||||
// The same profiles GNOME's Power panel offers. Not a stored preference --
|
||||
// the daemon owns it, it survives Panama restarts, and anything else on the
|
||||
// system can change it, so a copy here would drift.
|
||||
SettingsCard {
|
||||
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
|
||||
title: "Power profile"
|
||||
subtitle: PowerProfiles.degraded !== ""
|
||||
? "Performance is limited right now: " + PowerProfiles.degraded
|
||||
: (PowerProfiles.available
|
||||
? "Applies to the whole system and persists across sessions."
|
||||
: PowerProfiles.lastError)
|
||||
|
||||
Repeater {
|
||||
model: PowerProfiles.profiles
|
||||
|
||||
SettingRow {
|
||||
id: profileRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: PowerProfiles.label(profileRow.modelData)
|
||||
detail: PowerProfiles.detail(profileRow.modelData)
|
||||
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
|
||||
divider: profileRow.index < PowerProfiles.profiles.length - 1
|
||||
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
|
||||
onActivated: PowerProfiles.set(profileRow.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Idle behaviour"
|
||||
subtitle: IdleLock.managed
|
||||
? "Idle timings are managed here. Changes take effect immediately."
|
||||
: "hypridle is running its 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: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
|
||||
|
||||
SettingRow {
|
||||
label: "Manage idle timings here"
|
||||
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,166 @@
|
||||
// Privacy & Security.
|
||||
//
|
||||
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
|
||||
// history, trash, and device security. Panama covers the parts it genuinely
|
||||
// owns and is explicit about the parts it does not.
|
||||
//
|
||||
// The file-history and trash settings are the notable omission, and the reason
|
||||
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
|
||||
// which is not running in a Hyprland session. Offering switches for them would
|
||||
// store a preference, change nothing, and give no sign of it -- the exact
|
||||
// failure this codebase keeps designing against. So they are delegated by name
|
||||
// rather than reimplemented as controls that lie.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Privacy & Security"
|
||||
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
|
||||
? "Screen lock, device access, and a machine whose security settings all check out."
|
||||
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||
|
||||
Component.onCompleted: {
|
||||
if (!DeviceSecurity.scanned)
|
||||
DeviceSecurity.refresh();
|
||||
if (!Keyring.scanned)
|
||||
Keyring.refresh();
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Screen lock"
|
||||
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
|
||||
|
||||
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||
ToggleRow { setting: "lockOnSleep"; divider: false }
|
||||
}
|
||||
|
||||
// The login keyring, which nothing else surfaces.
|
||||
//
|
||||
// It is unlocked at sign-in by PAM, so this card normally just confirms
|
||||
// that. It earns its place on the rare occasion it is not: a locked keyring
|
||||
// breaks saved passwords everywhere at once, and does it without ever
|
||||
// saying the word "keyring" -- you get a mail account that will not
|
||||
// authenticate and a git push that cannot find its key.
|
||||
SettingsCard {
|
||||
visible: Keyring.scanned
|
||||
title: "Saved passwords"
|
||||
subtitle: !Keyring.available
|
||||
? "No secret service is answering, so saved passwords are unavailable."
|
||||
: Keyring.locked
|
||||
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
|
||||
: "The login keyring is unlocked, as it is after every normal sign-in."
|
||||
|
||||
// Two rows rather than one with a conditional button: a locked keyring
|
||||
// needs an action, an unlocked one is a statement of fact, and ActionRow
|
||||
// and TextRow already say exactly those two things.
|
||||
ActionRow {
|
||||
visible: Keyring.available && Keyring.locked
|
||||
label: "Login keyring"
|
||||
detail: "Unlock to restore access to stored passwords and keys"
|
||||
action: Keyring.unlocking ? "Waiting…" : "Unlock"
|
||||
enabled: !Keyring.unlocking
|
||||
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
||||
onTriggered: Keyring.unlock()
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: !(Keyring.available && Keyring.locked)
|
||||
label: "Login keyring"
|
||||
detail: Keyring.available
|
||||
? "Unlocked at sign-in by PAM, the same way GNOME does it"
|
||||
: "No secret service is answering on this session"
|
||||
value: Keyring.available ? "Unlocked" : "Unavailable"
|
||||
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
||||
}
|
||||
|
||||
// Only shown when it is true, because it is a diagnostic rather than a
|
||||
// setting: it means the daemon holding your secrets is not the one PAM
|
||||
// started, so whatever unlocked it will not survive a restart.
|
||||
SettingRow {
|
||||
visible: Keyring.replacementDaemon
|
||||
label: "Keyring service"
|
||||
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
|
||||
value: "Replaced"
|
||||
divider: Keyring.lastError !== ""
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: Keyring.lastError !== ""
|
||||
label: "Keyring problem"
|
||||
detail: Keyring.lastError
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Camera & microphone"
|
||||
subtitle: PrivacyState.anyActive
|
||||
? "In use right now — the bar shows an indicator whenever this is true."
|
||||
: "Nothing is using your camera or microphone."
|
||||
|
||||
TextRow {
|
||||
label: "Camera"
|
||||
detail: PrivacyState.cameraActive
|
||||
? "In use by " + (PrivacyState.cameraApp || "an application")
|
||||
: "Not in use"
|
||||
value: PrivacyState.cameraActive ? "Active" : "Idle"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Microphone"
|
||||
detail: PrivacyState.microphoneActive
|
||||
? "In use by " + (PrivacyState.microphoneApp || "an application")
|
||||
: "Not in use"
|
||||
value: PrivacyState.microphoneActive ? "Active" : "Idle"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Screen sharing"
|
||||
detail: PrivacyState.screenSharingActive
|
||||
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
|
||||
: "Not being shared"
|
||||
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Device security"
|
||||
subtitle: DeviceSecurity.attentionCount === 0
|
||||
? "Everything below is in its recommended state."
|
||||
: DeviceSecurity.attentionCount + " item"
|
||||
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
|
||||
|
||||
Repeater {
|
||||
model: DeviceSecurity.facts
|
||||
|
||||
TextRow {
|
||||
id: factRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: factRow.modelData.label
|
||||
detail: factRow.modelData.detail
|
||||
value: factRow.modelData.value
|
||||
divider: factRow.index < DeviceSecurity.facts.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Owned by Fedora"
|
||||
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
|
||||
|
||||
ActionRow {
|
||||
label: "File history & trash"
|
||||
detail: "Opens GNOME Settings, which owns these"
|
||||
action: "Open privacy"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("privacy")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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.
|
||||
|
||||
## System Health
|
||||
|
||||
The stable internal `services` route renders **System Health**. It is reachable
|
||||
from the Settings sidebar and its live 54px footer, the degraded-only bar
|
||||
indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans
|
||||
reserve no bar space and produce no notification.
|
||||
|
||||
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
||||
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
|
||||
explicit **Copy Report**, and bounded `notify-send` only when an external repair
|
||||
fails. For a concise terminal view, run:
|
||||
|
||||
```bash
|
||||
~/.config/quickshell/scripts/panama-doctor --summary
|
||||
```
|
||||
|
||||
The helper diagnoses Panama-owned desktop services, dependencies, links, and
|
||||
configured integrations. It does not read secret values, clipboard or
|
||||
notification contents, calendar events, SSIDs, addresses, or arbitrary command
|
||||
output. Its repair interface is an authored allow-list: it never installs a
|
||||
package, runs `sudo`, deletes user data, or repairs a service Panama does not
|
||||
own. A repair remains degraded until a fresh scan observes recovery.
|
||||
|
||||
The final card is the ownership boundary. Network configuration and the exact
|
||||
Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME
|
||||
Settings because Fedora's system services own those areas.
|
||||
|
||||
## 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 content-identical Quickshell entry can share the live shell's ID.**
|
||||
Quickshell derives the Shell ID from config *content*, not path. Runtime
|
||||
harnesses therefore create a distinct semantic entry file, address that exact
|
||||
file with `qs -p`, and discover its PID from the exact Config path in
|
||||
`qs list --all`. They terminate only that recorded PID with `kill`; never use
|
||||
`qs kill` from a copied configuration.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,75 @@
|
||||
// Region & Language.
|
||||
//
|
||||
// GNOME keeps language and formats under System; the setting itself is the
|
||||
// machine's locale, which localectl owns. Panama does not store a copy of it --
|
||||
// there is exactly one system locale and localectl is where it lives, so a
|
||||
// preference here would be a second source of truth that drifts the moment
|
||||
// anything else changes it.
|
||||
//
|
||||
// Keyboard layout is deliberately not repeated here even though GNOME groups it
|
||||
// with region. It is a compositor setting that applies instantly, it lives on
|
||||
// the Keyboard page with the rest of the typing settings, and showing it twice
|
||||
// invites the two views to disagree.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Region & Language"
|
||||
lede: SystemLocale.pendingRestart
|
||||
? "Your new language applies to programs started after you sign out and back in."
|
||||
: "The language and regional formats this machine uses."
|
||||
|
||||
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
|
||||
|
||||
SettingsCard {
|
||||
title: "Language"
|
||||
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
|
||||
|
||||
TextRow {
|
||||
label: "Current language"
|
||||
detail: SystemLocale.pendingRestart
|
||||
? "Chosen, but not in use until you sign out and back in"
|
||||
: "Used by programs that ask the system what language to speak"
|
||||
value: SystemLocale.currentLabel || "Reading…"
|
||||
}
|
||||
|
||||
SearchPicker {
|
||||
width: parent.width
|
||||
items: SystemLocale.locales
|
||||
current: SystemLocale.current
|
||||
placeholder: "Search languages and regions"
|
||||
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
|
||||
onPicked: value => SystemLocale.set(value)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: SystemLocale.lastError !== ""
|
||||
title: "Language problem"
|
||||
subtitle: SystemLocale.lastError
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Formats"
|
||||
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
|
||||
|
||||
ActionRow {
|
||||
label: "Clock and date display"
|
||||
detail: "How the desktop itself shows the time"
|
||||
action: "Open appearance"
|
||||
onTriggered: ShellState.openSettings("appearance")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Regional formats"
|
||||
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
|
||||
action: "Open system"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("system", "region")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Screen Intelligence"
|
||||
lede: "Turn text and codes on screen into content you can use."
|
||||
|
||||
Component.onCompleted: ScreenIntelligence.refresh()
|
||||
|
||||
Timer {
|
||||
@@ -13,38 +16,9 @@ Item {
|
||||
onTriggered: Capture.openIntelligence()
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text {
|
||||
text: "Screen Intelligence"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 27
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: "Turn text and codes on screen into content you can use."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
bottomPadding: 6
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Read anything on screen"
|
||||
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
|
||||
subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions."
|
||||
|
||||
SettingRow {
|
||||
icon: ""
|
||||
@@ -52,6 +26,7 @@ Item {
|
||||
detail: "Copy, search, translate, or open detected links"
|
||||
controlWidth: 160
|
||||
divider: false
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -67,20 +42,29 @@ Item {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Local recognition"
|
||||
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
|
||||
title: "Capture preferences"
|
||||
subtitle: "Choose where captures go and how recordings are encoded."
|
||||
|
||||
SettingRow {
|
||||
ChoiceRow { setting: "screenshotDir" }
|
||||
ChoiceRow { setting: "recordingDir" }
|
||||
ChoiceRow { setting: "recorderArgs"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Local recognition"
|
||||
subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result."
|
||||
|
||||
TextRow {
|
||||
label: "Text recognition"
|
||||
detail: "Tesseract with the English language model"
|
||||
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
|
||||
}
|
||||
SettingRow {
|
||||
TextRow {
|
||||
label: "QR & barcodes"
|
||||
detail: "ZBar recognizes codes alongside ordinary text"
|
||||
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
|
||||
}
|
||||
SettingRow {
|
||||
TextRow {
|
||||
label: "Privacy"
|
||||
detail: "Only Search, Translate, and Open send the selected result to another application or service"
|
||||
value: "Local first"
|
||||
@@ -90,14 +74,15 @@ Item {
|
||||
|
||||
SettingsCard {
|
||||
title: "Shortcut"
|
||||
SettingRow {
|
||||
|
||||
TextRow {
|
||||
label: "Read a screen selection"
|
||||
detail: "Also available as Read in the Print-screen picker"
|
||||
value: "Super + Shift + S"
|
||||
controlWidth: 190
|
||||
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
|
||||
}
|
||||
SettingRow {
|
||||
TextRow {
|
||||
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
|
||||
label: "Install recognition engines"
|
||||
detail: "sudo dnf install -y tesseract zbar"
|
||||
@@ -106,5 +91,3 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// A searchable list of choices, for settings with far too many values to put in
|
||||
// a dropdown and no useful preview to show.
|
||||
//
|
||||
// SearchPicker {
|
||||
// items: Locale.locales // [{ value, label, detail }]
|
||||
// current: Locale.current
|
||||
// placeholder: "Search languages"
|
||||
// onPicked: value => Locale.set(value)
|
||||
// }
|
||||
//
|
||||
// FontPicker is the same idea specialised: it draws each candidate in its own
|
||||
// family, which is the whole reason it is a list rather than a text field. This
|
||||
// one is for choices whose only useful preview is their name.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
// SearchField lives with the clipboard module, which is where it was first
|
||||
// needed; FontPicker imports it from the same place.
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// [{ value, label, detail }]
|
||||
property var items: []
|
||||
property string current: ""
|
||||
property string emptyText: "Nothing to choose from"
|
||||
property string placeholder: "Search"
|
||||
|
||||
signal picked(string value)
|
||||
|
||||
spacing: 0
|
||||
|
||||
// Capped and filtered rather than listing everything: 327 locales inside a
|
||||
// page that is already scrolling is worse than a search box.
|
||||
readonly property var matches: {
|
||||
const needle = filter.text.trim().toLowerCase();
|
||||
const list = root.items.filter(item =>
|
||||
needle === ""
|
||||
? true
|
||||
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|
||||
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
|
||||
|
||||
// The current choice stays visible while browsing, so it is always
|
||||
// clear what would be replaced.
|
||||
const selected = list.find(item => item.value === root.current);
|
||||
if (needle === "" && selected !== undefined)
|
||||
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
|
||||
return list.slice(0, 12);
|
||||
}
|
||||
|
||||
SearchField {
|
||||
id: filter
|
||||
width: parent.width
|
||||
placeholder: root.placeholder
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.matches
|
||||
|
||||
SettingRow {
|
||||
id: candidate
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool selected: candidate.modelData.value === root.current
|
||||
|
||||
label: candidate.modelData.label
|
||||
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
|
||||
controlWidth: 120
|
||||
divider: candidate.index < root.matches.length - 1
|
||||
activatable: !candidate.selected
|
||||
onActivated: root.picked(candidate.modelData.value)
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: candidate.selected
|
||||
text: "✓"
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize + 2
|
||||
color: Theme.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.matches.length === 0
|
||||
label: root.emptyText
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
function status(active: bool): string { return active ? "Running" : "Stopped"; }
|
||||
|
||||
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
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() }
|
||||
}
|
||||
Text { text: "A clear view of the background tools that make the desktop feel complete."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Your services"
|
||||
SettingRow {
|
||||
label: "Nextcloud"
|
||||
detail: "File synchronization and tray status"
|
||||
controlWidth: 190
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") }
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: "RustDesk"
|
||||
detail: "Remote access through the enabled system service"
|
||||
controlWidth: 190
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") }
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: "KDE Connect"
|
||||
detail: "Phone pairing, clipboard, files, and remote controls"
|
||||
divider: false
|
||||
controlWidth: 190
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Desktop foundation"
|
||||
SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) }
|
||||
SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) }
|
||||
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Fedora system settings"
|
||||
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
||||
SettingRow {
|
||||
label: "Network, Bluetooth, printers, users, and accounts"
|
||||
detail: "GNOME Settings remains searchable from the launcher too"
|
||||
divider: false
|
||||
controlWidth: 122
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@ Item {
|
||||
property bool divider: true
|
||||
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
|
||||
implicitHeight: Math.max(56, copy.implicitHeight + 20)
|
||||
|
||||
@@ -85,4 +91,27 @@ Item {
|
||||
visible: root.divider
|
||||
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,24 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property var hostWindow: null
|
||||
readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready
|
||||
&& pageLoader.item
|
||||
&& pageLoader.item.objectName === "home-phone-page"
|
||||
? pageLoader.item.pageDiagnostics
|
||||
: ({})
|
||||
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
|
||||
&& pageLoader.item
|
||||
&& pageLoader.item.objectName === "system-health-page"
|
||||
? pageLoader.item.uiDiagnostics()
|
||||
: ({})
|
||||
|
||||
function requestHealthAction(id: string): bool {
|
||||
if (pageLoader.status !== Loader.Ready
|
||||
|| !pageLoader.item
|
||||
|| pageLoader.item.objectName !== "system-health-page")
|
||||
return false;
|
||||
return pageLoader.item.activateRenderedAction(id);
|
||||
}
|
||||
|
||||
color: Theme.bg
|
||||
radius: 18
|
||||
@@ -35,7 +53,7 @@ Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Panama Settings"
|
||||
text: "Settings"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
@@ -88,12 +106,21 @@ Rectangle {
|
||||
case "appearance": return appearancePage;
|
||||
case "displays": return displaysPage;
|
||||
case "connectivity": return connectivityPage;
|
||||
case "home-phone": return homePhonePage;
|
||||
case "desktop": return desktopPage;
|
||||
case "sound": return soundPage;
|
||||
case "notifications": return notificationsPage;
|
||||
case "screen-intelligence": return screenIntelligencePage;
|
||||
case "shortcuts": return shortcutsPage;
|
||||
case "services": return servicesPage;
|
||||
case "mouse": return mousePage;
|
||||
case "privacy": return privacyPage;
|
||||
case "region": return regionPage;
|
||||
case "accounts": return onlineAccountsPage;
|
||||
case "accessibility": return accessibilityPage;
|
||||
case "power": return powerPage;
|
||||
case "datetime": return dateTimePage;
|
||||
case "applications": return applicationsPage;
|
||||
case "services": return healthPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
}
|
||||
@@ -130,15 +157,24 @@ Rectangle {
|
||||
}
|
||||
|
||||
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: displaysPage; DisplaysPage {} }
|
||||
Component { id: connectivityPage; ConnectivityPage {} }
|
||||
Component { id: homePhonePage; HomePhonePage {} }
|
||||
Component { id: desktopPage; DesktopPage {} }
|
||||
Component { id: soundPage; SoundPage {} }
|
||||
Component { id: notificationsPage; NotificationsPage {} }
|
||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||
Component { id: servicesPage; ServicesPage {} }
|
||||
Component { id: mousePage; MousePage {} }
|
||||
Component { id: privacyPage; PrivacyPage {} }
|
||||
Component { id: regionPage; RegionPage {} }
|
||||
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||
Component { id: healthPage; HealthPage {} }
|
||||
Component { id: aboutPage; AboutPage {} }
|
||||
|
||||
Shortcut {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
@@ -8,18 +9,39 @@ Rectangle {
|
||||
property string query: searchInput.text.trim().toLowerCase()
|
||||
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: [
|
||||
{ page: "home", label: "Home", icon: "\u{F02DC}" },
|
||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
|
||||
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
|
||||
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
|
||||
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
|
||||
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
|
||||
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
|
||||
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
|
||||
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
|
||||
{ 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: "System Health", icon: "\u{F0493}" },
|
||||
{ page: "about", label: "About", icon: "\u{F02FD}" }
|
||||
]
|
||||
|
||||
width: 272
|
||||
@@ -27,18 +49,16 @@ Rectangle {
|
||||
border.width: 0
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
id: sidebarHeader
|
||||
|
||||
Text {
|
||||
text: "PANAMA"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 2.2
|
||||
}
|
||||
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
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
@@ -90,13 +110,117 @@ Rectangle {
|
||||
onClicked: searchInput.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: sidebarScroll
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: sidebarHeader.bottom
|
||||
anchors.bottom: healthFooter.top
|
||||
anchors.leftMargin: 18
|
||||
anchors.rightMargin: 18
|
||||
anchors.topMargin: 12
|
||||
anchors.bottomMargin: 12
|
||||
contentWidth: width
|
||||
contentHeight: scrollContent.implicitHeight
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: scrollContent
|
||||
|
||||
width: sidebarScroll.width
|
||||
|
||||
// ── Search results ──────────────────────────────────────────────
|
||||
// Typing searches the settings themselves, not page names.
|
||||
Column {
|
||||
id: searchResults
|
||||
|
||||
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 {
|
||||
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 {
|
||||
id: navItem
|
||||
@@ -159,14 +283,43 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: healthFooter
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 54
|
||||
color: Theme.alpha(Theme.bg, 0.35)
|
||||
border.width: 0
|
||||
activeFocusOnTab: true
|
||||
color: footerTap.hovered || activeFocus
|
||||
? Theme.alpha(Theme.fg, 0.07)
|
||||
: Theme.alpha(Theme.bg, 0.35)
|
||||
border.width: activeFocus ? 2 : 0
|
||||
border.color: Theme.accent
|
||||
|
||||
function footerText(): string {
|
||||
if (Health.checks.length === 0)
|
||||
return Health.diagnosticUnavailable ? "Health check unavailable" : "Checking the desktop";
|
||||
if (Health.status === "error")
|
||||
return "Desktop needs attention";
|
||||
if (Health.status === "warning") {
|
||||
const count = Health.summary.warnings + Health.summary.errors;
|
||||
return count + (count === 1 ? " health observation" : " health observations");
|
||||
}
|
||||
return "Desktop is healthy";
|
||||
}
|
||||
|
||||
function footerColor(): color {
|
||||
if (Health.checks.length === 0)
|
||||
return Health.diagnosticUnavailable ? Theme.danger : Theme.fgMuted;
|
||||
if (Health.status === "error")
|
||||
return Theme.danger;
|
||||
if (Health.status === "warning")
|
||||
return Theme.warn;
|
||||
return Theme.ok;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 7
|
||||
@@ -175,17 +328,28 @@ Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 19
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Theme.ok
|
||||
color: healthFooter.footerColor()
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 36
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Panama desktop is healthy"
|
||||
text: healthFooter.footerText()
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
id: footerTap
|
||||
onTapped: root.pageRequested("services")
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: root.pageRequested("services")
|
||||
Keys.onSpacePressed: root.pageRequested("services")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import qs.services
|
||||
FloatingWindow {
|
||||
id: root
|
||||
|
||||
title: "Panama Settings"
|
||||
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
||||
|
||||
title: "Settings"
|
||||
visible: ShellState.settingsOpen
|
||||
implicitWidth: 1120
|
||||
implicitHeight: 760
|
||||
@@ -23,6 +25,7 @@ FloatingWindow {
|
||||
}
|
||||
|
||||
SettingsShell {
|
||||
id: settingsShell
|
||||
anchors.fill: parent
|
||||
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 qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
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 {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
// The chord of the bind currently being re-recorded, in the Lua form; empty
|
||||
// when nothing is being captured. Held here rather than per row so that
|
||||
// starting a new capture cancels any other.
|
||||
property string capturingChord: ""
|
||||
|
||||
Column {
|
||||
id: content
|
||||
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 }
|
||||
title: "Input & Shortcuts"
|
||||
lede: "The Forge mental model, carried forward into native tiling."
|
||||
|
||||
SettingsCard {
|
||||
title: "Panama shortcuts"
|
||||
Repeater {
|
||||
model: root.shortcuts
|
||||
SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
label: modelData.action
|
||||
value: modelData.key
|
||||
controlWidth: 210
|
||||
divider: index < root.shortcuts.length - 1
|
||||
}
|
||||
}
|
||||
title: "Keyboard"
|
||||
|
||||
// These were read-only text, on the grounds that a layout change needed
|
||||
// a compositor reload. It does not: setting input:kb_variant through
|
||||
// hl.config re-keymaps attached keyboards immediately -- verified by
|
||||
// watching active_keymap on a real keyboard change and change back. So
|
||||
// they are real controls.
|
||||
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
||||
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||
SliderRow { setting: "keyRepeatDelay" }
|
||||
SliderRow { setting: "keyRepeatRate" }
|
||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Hardware input"
|
||||
SettingRow {
|
||||
|
||||
ActionRow {
|
||||
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
|
||||
controlWidth: 126
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") }
|
||||
onTriggered: 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 the desktop'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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property bool output: true
|
||||
readonly property var nodes: AudioDevices.nodes(root.output)
|
||||
readonly property var current: AudioDevices.current(root.output)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.nodes
|
||||
|
||||
SoundDeviceRow {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
node: modelData
|
||||
output: root.output
|
||||
selected: modelData === root.current
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.nodes.length === 0
|
||||
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: 18
|
||||
bottomPadding: 18
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user