Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fcaada7e8 | ||
|
|
588dec4adc | ||
|
|
d96863b687 | ||
|
|
8156fc47ca | ||
|
|
9bc771bcca | ||
|
|
fe74a82a46 | ||
|
|
ac231eeb54 | ||
|
|
8cf03d4529 | ||
|
|
2a716dac9e | ||
|
|
719ef2f38e | ||
|
|
9ba224d776 | ||
|
|
8b59b78d9f | ||
|
|
e6b4d3c1a1 | ||
|
|
70d8d32ee2 | ||
|
|
c37ca3baee | ||
|
|
bd5d030d91 | ||
|
|
63f4bdb547 | ||
|
|
1a93a788fa | ||
|
|
1debd37d01 | ||
|
|
4341628b2f | ||
|
|
7777f5c1aa | ||
|
|
bc7bf3185c | ||
|
|
f3d91c14a1 | ||
|
|
21beb066e1 | ||
|
|
d2898d8806 | ||
|
|
0c3935f818 | ||
|
|
70a9d27c98 | ||
|
|
5cf2943ccf | ||
|
|
d0d9e196b0 | ||
|
|
2613768efd | ||
|
|
101797f8f0 | ||
|
|
b9336d5930 | ||
|
|
c36e423890 | ||
|
|
1c12892252 | ||
|
|
52818b7290 | ||
|
|
c8004e400a | ||
|
|
3e98cc2216 | ||
|
|
a80a6f4dda | ||
|
|
3520700983 | ||
|
|
a845534110 | ||
|
|
db34b1e6ed | ||
|
|
0f3bddc452 | ||
|
|
9aea519e54 | ||
|
|
e5a2a430e0 | ||
|
|
bfa9c58b09 | ||
|
|
1360a80f07 | ||
|
|
8be3fc2fdd | ||
|
|
2d69ce7648 | ||
|
|
d87f4b6d6a |
+3
-2
@@ -14,10 +14,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
# Generated from the color scheme; machine state, not configuration.
|
||||
/config/dot/kitty/current-theme.conf
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
# Generated from the color 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
|
||||
/config/dot/hypr/hyprlock.conf
|
||||
|
||||
@@ -152,11 +152,14 @@ case "$cmd" in
|
||||
|
||||
# 2.4) Run flatpak updates (user then system)
|
||||
flatpak update -y
|
||||
sudo flatpak update
|
||||
sudo flatpak update -y
|
||||
|
||||
# 2.5) Optional firmware via fwupd
|
||||
if $firmware; then
|
||||
sudo fwupdmgr refresh
|
||||
# fwupdmgr exits non-zero when metadata is already current -- that is
|
||||
# not an error, but under 'set -e' it would abort the script before
|
||||
# 'fwupdmgr update' ever runs.
|
||||
sudo fwupdmgr refresh || true
|
||||
sudo fwupdmgr update
|
||||
fi
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# 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
|
||||
# background. Same colors 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# The theme name and dark preference below follow Panama's color
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# The theme name and dark preference below follow Panama's color
|
||||
# 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
|
||||
|
||||
@@ -15,15 +15,15 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Hidden applications | GNOME App Hider list in Vicinae provider config | Live |
|
||||
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
|
||||
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
|
||||
| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
||||
| Dock | Quickshell dock with GNOME favourites and intellihide | Live |
|
||||
| Minimize | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
||||
| Dock | Quickshell dock with GNOME favorites and intellihide | Live |
|
||||
| Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live |
|
||||
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live |
|
||||
| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live |
|
||||
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
|
||||
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
|
||||
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
|
||||
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live |
|
||||
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favorites, 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 |
|
||||
@@ -36,7 +36,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Removable media | udiskie plus udisks notifications | Live |
|
||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | 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 settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labeled 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
|
||||
@@ -59,7 +59,7 @@ 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
|
||||
final System Health card hands network settings, users, sharing, color
|
||||
profiles, and digital wellbeing to their exact GNOME Settings panels rather
|
||||
than presenting inert Hyprland controls.
|
||||
|
||||
@@ -77,9 +77,9 @@ than presenting inert Hyprland controls.
|
||||
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
||||
| 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 |
|
||||
| Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
|
||||
| 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 |
|
||||
| Home Assistant | Replaced by secure favorites 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 |
|
||||
@@ -111,4 +111,4 @@ menu for a minority, which is worse than omitting it. Relevant references:
|
||||
- <https://github.com/hyprwm/Hyprland/discussions/1358>
|
||||
|
||||
A future focused-window menu could expose compositor actions such as float,
|
||||
move, fullscreen, and close. It should not be labelled as an application menu.
|
||||
move, fullscreen, and close. It should not be labeled as an application menu.
|
||||
|
||||
+14
-14
@@ -30,7 +30,7 @@ Don't "fix" them.
|
||||
| `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 |
|
||||
| `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
|
||||
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
|
||||
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
|
||||
| `keybinds.lua` | The full keymap |
|
||||
@@ -87,7 +87,7 @@ 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;
|
||||
wrong-typed settings file costs you your customizations and nothing else;
|
||||
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
|
||||
accepts the config in each of those states.
|
||||
|
||||
@@ -163,7 +163,7 @@ four places, and nowhere else —
|
||||
dock, popovers), running blue on the left to orchid on the right and fading
|
||||
out before the corners. See `quickshell/widgets/PrismEdge.qml`.
|
||||
2. The focused window's border — `general.col.active_border`, blue→orchid at
|
||||
115°. Unfocused windows get no colour at all, because the gradient only
|
||||
115°. Unfocused windows get no color at all, because the gradient only
|
||||
means something if one window on screen is wearing it.
|
||||
3. The active workspace pill in the bar.
|
||||
4. Slider fills.
|
||||
@@ -179,9 +179,9 @@ which is the opposite of the intent.
|
||||
|
||||
The Nerd Font is still used, but only to draw **icon glyphs** — it is the
|
||||
pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||||
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolour. Where a
|
||||
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolor. Where a
|
||||
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
|
||||
paints a palette colour through the icon's alpha.
|
||||
paints a palette color through the icon's alpha.
|
||||
|
||||
Anything whose digits change in place — the clock, the vitals percentages, the
|
||||
recording timer, the selection readout — sets `font.features:
|
||||
@@ -247,7 +247,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + SHIFT + H/J/K/L` | Move window |
|
||||
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
||||
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
|
||||
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter |
|
||||
| `SUPER + SHIFT + I/U` · `,/N` | Taller · shorter |
|
||||
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
|
||||
| `SUPER + Q` | Close |
|
||||
| `SUPER + U` | Fullscreen |
|
||||
@@ -256,7 +256,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down |
|
||||
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
|
||||
| `SUPER + SHIFT + grave` | Last window |
|
||||
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimise to scratchpad |
|
||||
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimize to scratchpad |
|
||||
|
||||
### Workspaces (dynamic, like GNOME)
|
||||
| Key | Action |
|
||||
@@ -270,19 +270,19 @@ The mental model is unchanged from Forge:
|
||||
### Launcher and shell
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite |
|
||||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favorite |
|
||||
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
|
||||
| `SUPER + V` | Clipboard history |
|
||||
| `SUPER + .` | Emoji picker |
|
||||
| `SUPER + S` | Quick settings |
|
||||
| `SUPER + I` | Settings |
|
||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||
| `SUPER + B` | Notification centre |
|
||||
| `SUPER + B` | Notification center |
|
||||
| `SUPER + grave` | Workspace overview |
|
||||
| `Print` | Screenshot / record picker |
|
||||
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
|
||||
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
|
||||
| `SUPER + SHIFT + P` | Colour picker |
|
||||
| `SUPER + SHIFT + P` | Color picker |
|
||||
| `CTRL + ALT + L` | Lock (SUPER+L is "focus right") |
|
||||
| `CTRL + ALT + Delete` | Power menu |
|
||||
|
||||
@@ -325,8 +325,8 @@ 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,
|
||||
Home shows the first four selected favorites at rest and every selected light
|
||||
when expanded. Use **Settings → Home & Phone** to choose favorites,
|
||||
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
|
||||
@@ -334,7 +334,7 @@ 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
|
||||
fallback. Favorites, 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.
|
||||
@@ -361,7 +361,7 @@ These are the places a 1:1 port was impossible, and what was done instead:
|
||||
|
||||
- **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an
|
||||
axis and lets the layout pick the edge. The eight Forge keys collapse to four
|
||||
behaviours, keeping the horizontal/vertical and grow/shrink pairing.
|
||||
behaviors, keeping the horizontal/vertical and grow/shrink pairing.
|
||||
- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a
|
||||
modifier misfires when you're quick with SUPER combos.
|
||||
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap.
|
||||
|
||||
@@ -23,8 +23,16 @@ hl.on("hyprland.start", function()
|
||||
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
|
||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
|
||||
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
|
||||
-- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
|
||||
-- commands without waiting for them to finish, so the dbus-update call
|
||||
-- above racing this one is not safe to assume complete -- a lost race
|
||||
-- leaves the Condition unmet and the unit silently never starts (exit 0,
|
||||
-- no error). hypridle is the only listener for the logind Lock signal,
|
||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||
-- synchronously in the same shell invocation first so the Condition
|
||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
|
||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||
@@ -39,18 +47,22 @@ hl.on("hyprland.start", function()
|
||||
-- Keyring unlock, for Nextcloud and Bitwarden credential storage.
|
||||
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
|
||||
|
||||
-- Tray applications carried over from ~/.config/autostart. These need the
|
||||
-- Quickshell tray (an SNI host) to be up, hence starting after it.
|
||||
-- Nextcloud and Bitwarden are NOT started here. Both ship a
|
||||
-- ~/.config/autostart/*.desktop entry, and systemd's own
|
||||
-- systemd-xdg-autostart-generator turns every such entry into a
|
||||
-- `PartOf=graphical-session.target` unit (`[email protected]`,
|
||||
-- `[email protected]`) that fires once the uwsm
|
||||
-- session brings up graphical-session.target -- confirmed live via
|
||||
-- `systemctl --user list-units 'app-*@autostart.service'`. An explicit
|
||||
-- second launch here used to duplicate that: for Bitwarden specifically,
|
||||
-- each `flatpak run` gets its own sandbox instance, so the two starts
|
||||
-- didn't just race, they left two competing processes fighting over the
|
||||
-- app's single-instance lock, with neither reliably owning a usable
|
||||
-- window. Trust the generator instead of re-launching.
|
||||
--
|
||||
-- Commands are copied verbatim from the GNOME .desktop files rather than
|
||||
-- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and
|
||||
-- its autostart entry launches a specific script with --autostart.
|
||||
--
|
||||
-- RustDesk is deliberately absent: it ships an enabled *system* service
|
||||
-- (`rustdesk --service`) that spawns --server and --tray for the session on
|
||||
-- its own. Starting it here as well would give you two trays.
|
||||
hl.exec_cmd("nextcloud --background")
|
||||
hl.exec_cmd("flatpak run --command=bitwarden.sh com.bitwarden.desktop --autostart")
|
||||
-- RustDesk is deliberately absent too: it ships an enabled *system*
|
||||
-- service (`rustdesk --service`) that spawns --server and --tray for the
|
||||
-- session on its own. Starting it here as well would give you two trays.
|
||||
end)
|
||||
|
||||
hl.on("hyprland.shutdown", function()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Environment
|
||||
--
|
||||
-- hl.env() sets variables before the display server initialises, which is what
|
||||
-- hl.env() sets variables before the display server initializes, which is what
|
||||
-- makes it valid for AQ_* and cursor variables.
|
||||
--
|
||||
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
|
||||
@@ -58,7 +58,7 @@ hl.env("QT_QPA_PLATFORM", "wayland;xcb")
|
||||
|
||||
-- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3
|
||||
-- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than
|
||||
-- approximating it -- and it gives them GTK file/font/colour dialogs. It also
|
||||
-- approximating it -- and it gives them GTK file/font/color dialogs. It also
|
||||
-- covers Qt5 and Qt6 with one value.
|
||||
--
|
||||
-- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hypridle — idle behaviour
|
||||
# hypridle — idle behavior
|
||||
#
|
||||
# Mirrors the GNOME power profile this replaces, exactly:
|
||||
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
general {
|
||||
# `pidof` guard prevents stacking lockers if this fires twice.
|
||||
lock_cmd = pidof hyprlock || hyprlock
|
||||
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
|
||||
before_sleep_cmd = loginctl lock-session
|
||||
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Shared preferences first: looks/input/monitors read their defaults through it.
|
||||
-- It never raises, so a missing or malformed settings file costs customisations
|
||||
-- It never raises, so a missing or malformed settings file costs customizations
|
||||
-- and nothing else.
|
||||
require("prefs")
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hyprlock — lock screen
|
||||
# hyprlock — lock screen.
|
||||
#
|
||||
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
|
||||
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
|
||||
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||
# next color scheme change.
|
||||
#
|
||||
# The colors here follow the desktop's light/dark setting. They used to be
|
||||
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
|
||||
# stayed dark when everything else went light.
|
||||
#
|
||||
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
|
||||
# template carries "R, G, B" triples where the rest of Panama uses hex. The two
|
||||
# Pango markup values are the exception and want ##rrggbb.
|
||||
#
|
||||
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -45,7 +54,7 @@ background {
|
||||
vibrancy_darkness = 0.05
|
||||
|
||||
# Shown if the screenshot is unavailable.
|
||||
color = rgba(34, 36, 54, 1.0)
|
||||
color = rgba(@BG@, 1.0)
|
||||
|
||||
zindex = -1
|
||||
}
|
||||
@@ -54,7 +63,7 @@ background {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:1000] date +"%-I:%M"
|
||||
color = rgba(200, 211, 245, 1.0)
|
||||
color = rgba(@FG@, 1.0)
|
||||
font_size = 120
|
||||
font_family = Adwaita Sans Light
|
||||
position = 0, 260
|
||||
@@ -65,7 +74,7 @@ label {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:60000] date +"%A, %B %-d"
|
||||
color = rgba(130, 139, 184, 1.0)
|
||||
color = rgba(@MUTED@, 1.0)
|
||||
font_size = 24
|
||||
font_family = Adwaita Sans
|
||||
position = 0, 160
|
||||
@@ -84,18 +93,18 @@ input-field {
|
||||
outline_thickness = 2
|
||||
rounding = 26
|
||||
|
||||
outer_color = rgba(130, 170, 255, 0.9)
|
||||
inner_color = rgba(46, 47, 61, 0.85)
|
||||
font_color = rgba(200, 211, 245, 1.0)
|
||||
check_color = rgba(130, 170, 255, 1.0)
|
||||
fail_color = rgba(255, 117, 127, 1.0)
|
||||
outer_color = rgba(@ACCENT@, 0.9)
|
||||
inner_color = rgba(@FIELD@, 0.85)
|
||||
font_color = rgba(@FG@, 1.0)
|
||||
check_color = rgba(@ACCENT@, 1.0)
|
||||
fail_color = rgba(@ERROR@, 1.0)
|
||||
|
||||
dots_size = 0.25
|
||||
dots_spacing = 0.3
|
||||
dots_center = true
|
||||
|
||||
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
|
||||
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
|
||||
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
|
||||
fade_on_empty = false
|
||||
hide_input = false
|
||||
@@ -105,7 +114,7 @@ input-field {
|
||||
label {
|
||||
monitor =
|
||||
text = $USER
|
||||
color = rgba(200, 211, 245, 0.9)
|
||||
color = rgba(@FG@, 0.9)
|
||||
font_size = 16
|
||||
font_family = Adwaita Sans
|
||||
position = 0, -110
|
||||
@@ -9,7 +9,7 @@
|
||||
# hyprland-guiutils hyprland-dialog / hyprland-run
|
||||
# hyprpolkitagent authentication prompts (via hyprland-qt-support)
|
||||
#
|
||||
# Colours are Tokyo Night Moon, matching quickshell/config/Theme.qml and
|
||||
# Colors are Tokyo Night Moon, matching quickshell/config/Theme.qml and
|
||||
# hypr/looks.lua. Format is hyprlang; path is fixed at
|
||||
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,10 +22,10 @@ hl.config({
|
||||
repeat_rate = prefs.get("keyRepeatRate", 33),
|
||||
|
||||
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
|
||||
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
|
||||
-- previously claimed 1 was "click to focus, GNOME's behavior", 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
|
||||
-- so click-to-focus is 0. Changing the shipped value is a behavior
|
||||
-- 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),
|
||||
|
||||
@@ -118,7 +118,7 @@ bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { descriptio
|
||||
-- which Hyprland has no equivalent for.
|
||||
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
|
||||
|
||||
-- Notification centre.
|
||||
-- Notification center.
|
||||
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
|
||||
|
||||
-- Screenshot / screen record. One key, then pick screen / window / region and
|
||||
@@ -133,8 +133,8 @@ bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description =
|
||||
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.
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
|
||||
-- Color picker: copies the hex under the cursor to the clipboard.
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
|
||||
|
||||
-- ── Window management ───────────────────────────────────────────────────────
|
||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||
@@ -182,7 +182,7 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
|
||||
--
|
||||
-- Forge resized one named edge at a time. Hyprland resizes the active window
|
||||
-- along an axis and lets the layout decide which edge actually moves, so the
|
||||
-- eight Forge keys collapse onto four behaviours. The pairing is kept
|
||||
-- eight Forge keys collapse onto four behaviors. The pairing is kept
|
||||
-- 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
|
||||
@@ -192,12 +192,27 @@ bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative =
|
||||
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" })
|
||||
-- SUPER+SHIFT+P was double-bound with the color picker above; moved to
|
||||
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
|
||||
-- uses rather than landing on an arbitrary free key.
|
||||
bind(mod .. " + SHIFT + Comma", 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).
|
||||
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" })
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
-- showing what you are choosing between.
|
||||
--
|
||||
-- The gesture needs three binds, not two. Tab steps the selection, and the
|
||||
-- switch is only COMMITTED when the modifier is released -- which is the sole
|
||||
-- way the compositor can tell the gesture is finished. That release bind is on
|
||||
-- the bare modifier, so it fires on EVERY Super release in the session; the
|
||||
-- handler returns immediately when no switch is open, which is why this is
|
||||
-- affordable.
|
||||
--
|
||||
-- The release bind carries no description on purpose: it is not a shortcut
|
||||
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
|
||||
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
|
||||
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
|
||||
-- Jump back to the previously focused window.
|
||||
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
||||
|
||||
@@ -211,6 +226,17 @@ bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description
|
||||
-- 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.
|
||||
|
||||
-- Behavior for the relative/cyclic binds below. These are Hyprland's own
|
||||
-- `binds:` options -- not part of general/dwindle -- and have no other home
|
||||
-- in the config, so they are read here rather than in looks.lua.
|
||||
hl.config({
|
||||
binds = {
|
||||
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
|
||||
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
|
||||
},
|
||||
})
|
||||
|
||||
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" })
|
||||
@@ -233,18 +259,18 @@ end
|
||||
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.
|
||||
-- Minimize, as far as Hyprland has one.
|
||||
--
|
||||
-- Hyprland has no minimise: it receives the request (the binary has
|
||||
-- Hyprland has no minimize: it receives the request (the binary has
|
||||
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
|
||||
-- no dispatcher, no config option and not even an event to hook, so titlebar
|
||||
-- minimise buttons are inert and cannot be made to work. A tiling WM has no
|
||||
-- minimize buttons are inert and cannot be made to work. A tiling WM has no
|
||||
-- iconified state and no taskbar to restore from.
|
||||
--
|
||||
-- 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.
|
||||
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" })
|
||||
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
|
||||
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })
|
||||
|
||||
-- ── Session ─────────────────────────────────────────────────────────────────
|
||||
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
|
||||
|
||||
+42
-15
@@ -1,8 +1,8 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Look and feel -- Tokyo Night Moon
|
||||
--
|
||||
-- Colours here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent #82aaff borders / focus
|
||||
-- Colors here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
|
||||
-- bg #222436 base
|
||||
--
|
||||
-- Performance note: every animation below is event-driven. Nothing uses the
|
||||
@@ -14,6 +14,30 @@
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- Mirrors config/Theme.qml's `accents` map. Lua has no import path into a QML
|
||||
-- singleton, so the hex pairs are restated here -- just the four hex strings
|
||||
-- each named accent needs, not the labels, which stay UI-only.
|
||||
local accents = {
|
||||
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
|
||||
orchid = { dark = "c099ff", darkSecondary = "fca7ea", light = "7847bd", lightSecondary = "9854f1" },
|
||||
teal = { dark = "86e1fc", darkSecondary = "82aaff", light = "007197", lightSecondary = "2e7de9" },
|
||||
green = { dark = "c3e88d", darkSecondary = "86e1fc", light = "587539", lightSecondary = "007197" },
|
||||
amber = { dark = "ffc777", darkSecondary = "ff966c", light = "8c6c3e", lightSecondary = "b15c00" },
|
||||
orange = { dark = "ff966c", darkSecondary = "ff757f", light = "b15c00", lightSecondary = "c64343" },
|
||||
rose = { dark = "ff757f", darkSecondary = "c099ff", light = "f52a65", lightSecondary = "9854f1" },
|
||||
slate = { dark = "828bb8", darkSecondary = "82aaff", light = "6172b0", lightSecondary = "2e7de9" },
|
||||
}
|
||||
|
||||
-- The accent pair a fresh session or `hyprctl reload` starts from.
|
||||
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
|
||||
-- data, once the shell settles (~1.2s after startup -- see its `settle`
|
||||
-- Timer). This table exists only so the compositor is never observably blue
|
||||
-- for a non-blue accent during the gap before that first live apply.
|
||||
local accentScheme = prefs.get("colorScheme", "dark")
|
||||
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
|
||||
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
|
||||
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
gaps_in = prefs.get("gapsIn", 5),
|
||||
@@ -22,16 +46,17 @@ hl.config({
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
||||
-- is visible on both a tall and a wide window. Same two colours as
|
||||
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
|
||||
-- tmux theme this palette came from.
|
||||
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.
|
||||
-- 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.
|
||||
-- The focused accent role, on a diagonal so the pair is visible
|
||||
-- on both a tall and a wide window. Driven by the chosen
|
||||
-- accentName (see the `accents` table above); services/
|
||||
-- ColorScheme.qml applies the same values live, and restates them
|
||||
-- from Theme.accent/Theme.accentSecondary on every scheme change
|
||||
-- too, since each accent carries a separate pair per scheme.
|
||||
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
|
||||
-- The neutral inactive role follows the color scheme because a
|
||||
-- dark neutral disappears against a light desktop.
|
||||
-- services/ColorScheme.qml applies the same values live; this is
|
||||
-- the value a fresh session starts from.
|
||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||
},
|
||||
@@ -105,11 +130,13 @@ hl.config({
|
||||
-- New in 0.56. Kept deliberately faint: in this direction the gradient
|
||||
-- border is the signature, and a strong halo would compete with it.
|
||||
-- This is just enough to lift the focused window off the wallpaper.
|
||||
-- Derived from the same accent as active_border above, not hardcoded,
|
||||
-- so the halo never disagrees with the border it surrounds.
|
||||
glow = {
|
||||
enabled = prefs.get("glowEnabled", true),
|
||||
range = prefs.get("glowRange", 8),
|
||||
render_power = 2,
|
||||
color = "rgba(82aaff33)",
|
||||
color = "rgba(" .. accentStart .. "33)",
|
||||
color_inactive = "rgba(00000000)",
|
||||
},
|
||||
|
||||
@@ -166,12 +193,12 @@ hl.config({
|
||||
-- Let a crashed lock screen be recovered rather than stranding you.
|
||||
allow_session_lock_restore = true,
|
||||
|
||||
-- Don't let apps steal focus by shouting; matches GNOME's behaviour.
|
||||
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
|
||||
focus_on_activate = false,
|
||||
},
|
||||
|
||||
render = {
|
||||
-- Colour management is stable and on by default in 0.56.
|
||||
-- Color management is stable and on by default in 0.56.
|
||||
cm_enabled = true,
|
||||
|
||||
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||
-- { ["DP-2"] = {
|
||||
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
||||
-- x = 0, y = 0, primary = true,
|
||||
-- } }
|
||||
--
|
||||
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||
-- Only mode, scale, and transform are read. Color 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.
|
||||
@@ -71,6 +74,26 @@ local function valid_transform(transform)
|
||||
and transform <= 3
|
||||
end
|
||||
|
||||
local function valid_coordinate(value)
|
||||
return type(value) == "number"
|
||||
and value == value
|
||||
and value == math.floor(value)
|
||||
and value >= -100000
|
||||
and value <= 100000
|
||||
end
|
||||
|
||||
local function valid_position(entry)
|
||||
return valid_coordinate(entry.x) and valid_coordinate(entry.y)
|
||||
end
|
||||
|
||||
local function valid_primary(entry)
|
||||
return type(entry.primary) == "boolean"
|
||||
end
|
||||
|
||||
local function has_layout_fields(entry)
|
||||
return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil
|
||||
end
|
||||
|
||||
local function display_entry(output)
|
||||
if type(output) ~= "string" or output == ""
|
||||
or output:match("^[%w_.-]+$") == nil then
|
||||
@@ -85,9 +108,23 @@ local function display_entry(output)
|
||||
or not valid_transform(entry.transform) then
|
||||
return nil
|
||||
end
|
||||
-- Legacy records have none of the layout fields and keep automatic
|
||||
-- placement. A partially written extended record is unsafe: accepting its
|
||||
-- mode but guessing its position could overlap or strand another output.
|
||||
if has_layout_fields(entry)
|
||||
and (not valid_position(entry) or not valid_primary(entry)) then
|
||||
return nil
|
||||
end
|
||||
return entry
|
||||
end
|
||||
|
||||
local function display_position(entry, fallback)
|
||||
if entry ~= nil and has_layout_fields(entry) then
|
||||
return string.format("%dx%d", entry.x, entry.y)
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
local shipped_mode = "4500x3000@60"
|
||||
local shipped_scale = 1.5
|
||||
local shipped_transform = 0
|
||||
@@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2")
|
||||
hl.monitor({
|
||||
output = "DP-2",
|
||||
mode = dp2 and dp2.mode or shipped_mode,
|
||||
position = "0x0",
|
||||
position = display_position(dp2, "0x0"),
|
||||
scale = dp2 and dp2.scale or shipped_scale,
|
||||
transform = dp2 and dp2.transform or shipped_transform,
|
||||
|
||||
@@ -110,7 +147,7 @@ hl.monitor({
|
||||
})
|
||||
|
||||
-- Other connected outputs use the same validated per-output store. They keep
|
||||
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||
-- automatic placement and the compositor's normal color policy; DP-2 alone
|
||||
-- carries the panel-specific 10-bit policy documented above.
|
||||
for output, _ in pairs(displays) do
|
||||
if output ~= "DP-2" then
|
||||
@@ -119,7 +156,7 @@ for output, _ in pairs(displays) do
|
||||
hl.monitor({
|
||||
output = output,
|
||||
mode = entry.mode,
|
||||
position = "auto",
|
||||
position = display_position(entry, "auto"),
|
||||
scale = entry.scale,
|
||||
transform = entry.transform,
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- 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
|
||||
-- file must cost the user nothing worse than their customizations; it must
|
||||
-- never cost them a working compositor. Every failure path returns the caller's
|
||||
-- fallback.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,7 +69,7 @@ hl.window_rule({
|
||||
float = true,
|
||||
})
|
||||
|
||||
-- Settings-style utility windows and pickers: float and centre, like GNOME did.
|
||||
-- Settings-style utility windows and pickers: float and center, like GNOME did.
|
||||
hl.window_rule({
|
||||
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
|
||||
float = true,
|
||||
@@ -150,7 +150,7 @@ hl.layer_rule({
|
||||
ignore_alpha = 0.3,
|
||||
})
|
||||
|
||||
-- Popovers: calendar, quick settings, notification centre, tray menus.
|
||||
-- Popovers: calendar, quick settings, notification center, tray menus.
|
||||
hl.layer_rule({
|
||||
name = "qs-popover",
|
||||
match = { namespace = "^qs-popover" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 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
|
||||
# includes current-theme.conf, which Panama generates from the color 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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-- 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
|
||||
-- user their color scheme preference and nothing else; editing text is more
|
||||
-- important than matching the desktop.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
-- Tokyo Night, following the desktop's colour scheme.
|
||||
-- Tokyo Night, following the desktop's color 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
|
||||
-- against Moon's palette -- a pale comment color, a mid-gray gutter -- and
|
||||
-- applying them to Day would put light gray text on a light background, which
|
||||
-- is exactly the legibility problem they exist to solve, inverted.
|
||||
|
||||
local panama = require("config.panama")
|
||||
|
||||
@@ -22,7 +22,7 @@ console_title_template = '{{ .Shell }} in {{ .Folder }}'
|
||||
[[blocks.segments]]
|
||||
type = 'git'
|
||||
style = 'plain'
|
||||
foreground = 'p:grey'
|
||||
foreground = 'p:gray'
|
||||
background = 'transparent'
|
||||
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
import "services/AudioStreams.js" as AudioStreams
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
readonly property int audioOutStreamFlag: 4
|
||||
|
||||
property var fixtureNodes: [
|
||||
{
|
||||
id: 10,
|
||||
ready: true,
|
||||
type: audioOutStreamFlag,
|
||||
properties: {
|
||||
"application.id": "org.chromium.Chromium",
|
||||
"application.name": "Chromium",
|
||||
"application.icon_name": "chromium"
|
||||
},
|
||||
description: "Chromium audio",
|
||||
audio: { volume: 0.4, muted: false }
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
ready: true,
|
||||
type: audioOutStreamFlag,
|
||||
properties: {
|
||||
"application.id": "org.chromium.Chromium",
|
||||
"application.name": "Chromium",
|
||||
"application.icon_name": "chromium"
|
||||
},
|
||||
description: "Chromium audio",
|
||||
audio: { volume: 0.8, muted: true }
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
ready: true,
|
||||
type: audioOutStreamFlag,
|
||||
properties: {
|
||||
"application.process.binary": "spotify",
|
||||
"application.name": "Spotify"
|
||||
},
|
||||
description: "Spotify",
|
||||
audio: { volume: 0.25, muted: false }
|
||||
},
|
||||
{
|
||||
id: 30,
|
||||
ready: true,
|
||||
type: 2,
|
||||
properties: { "application.name": "Microphone capture" },
|
||||
description: "Input stream",
|
||||
audio: { volume: 0.5, muted: false }
|
||||
},
|
||||
{
|
||||
id: 40,
|
||||
ready: false,
|
||||
type: audioOutStreamFlag,
|
||||
properties: { "application.name": "Not ready" },
|
||||
description: "Unready stream",
|
||||
audio: { volume: 0.5, muted: false }
|
||||
},
|
||||
{
|
||||
id: 99,
|
||||
ready: true,
|
||||
type: audioOutStreamFlag,
|
||||
properties: {},
|
||||
description: "",
|
||||
audio: { volume: 1, muted: false }
|
||||
}
|
||||
]
|
||||
|
||||
function groups(): var {
|
||||
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "application-volume-test"
|
||||
|
||||
function summary(): string {
|
||||
const applications = groups();
|
||||
const chromium = applications.find(application =>
|
||||
application.key === "org.chromium.Chromium");
|
||||
return JSON.stringify({
|
||||
groups: applications.map(application => ({
|
||||
key: application.key,
|
||||
label: application.label,
|
||||
icon: application.icon,
|
||||
count: application.nodes.length
|
||||
})),
|
||||
chromiumVolume: AudioStreams.volume(chromium),
|
||||
chromiumMuted: AudioStreams.muted(chromium)
|
||||
});
|
||||
}
|
||||
|
||||
function mutateVolume(): string {
|
||||
const chromium = groups().find(application =>
|
||||
application.key === "org.chromium.Chromium");
|
||||
const changed = AudioStreams.setVolume(chromium, 0.7);
|
||||
return JSON.stringify({
|
||||
changed,
|
||||
volumes: chromium.nodes.map(node => node.audio.volume),
|
||||
muted: chromium.nodes.map(node => node.audio.muted)
|
||||
});
|
||||
}
|
||||
|
||||
function mutateMute(): string {
|
||||
const chromium = groups().find(application =>
|
||||
application.key === "org.chromium.Chromium");
|
||||
const changed = AudioStreams.setMuted(chromium, true);
|
||||
return JSON.stringify({
|
||||
changed,
|
||||
muted: chromium.nodes.map(node => node.audio.muted)
|
||||
});
|
||||
}
|
||||
|
||||
function serviceSummary(): string {
|
||||
const applications = AudioDevices.applications;
|
||||
return JSON.stringify({
|
||||
count: applications.length,
|
||||
validTypes: applications.every(application =>
|
||||
application.nodes.every(node =>
|
||||
(node.type & PwNodeType.AudioOutStream)
|
||||
=== PwNodeType.AudioOutStream))
|
||||
});
|
||||
}
|
||||
|
||||
function invalidMutations(): string {
|
||||
return JSON.stringify({
|
||||
nullVolume: AudioDevices.setApplicationVolume(null, 0.5),
|
||||
emptyMute: AudioDevices.setApplicationMuted({ nodes: [] }, true)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ Singleton {
|
||||
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
|
||||
// schema no longer recognizes 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);
|
||||
|
||||
@@ -19,7 +19,7 @@ Singleton {
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
onSaved: root.saveError = ""
|
||||
onSaveFailed: error => root.saveError = "Could not save Home favourites."
|
||||
onSaveFailed: error => root.saveError = "Could not save Home favorites."
|
||||
|
||||
JsonAdapter {
|
||||
id: values
|
||||
|
||||
@@ -5,7 +5,7 @@ pragma Singleton
|
||||
// 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
|
||||
// does not recognize -- and an unrecognized 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.
|
||||
//
|
||||
|
||||
@@ -171,7 +171,7 @@ Singleton {
|
||||
unit: "px",
|
||||
group: "windows",
|
||||
label: "Inner gaps",
|
||||
detail: "Space between neighbouring tiled windows",
|
||||
detail: "Space between neighboring tiled windows",
|
||||
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
|
||||
},
|
||||
{
|
||||
@@ -278,7 +278,7 @@ Singleton {
|
||||
// ── Master layout ───────────────────────────────────────────────────
|
||||
// Only meaningful when the tiling layout is Master and stack. Offering
|
||||
// that layout with none of its options was an omission: it is the one
|
||||
// layout whose whole behaviour is in these settings.
|
||||
// layout whose whole behavior is in these settings.
|
||||
{
|
||||
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
|
||||
group: "master",
|
||||
@@ -295,7 +295,7 @@ Singleton {
|
||||
{ value: "right", label: "Right" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
{ value: "center", label: "Centre" }
|
||||
{ value: "center", label: "Center" }
|
||||
],
|
||||
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
||||
},
|
||||
@@ -429,7 +429,7 @@ Singleton {
|
||||
{
|
||||
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.
|
||||
// serialized 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",
|
||||
@@ -467,7 +467,7 @@ Singleton {
|
||||
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "followMouse", type: "enum", def: 1, group: "input",
|
||||
key: "followMouse", type: "enum", def: 1, group: "pointer",
|
||||
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
|
||||
@@ -475,7 +475,7 @@ Singleton {
|
||||
// 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
|
||||
// -- so a desktop labeled "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.
|
||||
//
|
||||
@@ -495,7 +495,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
|
||||
group: "input",
|
||||
group: "pointer",
|
||||
label: "Pointer speed",
|
||||
detail: "Zero is flat, unaccelerated response",
|
||||
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
|
||||
@@ -503,7 +503,7 @@ Singleton {
|
||||
{
|
||||
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||
unit: "s",
|
||||
group: "input",
|
||||
group: "pointer",
|
||||
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
|
||||
@@ -747,6 +747,66 @@ Singleton {
|
||||
label: "Wallpaper",
|
||||
detail: "Shown on every output"
|
||||
},
|
||||
{
|
||||
key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
|
||||
label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
|
||||
options: [
|
||||
{ value: "single", label: "Single" },
|
||||
{ value: "slideshow", label: "Slideshow" },
|
||||
{ value: "per-monitor", label: "Per display" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
|
||||
label: "Slideshow collection", detail: "Backgrounds selected for rotation"
|
||||
},
|
||||
{
|
||||
key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
|
||||
unit: "min", group: "wallpaper", label: "Change background every",
|
||||
detail: "Time between slideshow images"
|
||||
},
|
||||
{
|
||||
key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
|
||||
label: "Shuffle", detail: "Show every selected image before repeating"
|
||||
},
|
||||
{
|
||||
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
|
||||
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
|
||||
},
|
||||
|
||||
// ── Lock-screen appearance ─────────────────────────────────────────
|
||||
// scripts/panama-lock validates these again before generating a state
|
||||
// config. The tracked hyprlock.conf remains the safe fallback.
|
||||
{
|
||||
key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
|
||||
label: "Background", detail: "What appears behind the lock screen",
|
||||
options: [
|
||||
{ value: "screenshot", label: "Blurred desktop" },
|
||||
{ value: "wallpaper", label: "Current wallpaper" },
|
||||
{ value: "solid", label: "Solid color" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
|
||||
group: "lockAppearance", label: "Background blur",
|
||||
detail: "Softens what is behind the password field"
|
||||
},
|
||||
{
|
||||
key: "lockShowClock", type: "bool", def: true, group: "lockAppearance",
|
||||
label: "Show clock", detail: "Use the desktop's 12 or 24-hour format"
|
||||
},
|
||||
{
|
||||
key: "lockShowDate", type: "bool", def: true, group: "lockAppearance",
|
||||
label: "Show date", detail: "Show the weekday and full date"
|
||||
},
|
||||
{
|
||||
key: "lockShowUser", type: "bool", def: true, group: "lockAppearance",
|
||||
label: "Show user name", detail: "Identify the signed-in account"
|
||||
},
|
||||
{
|
||||
key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance",
|
||||
label: "Hide password field until typing", detail: "Keep the empty field out of the way"
|
||||
},
|
||||
|
||||
// ── Idle, lock, and sleep ───────────────────────────────────────────
|
||||
// Written into a generated hypridle config; see scripts/panama-idle.
|
||||
@@ -792,14 +852,14 @@ Singleton {
|
||||
detail: "A time earlier than the start simply means the next morning"
|
||||
},
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────
|
||||
// ── Color 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
|
||||
// compositor's border colors, because an application toolbar or a
|
||||
// window border still wearing the other scheme is more jarring than
|
||||
// either scheme on its own.
|
||||
{
|
||||
@@ -811,6 +871,26 @@ Singleton {
|
||||
{ value: "light", label: "Light" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "accentName", type: "enum", def: "blue", group: "appearance",
|
||||
label: "Accent color",
|
||||
detail: "Drives the focused window border, the bar hairline, and every active state",
|
||||
// NAMED accents, not a free color. Each name carries a curated
|
||||
// pair per scheme, because one hex cannot serve both: a color
|
||||
// legible on the dark ground is usually illegible on the light one.
|
||||
// The palette and its measured contrast live in config/Theme.qml,
|
||||
// which is also what stops this list drifting from what is drawn.
|
||||
options: [
|
||||
{ value: "blue", label: "Prism blue" },
|
||||
{ value: "orchid", label: "Orchid" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "amber", label: "Amber" },
|
||||
{ value: "orange", label: "Orange" },
|
||||
{ value: "rose", label: "Rose" },
|
||||
{ value: "slate", label: "Slate" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Application themes ─────────────────────────────────────────────
|
||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||
@@ -918,7 +998,7 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Application titlebars ──────────────────────────────────────────
|
||||
// These affect applications that honour GNOME's window preferences.
|
||||
// These affect applications that honor GNOME's window preferences.
|
||||
// Hyprland itself has no server-side titlebar buttons, so minimize is
|
||||
// deliberately absent rather than presented as a switch that lies.
|
||||
{
|
||||
@@ -981,7 +1061,7 @@ Singleton {
|
||||
// ── 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
|
||||
// that can be read for utilization; VitalsWidget hides itself when the
|
||||
// path is unreadable, so a stale value degrades to no readout rather
|
||||
// than a wrong one.
|
||||
{
|
||||
@@ -997,7 +1077,7 @@ Singleton {
|
||||
// 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.
|
||||
// reading recognizable.
|
||||
{
|
||||
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
@@ -1131,8 +1211,8 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Display configuration ───────────────────────────────────────────
|
||||
// { "<output>": { mode, scale, transform } }, applied by
|
||||
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
||||
// hypr/monitors.lua on top of the shipped values. Color 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.
|
||||
@@ -1140,7 +1220,7 @@ Singleton {
|
||||
key: "displays", type: "json", def: ({}), group: "display",
|
||||
internal: true,
|
||||
label: "Display configuration",
|
||||
detail: "Resolution, scale, and rotation per connected display"
|
||||
detail: "Resolution, scale, rotation, position, and primary display"
|
||||
},
|
||||
|
||||
// ── Per-application notification rules ──────────────────────────────
|
||||
@@ -1245,7 +1325,7 @@ Singleton {
|
||||
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
|
||||
// of these are serialized 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))
|
||||
|
||||
@@ -62,7 +62,7 @@ Singleton {
|
||||
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────────
|
||||
// Pinned apps, in order, taken from the GNOME dash favourites.
|
||||
// Pinned apps, in order, taken from the GNOME dash favorites.
|
||||
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||
|
||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pragma Singleton
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tokyo Night Moon — the single source of truth for every colour, radius,
|
||||
// Tokyo Night Moon — the single source of truth for every color, radius,
|
||||
// duration and font in the shell. Nothing below this file should hardcode a
|
||||
// colour; if you find yourself writing "#" outside this file, add a token here.
|
||||
// color; if you find yourself writing "#" outside this file, add a token here.
|
||||
//
|
||||
// The bar keeps GNOME's 36px information density but sits directly on the
|
||||
// desktop, edge to edge. The dock retains the former glass vocabulary: bottom,
|
||||
@@ -18,7 +18,7 @@ import QtQuick
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────────
|
||||
// ── Color 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
|
||||
@@ -42,13 +42,41 @@ Singleton {
|
||||
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: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||
// ── The accent ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// `accent` is the primary and carries every state meaning (focused, active,
|
||||
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||
// gradient. That restraint is the whole point: the two colors meeting is
|
||||
// the signature, so the second color stops being special the moment it is
|
||||
// used as a flat fill.
|
||||
//
|
||||
// NAMED accents rather than a free color. Each name carries a curated
|
||||
// triple per scheme, because an arbitrary hex cannot work in both: a color
|
||||
// legible on the Moon background is usually illegible on the Day one, and a
|
||||
// picker that lets someone choose an unreadable desktop is not a feature.
|
||||
// Every pair below measures at least 3:1 against the ground it sits on.
|
||||
// This is also GNOME's model, which is the parity being chased.
|
||||
//
|
||||
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
|
||||
// the default.
|
||||
readonly property var accents: ({
|
||||
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue" },
|
||||
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid" },
|
||||
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal" },
|
||||
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green" },
|
||||
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber" },
|
||||
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange" },
|
||||
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose" },
|
||||
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate" }
|
||||
})
|
||||
|
||||
// Falls back to blue for an unknown name, so a settings file written by a
|
||||
// newer Panama -- or edited by hand -- degrades to the shipped identity
|
||||
// rather than to an undefined color.
|
||||
readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"]
|
||||
|
||||
readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light
|
||||
readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary
|
||||
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"
|
||||
@@ -108,7 +136,7 @@ Singleton {
|
||||
|
||||
// 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).
|
||||
// hardcoded dark fill Qt will not recolor (see widgets/ThemedIcon.qml).
|
||||
readonly property string fontMono: DesktopPreferences.get("iconFont")
|
||||
|
||||
// Apply to any text whose digits change in place — clocks, percentages,
|
||||
@@ -152,7 +180,7 @@ Singleton {
|
||||
}
|
||||
|
||||
// Blend two tokens — used for hover/pressed states so we never invent a
|
||||
// colour that isn't derived from the palette.
|
||||
// color that isn't derived from the palette.
|
||||
function mix(a: color, b: color, ratio: real): color {
|
||||
return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio);
|
||||
}
|
||||
@@ -163,7 +191,7 @@ Singleton {
|
||||
// the active workspace pill and slider fills.
|
||||
//
|
||||
// Where the gradient runs left-to-right the blue leads, because the eye
|
||||
// reads left first and blue is the colour that carries meaning.
|
||||
// reads left first and blue is the color that carries meaning.
|
||||
readonly property real prismStart: 0.22
|
||||
readonly property real prismEnd: 0.78
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
DisplayIdentify {}
|
||||
|
||||
QtObject {
|
||||
id: fixtureService
|
||||
|
||||
property var monitors: [
|
||||
{
|
||||
name: "DP-2", description: "Primary display", width: 4500, height: 3000,
|
||||
refreshRate: 60, mode: "[email protected]", scale: 1.5,
|
||||
transform: 0, x: 0, y: 0, primary: true
|
||||
},
|
||||
{
|
||||
name: "HDMI-A-1", description: "Second display", width: 2560, height: 1440,
|
||||
refreshRate: 60, mode: "[email protected]", scale: 1,
|
||||
transform: 0, x: 3000, y: 0, primary: false
|
||||
}
|
||||
]
|
||||
property var applied: []
|
||||
|
||||
function currentLayout(): var {
|
||||
return monitors.map(record => Object.assign({}, record));
|
||||
}
|
||||
|
||||
function applyLayout(layout: var): bool {
|
||||
applied = layout.map(record => Object.assign({}, record));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
DisplayArrangement {
|
||||
id: arrangement
|
||||
width: 800
|
||||
displayService: fixtureService
|
||||
selectedOutput: "HDMI-A-1"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "display-arrangement-test"
|
||||
|
||||
function status(width: int): string {
|
||||
arrangement.width = width;
|
||||
arrangement.resetDraft();
|
||||
return JSON.stringify(arrangement.canvasSnapshot());
|
||||
}
|
||||
|
||||
function dragFixture(): string {
|
||||
arrangement.resetDraft();
|
||||
arrangement.setDraftPosition("HDMI-A-1", 3016, 0, true);
|
||||
arrangement.applyDraft();
|
||||
return JSON.stringify(fixtureService.applied);
|
||||
}
|
||||
|
||||
function keyboardFixture(): string {
|
||||
arrangement.resetDraft();
|
||||
arrangement.nudge("HDMI-A-1", -10, 0);
|
||||
const afterArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
|
||||
arrangement.nudge("HDMI-A-1", -100, 0);
|
||||
const afterShiftArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
|
||||
return JSON.stringify({ afterArrow, afterShiftArrow });
|
||||
}
|
||||
|
||||
function primaryFixture(): string {
|
||||
arrangement.resetDraft();
|
||||
arrangement.makePrimary("HDMI-A-1");
|
||||
return JSON.stringify(fixtureService.applied.map(record => ({
|
||||
name: record.name, x: record.x, y: record.y, primary: record.primary
|
||||
})));
|
||||
}
|
||||
|
||||
function identify(): void { Displays.identify(); }
|
||||
function identifying(): bool { return Displays.identifying; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import "services/DisplayLayout.js" as DisplayLayout
|
||||
|
||||
ShellRoot {
|
||||
readonly property var fixture: [
|
||||
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true },
|
||||
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
|
||||
]
|
||||
|
||||
IpcHandler {
|
||||
target: "display-layout-test"
|
||||
|
||||
function status(): string {
|
||||
const normalized = DisplayLayout.normalize(fixture);
|
||||
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
||||
const near = normalized.map(record => Object.assign({}, record));
|
||||
near[1].x = 3016;
|
||||
const far = normalized.map(record => Object.assign({}, record));
|
||||
far[1].x = 3017;
|
||||
return JSON.stringify({
|
||||
valid: DisplayLayout.validate(fixture),
|
||||
sizes: fixture.map(DisplayLayout.logicalSize),
|
||||
normalized: normalized.map(record => ({ name: record.name, x: record.x, y: record.y, primary: record.primary })),
|
||||
bounds: canvas.bounds,
|
||||
canvasScale: canvas.scale,
|
||||
canvasRects: canvas.rects,
|
||||
near: DisplayLayout.snap(near, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x,
|
||||
far: DisplayLayout.snap(far, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x
|
||||
});
|
||||
}
|
||||
|
||||
function invalid(): string {
|
||||
const base = fixture.map(record => Object.assign({}, record));
|
||||
const cases = [];
|
||||
const add = layout => cases.push(DisplayLayout.validate(layout));
|
||||
add([base[0], Object.assign({}, base[1], { name: "DP-2" })]);
|
||||
add(base.map(record => Object.assign({}, record, { primary: false })));
|
||||
add(base.map(record => Object.assign({}, record, { primary: true })));
|
||||
add([Object.assign({}, base[0], { x: 0.5 }), base[1]]);
|
||||
add([Object.assign({}, base[0], { scale: 0 }), base[1]]);
|
||||
add([Object.assign({}, base[0], { transform: 4 }), base[1]]);
|
||||
add([Object.assign({}, base[0], { width: Infinity }), base[1]]);
|
||||
add([Object.assign({}, base[0], { width: 0 }), base[1]]);
|
||||
return JSON.stringify(cases);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ ShellRoot {
|
||||
mode: monitor ? monitor.mode : "",
|
||||
scale: monitor ? monitor.scale : 0,
|
||||
transform: monitor ? monitor.transform : -1,
|
||||
x: monitor ? monitor.x : 0,
|
||||
y: monitor ? monitor.y : 0,
|
||||
primary: monitor ? monitor.primary : false,
|
||||
modes: monitor ? monitor.modes.length : 0,
|
||||
awaiting: Displays.awaitingConfirmation,
|
||||
canConfirm: Displays.canConfirm,
|
||||
@@ -36,8 +39,51 @@ ShellRoot {
|
||||
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||
}
|
||||
|
||||
function transactionStatus(): string {
|
||||
return JSON.stringify({
|
||||
layout: Displays.currentLayout(),
|
||||
pending: Displays.pendingRequestedLayout,
|
||||
previous: Displays.pendingPreviousLayout,
|
||||
reverting: Displays.revertExpectedLayout,
|
||||
awaiting: Displays.awaitingConfirmation,
|
||||
canConfirm: Displays.canConfirm,
|
||||
busy: Displays.busy,
|
||||
generation: Displays.operationGeneration,
|
||||
revertGeneration: Displays.revertGeneration,
|
||||
lastError: Displays.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayoutFixture(secondX: int, secondY: int): bool {
|
||||
const layout = Displays.currentLayout();
|
||||
if (layout.length !== 2) return false;
|
||||
layout[0].x = 0;
|
||||
layout[0].y = 0;
|
||||
layout[0].primary = true;
|
||||
layout[1].x = secondX;
|
||||
layout[1].y = secondY;
|
||||
layout[1].primary = false;
|
||||
return Displays.applyLayout(layout);
|
||||
}
|
||||
|
||||
function makePrimaryFixture(output: string): bool {
|
||||
return Displays.makePrimary(output);
|
||||
}
|
||||
|
||||
function injectReadback(text: string, generation: int): void {
|
||||
Displays.parse(text, generation);
|
||||
}
|
||||
|
||||
function expireApplyVerification(): void {
|
||||
Displays.verificationTimedOut();
|
||||
}
|
||||
|
||||
function expireRevertVerification(): void {
|
||||
Displays.revertVerificationTimedOut();
|
||||
}
|
||||
|
||||
function refreshIdentityFixture(): string {
|
||||
const modes = Displays.normaliseModes([
|
||||
const modes = Displays.normalizeModes([
|
||||
"[email protected]",
|
||||
"[email protected]"
|
||||
]);
|
||||
@@ -49,6 +95,30 @@ ShellRoot {
|
||||
});
|
||||
}
|
||||
|
||||
function positionFixture(): string {
|
||||
const previous = Displays.monitors;
|
||||
Displays.parse(JSON.stringify([
|
||||
{
|
||||
name: "DP-2", description: "Primary", width: 4500, height: 3000,
|
||||
refreshRate: 60, scale: 1.5, transform: 0, x: 140, y: 80,
|
||||
availableModes: ["[email protected]"]
|
||||
},
|
||||
{
|
||||
name: "HDMI-A-1", description: "Second", width: 2560, height: 1440,
|
||||
refreshRate: 60, scale: 1, transform: 0, x: 3140, y: 80,
|
||||
availableModes: ["[email protected]"]
|
||||
}
|
||||
]), Displays.operationGeneration);
|
||||
const result = JSON.stringify(Displays.monitors.map(monitor => ({
|
||||
name: monitor.name,
|
||||
x: monitor.x,
|
||||
y: monitor.y,
|
||||
primary: monitor.primary
|
||||
})));
|
||||
Displays.monitors = previous;
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyBad(kind: string): bool {
|
||||
const monitor = Displays.monitors[0];
|
||||
if (!monitor) return false;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "lock-screen-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
generated: LockScreen.generated,
|
||||
path: LockScreen.path,
|
||||
fallback: LockScreen.fallback,
|
||||
lastError: LockScreen.lastError,
|
||||
busy: LockScreen.busy
|
||||
});
|
||||
}
|
||||
|
||||
function burst(): void {
|
||||
DesktopPreferences.set("lockBlurLevel", 1);
|
||||
DesktopPreferences.set("lockBlurLevel", 2);
|
||||
DesktopPreferences.set("lockBlurLevel", 4);
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
LockScreen.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
readonly property string fixtureWallpaper: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
||||
|
||||
LockScreenPreview {
|
||||
id: screenshotPreview
|
||||
width: 620
|
||||
backgroundMode: "screenshot"
|
||||
blurLevel: 4
|
||||
showClock: true
|
||||
showDate: true
|
||||
showUser: true
|
||||
fadeOnEmpty: false
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
LockScreenPreview {
|
||||
id: wallpaperPreview
|
||||
width: 620
|
||||
backgroundMode: "wallpaper"
|
||||
blurLevel: 2
|
||||
showClock: false
|
||||
showDate: true
|
||||
showUser: false
|
||||
fadeOnEmpty: true
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
LockScreenPreview {
|
||||
id: solidPreview
|
||||
width: 620
|
||||
backgroundMode: "solid"
|
||||
blurLevel: 0
|
||||
showClock: true
|
||||
showDate: false
|
||||
showUser: true
|
||||
fadeOnEmpty: false
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "lock-screen-settings-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
screenshot: {
|
||||
mode: screenshotPreview.previewMode,
|
||||
wallpaperVisible: screenshotPreview.wallpaperVisible,
|
||||
blurStrength: screenshotPreview.blurStrength,
|
||||
clockVisible: screenshotPreview.clockVisible,
|
||||
dateVisible: screenshotPreview.dateVisible,
|
||||
userVisible: screenshotPreview.userVisible,
|
||||
passwordVisible: screenshotPreview.passwordVisible
|
||||
},
|
||||
wallpaper: {
|
||||
mode: wallpaperPreview.previewMode,
|
||||
wallpaperVisible: wallpaperPreview.wallpaperVisible,
|
||||
blurStrength: wallpaperPreview.blurStrength,
|
||||
clockVisible: wallpaperPreview.clockVisible,
|
||||
dateVisible: wallpaperPreview.dateVisible,
|
||||
userVisible: wallpaperPreview.userVisible,
|
||||
passwordVisible: wallpaperPreview.passwordVisible
|
||||
},
|
||||
solid: {
|
||||
mode: solidPreview.previewMode,
|
||||
wallpaperVisible: solidPreview.wallpaperVisible,
|
||||
blurStrength: solidPreview.blurStrength,
|
||||
clockVisible: solidPreview.clockVisible,
|
||||
dateVisible: solidPreview.dateVisible,
|
||||
userVisible: solidPreview.userVisible,
|
||||
passwordVisible: solidPreview.passwordVisible
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// 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.
|
||||
// a failing step is not one to discover the behavior of during an upgrade.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
@@ -13,6 +13,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.toggle("activity")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
|
||||
onSecondaryActivated: ShellState.openSettings("privacy")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
|
||||
@@ -25,10 +25,18 @@ PanelWindow {
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
// Deliberately does not read Capture.recordingSeconds (or anything else
|
||||
// that ticks once a second): this array is a plain JS array, not an
|
||||
// identity-preserving model, so any dependency that changes every second
|
||||
// would make the whole thing re-derive every second, and the Repeater
|
||||
// below would destroy and recreate every row -- including one the user
|
||||
// might be hovering or about to click. The set of activities should only
|
||||
// change when an activity actually starts or stops. Elapsed time is
|
||||
// rendered by each row's own Text binding instead, further down.
|
||||
readonly property var activities: {
|
||||
const result = [];
|
||||
if (PrivacyState.recordingActive)
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true });
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
|
||||
if (PrivacyState.screenSharingActive)
|
||||
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
||||
if (PrivacyState.cameraActive)
|
||||
@@ -38,11 +46,12 @@ PanelWindow {
|
||||
return result;
|
||||
}
|
||||
|
||||
function elapsed(): string {
|
||||
const total = Capture.recordingSeconds;
|
||||
const seconds = String(total % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(total / 60) % 60;
|
||||
const hours = Math.floor(total / 3600);
|
||||
// Pure formatter, no ticking property read here -- callers decide what
|
||||
// seconds value to pass, and only they take on the per-second dependency.
|
||||
function formatElapsed(totalSeconds: int): string {
|
||||
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
@@ -164,7 +173,11 @@ PanelWindow {
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: activityRow.modelData.detail
|
||||
// Only this Text re-evaluates every second while
|
||||
// recording -- Capture.recordingSeconds is read
|
||||
// here, not in the parent `activities` array, so
|
||||
// the row itself is never torn down for a tick.
|
||||
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// Bar {}
|
||||
// }
|
||||
//
|
||||
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather centre,
|
||||
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather center,
|
||||
// media + tray + status cluster right.
|
||||
|
||||
import Quickshell
|
||||
@@ -83,7 +83,7 @@ PanelWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Centre ──────────────────────────────────────────────────────────
|
||||
// ── Center ──────────────────────────────────────────────────────────
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
@@ -15,6 +15,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.openDateMenu("agenda")
|
||||
|
||||
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
|
||||
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||
|
||||
ToolTip.visible: root.hovered && root.visible
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
// The calendar that drops out of the clock — GNOME's date menu, minus the
|
||||
// notification list (that lives in its own panel). Month grid, today marked
|
||||
// with the accent, arrows to page through months, current weather at the foot.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Popover {
|
||||
id: root
|
||||
|
||||
// Popover's container is a plain Item, which does not derive an implicit
|
||||
// size from its children, so the window has to be sized from the body.
|
||||
implicitWidth: body.implicitWidth + contentPadding * 2
|
||||
implicitHeight: body.implicitHeight + contentPadding * 2
|
||||
|
||||
readonly property int cellSize: 34
|
||||
readonly property int cellHeight: 30
|
||||
|
||||
// Hours precision is enough: the only thing that has to change on its own
|
||||
// is which cell counts as "today", and that only moves at midnight.
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Hours
|
||||
}
|
||||
|
||||
readonly property int todayYear: clock.date.getFullYear()
|
||||
readonly property int todayMonth: clock.date.getMonth()
|
||||
readonly property int todayDay: clock.date.getDate()
|
||||
|
||||
// The month currently on screen. Reset to today every time the popover
|
||||
// opens, so it never comes back showing wherever you paged off to.
|
||||
property int viewYear: root.todayYear
|
||||
property int viewMonth: root.todayMonth
|
||||
|
||||
onVisibleChanged: if (visible)
|
||||
root.showToday()
|
||||
|
||||
function showToday(): void {
|
||||
root.viewYear = root.todayYear;
|
||||
root.viewMonth = root.todayMonth;
|
||||
}
|
||||
|
||||
function stepMonth(delta: int): void {
|
||||
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
|
||||
root.viewYear = d.getFullYear();
|
||||
root.viewMonth = d.getMonth();
|
||||
}
|
||||
|
||||
// Six weeks of cells, so the grid height never changes as you page through
|
||||
// months. Days from the neighbouring months fill the edges, dimmed.
|
||||
readonly property var cells: {
|
||||
const first = new Date(root.viewYear, root.viewMonth, 1);
|
||||
const offset = first.getDay(); // 0 = Sunday, matching the header row
|
||||
const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate();
|
||||
const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate();
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const n = i - offset + 1;
|
||||
if (n < 1)
|
||||
out.push({
|
||||
day: inPrevMonth + n,
|
||||
current: false
|
||||
});
|
||||
else if (n > inThisMonth)
|
||||
out.push({
|
||||
day: n - inThisMonth,
|
||||
current: false
|
||||
});
|
||||
else
|
||||
out.push({
|
||||
day: n,
|
||||
current: true
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Month header ────────────────────────────────────────────────────
|
||||
Item {
|
||||
width: root.cellSize * 7
|
||||
height: 28
|
||||
|
||||
CalendarArrow {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "\u{F0141}" // md-chevron_left
|
||||
onActivated: root.stepMonth(-1)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.fg
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.showToday()
|
||||
}
|
||||
}
|
||||
|
||||
CalendarArrow {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "\u{F0142}" // md-chevron_right
|
||||
onActivated: root.stepMonth(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekday header ──────────────────────────────────────────────────
|
||||
Row {
|
||||
Repeater {
|
||||
model: ["S", "M", "T", "W", "T", "F", "S"]
|
||||
|
||||
delegate: Text {
|
||||
required property string modelData
|
||||
|
||||
width: root.cellSize
|
||||
height: 20
|
||||
text: modelData
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Day grid ────────────────────────────────────────────────────────
|
||||
Grid {
|
||||
columns: 7
|
||||
|
||||
Repeater {
|
||||
model: root.cells
|
||||
|
||||
delegate: Item {
|
||||
id: cell
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
|
||||
|
||||
width: root.cellSize
|
||||
height: root.cellHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: root.cellHeight - 2
|
||||
height: root.cellHeight - 2
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
visible: cell.isToday
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: cell.modelData.day
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
|
||||
|
||||
color: {
|
||||
if (cell.isToday)
|
||||
return Theme.bgDark;
|
||||
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
width: root.cellSize * 7
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
visible: Weather.available
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Weather.available
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Weather.icon
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
color: Theme.accentAlt
|
||||
}
|
||||
|
||||
Column {
|
||||
Text {
|
||||
text: Math.round(Weather.temperature) + Weather.unitSuffix
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Weather.description
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.fgDim
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// The centre clock, kept in the same place and the same format GNOME had it:
|
||||
// The center clock, kept in the same place and the same format GNOME had it:
|
||||
// weekday, date, 12-hour time with seconds. Clicking it opens the calendar,
|
||||
// exactly like GNOME's date menu.
|
||||
|
||||
@@ -26,11 +26,16 @@ Pill {
|
||||
|
||||
onActivated: ShellState.toggleDateMenu("agenda")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
|
||||
// format toggles are mirrored on Appearance, but someone right-clicking a
|
||||
// clock is far more often after the time itself than its typography.
|
||||
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Qt.formatDateTime(clock.date, root.format)
|
||||
font.family: Theme.fontFamily
|
||||
// Seconds tick once a second in the centre of the bar; without tabular
|
||||
// Seconds tick once a second in the center of the bar; without tabular
|
||||
// figures the whole clock shifts sideways on every digit change.
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
@@ -34,6 +35,9 @@ Pill {
|
||||
onActivated: if (root.player?.canTogglePlaying)
|
||||
root.player.togglePlaying()
|
||||
|
||||
// Right-click opens the settings that govern this widget. Output device and per-application volume.
|
||||
onSecondaryActivated: ShellState.openSettings("sound")
|
||||
|
||||
// Scroll up = previous, down = next — the same direction as the workspace
|
||||
// switcher, so the whole bar scrolls consistently.
|
||||
onScrolled: delta => {
|
||||
|
||||
@@ -24,6 +24,9 @@ Pill {
|
||||
|
||||
onActivated: root.requestQuickSettings()
|
||||
|
||||
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
|
||||
onSecondaryActivated: ShellState.openSettings("connectivity")
|
||||
|
||||
// ── Audio ───────────────────────────────────────────────────────────────
|
||||
// Without a tracker, volume and muted silently read as zero/false.
|
||||
PwObjectTracker {
|
||||
@@ -59,7 +62,7 @@ Pill {
|
||||
if (!root.wifiNetwork)
|
||||
return "\u{F092D}"; // md-wifi_strength_off
|
||||
|
||||
// NetworkManager reports strength 0-100; some backends normalise to
|
||||
// NetworkManager reports strength 0-100; some backends normalize to
|
||||
// 0-1, so accept either rather than showing one bar forever.
|
||||
const raw = root.wifiNetwork.signalStrength ?? 0;
|
||||
const strength = raw <= 1 ? raw * 100 : raw;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// One glyph + percentage pair inside VitalsWidget. Split out so the three
|
||||
// fields cannot drift apart in spacing, width or colour thresholds.
|
||||
// fields cannot drift apart in spacing, width or color thresholds.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Vitals extension was configured with (_processor_usage_, _memory_usage_,
|
||||
// _gpu#1_usage_).
|
||||
//
|
||||
// Values are colour-coded rather than graphed: at bar size a number you can
|
||||
// Values are color-coded rather than graphed: at bar size a number you can
|
||||
// glance at beats a sparkline you have to squint at.
|
||||
|
||||
import QtQuick
|
||||
@@ -13,6 +13,10 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Right-click opens the settings that govern this widget. Which readouts
|
||||
// appear in the bar, and how often they update.
|
||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||
|
||||
interactive: false
|
||||
|
||||
Row {
|
||||
|
||||
@@ -10,6 +10,9 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
|
||||
onSecondaryActivated: ShellState.openSettings("home")
|
||||
|
||||
interactive: false
|
||||
visible: Weather.available
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Item {
|
||||
|
||||
implicitWidth: row.implicitWidth + 12
|
||||
// Full bar height makes (0, 0) part of the wheel target. The dots stay
|
||||
// optically centred by the row and active-pill geometry below.
|
||||
// optically centered by the row and active-pill geometry below.
|
||||
implicitHeight: Theme.barHeight
|
||||
|
||||
// Scroll over the padding either side of the row, not just over a dot.
|
||||
@@ -59,7 +59,7 @@ Item {
|
||||
border.width: 0 // QTBUG-137166: rounded rects can lose their corners
|
||||
|
||||
// The prism, as a fill rather than a hairline. This is the one element
|
||||
// in the bar that is always coloured, so it is where the pair belongs.
|
||||
// in the bar that is always colored, so it is where the pair belongs.
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The floating control panel at the bottom of the capture overlay.
|
||||
//
|
||||
// Layout is a deliberate copy of GNOME 42's screenshot UI: the area segmented
|
||||
// control on top, and below it the shutter dead-centre with "Show Pointer" on
|
||||
// control on top, and below it the shutter dead-center with "Show Pointer" on
|
||||
// the left and the screenshot/screencast switch on the right.
|
||||
|
||||
import QtQuick
|
||||
@@ -13,7 +13,7 @@ Rectangle {
|
||||
|
||||
signal captureRequested
|
||||
|
||||
// Set by the overlay so the shutter can grey out when Window mode has
|
||||
// Set by the overlay so the shutter can gray out when Window mode has
|
||||
// nothing pickable (no Hyprland, or an empty workspace).
|
||||
property bool captureEnabled: true
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// One segment of the Screen / Window / Selection control.
|
||||
//
|
||||
// Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the
|
||||
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolour
|
||||
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolor
|
||||
// them, so they'd be invisible on this dark overlay.
|
||||
|
||||
import QtQuick
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// The "you are being recorded" pill.
|
||||
//
|
||||
// GNOME puts a red dot and a timer in the top bar while a screencast runs and
|
||||
// clicking it stops the recording. The bar is another module's window, so this
|
||||
// is its own tiny layer surface parked just below it.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: Capture.recording
|
||||
color: "transparent"
|
||||
|
||||
// Top only: with neither left nor right anchored, layer-shell centres the
|
||||
// surface horizontally.
|
||||
anchors.top: true
|
||||
margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap
|
||||
|
||||
implicitWidth: pill.implicitWidth
|
||||
implicitHeight: pill.implicitHeight
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-recording"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
function elapsed(): string {
|
||||
const t = Capture.recordingSeconds;
|
||||
const s = ("0" + (t % 60)).slice(-2);
|
||||
const m = Math.floor(t / 60) % 60;
|
||||
const h = Math.floor(t / 3600);
|
||||
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
implicitWidth: row.implicitWidth + 12
|
||||
implicitHeight: 34
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.redDeep
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
// Static dot, not a blinking one: nothing in this shell repaints
|
||||
// while idle.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
border.width: 0
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: win.elapsed()
|
||||
color: Theme.fg
|
||||
// Tabular figures: the timer must not shuffle as it counts.
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: stop
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 26
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: ""
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: stopMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// SIGINT, so wf-recorder finalises the container.
|
||||
onClicked: Capture.stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Selection mode: a GNOME-style rubber band over the frozen screen.
|
||||
//
|
||||
// Behaviours copied from GNOME 42: the overlay opens with a sensible centred
|
||||
// Behaviors copied from GNOME 42: the overlay opens with a sensible centered
|
||||
// selection already in place (so the shutter is immediately useful), dragging
|
||||
// inside moves it, dragging a corner resizes from the opposite corner, and
|
||||
// dragging anywhere else starts a fresh band.
|
||||
@@ -52,7 +52,7 @@ Item {
|
||||
root.resetSelection();
|
||||
}
|
||||
|
||||
// Centred, roughly the middle half of the screen.
|
||||
// Centered, roughly the middle half of the screen.
|
||||
function resetSelection(): void {
|
||||
if (root.width <= 0 || root.height <= 0)
|
||||
return;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// The month grid from GNOME's date menu.
|
||||
//
|
||||
// Six week rows always, so the grid height never changes as you page through
|
||||
// months and the panel below it never jumps. Days belonging to the neighbouring
|
||||
// months and the panel below it never jumps. Days belonging to the neighboring
|
||||
// months fill the edges, dimmed. Today wears the accent.
|
||||
//
|
||||
// Days become real navigation targets now that Evolution Data Server backs the
|
||||
// panel. Up to three source-coloured markers show whether a date has events.
|
||||
// panel. Up to three source-colored markers show whether a date has events.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
@@ -134,7 +134,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Centred on the row rather than between the pill and the arrows,
|
||||
// Centered on the row rather than between the pill and the arrows,
|
||||
// so it does not shift sideways when the pill appears.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// GNOME's date/time menu: calendar, ongoing activity and messages in one panel,
|
||||
// dropping from the top centre because that is where GNOME put it and the
|
||||
// dropping from the top center because that is where GNOME put it and the
|
||||
// muscle memory is the whole point.
|
||||
//
|
||||
// A layer-shell surface rather than a PopupWindow because the inline reply
|
||||
// fields have to be typeable, and because the clock it hangs from is owned by a
|
||||
// different module.
|
||||
//
|
||||
// Layer-shell centres a surface on whichever axis it is not anchored to, so
|
||||
// Layer-shell centers a surface on whichever axis it is not anchored to, so
|
||||
// anchoring only `top` is what puts it in the middle.
|
||||
|
||||
import QtQuick
|
||||
@@ -87,7 +87,7 @@ PanelWindow {
|
||||
}
|
||||
|
||||
// Grows out of the top edge it is anchored to, rather than out of its
|
||||
// own centre — the panel should look like it came from the bar.
|
||||
// own center — the panel should look like it came from the bar.
|
||||
transform: Scale {
|
||||
id: openScale
|
||||
origin.x: surface.width / 2
|
||||
|
||||
@@ -48,12 +48,22 @@ PanelWindow {
|
||||
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
|
||||
|
||||
// ── Intellihide ─────────────────────────────────────────────────────────
|
||||
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
||||
// scope a per-screen bar to its own screen. Falls back to null when this
|
||||
// Dock is created standalone (no `screen` set) rather than via Variants.
|
||||
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
|
||||
|
||||
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
|
||||
// be computed. "Is anything on this workspace at all" is the robust proxy,
|
||||
// and it is what Dash-to-Dock's all-windows intellihide felt like in
|
||||
// practice: an empty workspace keeps the dock out.
|
||||
//
|
||||
// Deliberately this instance's own monitor's active workspace, not the
|
||||
// globally-focused one -- with one Dock per screen, keying off the global
|
||||
// focus would make focusing an empty workspace on monitor A hide the dock
|
||||
// on monitor B even though B's own workspace is still busy.
|
||||
readonly property bool workspaceOccupied: {
|
||||
const ws = Hyprland.focusedWorkspace;
|
||||
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
|
||||
return !!ws && ws.toplevels.values.length > 0;
|
||||
}
|
||||
|
||||
@@ -83,15 +93,31 @@ PanelWindow {
|
||||
onTriggered: root.revealed = false
|
||||
}
|
||||
|
||||
// Other modules (the bar, the capture overlay) read this.
|
||||
onRevealedChanged: ShellState.dockRevealed = revealed
|
||||
// Other modules (the bar, the capture overlay) read this. It is one
|
||||
// shared flag but there is one Dock per monitor, so only the instance on
|
||||
// the currently-focused monitor is allowed to write it -- otherwise
|
||||
// whichever instance last changed reveal state would stomp the others,
|
||||
// and a reader would see an arbitrary monitor's value. This scopes the
|
||||
// flag to mean "is the dock revealed on the monitor the user is on",
|
||||
// which is what a capture overlay or the bar actually care about.
|
||||
// (A true per-monitor flag would need ShellState.dockRevealed itself to
|
||||
// become keyed by screen, which is out of scope here -- see the report.)
|
||||
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
|
||||
|
||||
onRevealedChanged: root._syncShellState()
|
||||
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
||||
|
||||
function _syncShellState(): void {
|
||||
if (root.isFocusedMonitorInstance)
|
||||
ShellState.dockRevealed = root.revealed;
|
||||
}
|
||||
|
||||
// wantRevealed's first evaluation emits no change signal when it lands on
|
||||
// false (the default), so the initial state has to be taken explicitly —
|
||||
// otherwise a shell started on a busy workspace would leave the dock up.
|
||||
Component.onCompleted: {
|
||||
revealed = wantRevealed;
|
||||
ShellState.dockRevealed = revealed;
|
||||
root._syncShellState();
|
||||
}
|
||||
|
||||
// ── Input region ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -172,14 +172,14 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Centred on the hovered item, clamped inside the dock. Reads the item's
|
||||
// Centered on the hovered item, clamped inside the dock. Reads the item's
|
||||
// x directly (rather than mapToItem) so the binding re-evaluates when
|
||||
// the row reflows.
|
||||
x: {
|
||||
if (!root.hoveredItem)
|
||||
return 0;
|
||||
const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
|
||||
const center = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
return Math.max(4, Math.min(root.width - width - 4, center - width / 2));
|
||||
}
|
||||
y: -height - 8
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// One dock icon: the app, its running-window dots, hover scale and click
|
||||
// behaviour. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
|
||||
// behavior. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
|
||||
// underneath, grow-on-hover, click to focus / cycle / launch.
|
||||
|
||||
import Quickshell
|
||||
@@ -72,7 +72,7 @@ Item {
|
||||
}
|
||||
|
||||
// Last resort for an app with no resolvable icon: an initial, which is
|
||||
// still recognisable, unlike the theme's broken-icon placeholder.
|
||||
// still recognizable, unlike the theme's broken-icon placeholder.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.iconSource === ""
|
||||
@@ -155,7 +155,7 @@ Item {
|
||||
return;
|
||||
|
||||
// Quickshell reports the address without the 0x prefix Hyprland's
|
||||
// window selector expects; normalise so either form works.
|
||||
// window selector expects; normalize so either form works.
|
||||
if (next.address) {
|
||||
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
|
||||
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// One notification, rendered GNOME-style: app icon, app name, summary, body,
|
||||
// optional image, action buttons and an inline reply box.
|
||||
//
|
||||
// Shared by the toasts and the notification centre so a notification looks the
|
||||
// Shared by the toasts and the notification center so a notification looks the
|
||||
// same wherever you meet it.
|
||||
//
|
||||
// IconButton/ThemedIcon come from the quicksettings module — they belong in
|
||||
@@ -21,7 +21,7 @@ Rectangle {
|
||||
|
||||
required property var notification
|
||||
|
||||
// The centre renders a denser card than a toast banner.
|
||||
// The center renders a denser card than a toast banner.
|
||||
property bool compact: false
|
||||
|
||||
signal dismissed
|
||||
@@ -43,7 +43,7 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Critical notifications get a red edge rather than a colour wash, so the
|
||||
// Critical notifications get a red edge rather than a color wash, so the
|
||||
// text contrast never changes.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// GNOME's message tray: everything that has arrived, grouped by app.
|
||||
//
|
||||
// Drops from the top centre because that is where GNOME's tray lives and the
|
||||
// muscle memory is the whole point. Layer-shell centres a surface on whichever
|
||||
// axis it is not anchored to, so anchoring only `top` does it.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: ShellState.notificationsOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 440
|
||||
implicitHeight: surface.implicitHeight
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-notifications"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
Notifs.markAllRead();
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
windows: [root]
|
||||
active: root.visible
|
||||
onCleared: ShellState.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
|
||||
// border so the two don't fight for the same row of pixels.
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 32
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Notifications"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
IconButton {
|
||||
size: 30
|
||||
iconSize: 16
|
||||
icon: "notifications-disabled-symbolic"
|
||||
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
|
||||
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: clearLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
visible: Notifs.hasNotifications
|
||||
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: clearLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Clear all"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: clearMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Notifs.dismissAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ─────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 120
|
||||
visible: !Notifs.hasNotifications
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grouped history ─────────────────────────────────────────
|
||||
ScrollColumn {
|
||||
width: parent.width
|
||||
maxHeight: 620
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Notifs.hasNotifications
|
||||
|
||||
Repeater {
|
||||
model: Notifs.groups
|
||||
|
||||
Column {
|
||||
id: group
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 26
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: group.modelData.app
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
IconButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
iconSize: 13
|
||||
tint: Theme.fgDim
|
||||
icon: "edit-clear-all-symbolic"
|
||||
iconFallback: "window-close-symbolic"
|
||||
onClicked: Notifs.dismissApp(group.modelData.app)
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: group.modelData.items
|
||||
|
||||
NotificationCard {
|
||||
required property var modelData
|
||||
|
||||
width: group.width
|
||||
compact: true
|
||||
notification: modelData
|
||||
onDismissed: Notifs.dismiss(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// A single banner: a notification card that slides in and times itself out.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
@@ -14,28 +13,25 @@ Item {
|
||||
// into a keyboard focus request on the layer surface.
|
||||
signal replyFocusChanged(bool focused)
|
||||
|
||||
// Mirrors the signal above so the dismiss timer below can read it.
|
||||
property bool replyFocused: false
|
||||
|
||||
implicitHeight: card.implicitHeight
|
||||
|
||||
// Critical notifications stay until dismissed (the setting is 0). An app
|
||||
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
||||
// "server decides", which is our default.
|
||||
readonly property int timeoutMs: {
|
||||
if (root.notification.urgency === NotificationUrgency.Critical)
|
||||
return Settings.notificationTimeoutCriticalMs;
|
||||
if (root.notification.expireTimeout === 0)
|
||||
return 0;
|
||||
if (root.notification.expireTimeout > 0)
|
||||
return Math.round(root.notification.expireTimeout * 1000);
|
||||
return Settings.notificationTimeoutMs;
|
||||
}
|
||||
// "server decides", which is our default. Shared with Notifs.qml, which
|
||||
// gives a DND-hidden transient notification the same lifetime.
|
||||
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
Timer {
|
||||
// Hovering holds the banner open; the countdown restarts on leave.
|
||||
running: root.timeoutMs > 0 && !hover.hovered
|
||||
// Hovering, or actively typing a reply, holds the banner open; the
|
||||
// countdown restarts (from the top, same as hover) once both let go.
|
||||
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
|
||||
interval: root.timeoutMs
|
||||
onTriggered: Notifs.dropPopup(root.notification)
|
||||
}
|
||||
@@ -45,7 +41,10 @@ Item {
|
||||
width: parent.width
|
||||
notification: root.notification
|
||||
onDismissed: Notifs.dropPopup(root.notification)
|
||||
onReplyFocusChanged: focused => root.replyFocusChanged(focused)
|
||||
onReplyFocusChanged: focused => {
|
||||
root.replyFocused = focused;
|
||||
root.replyFocusChanged(focused);
|
||||
}
|
||||
|
||||
// Slide in from the right edge. Runs once, on creation.
|
||||
NumberAnimation on x {
|
||||
|
||||
@@ -45,7 +45,17 @@ PanelWindow {
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
// Notifs.popups.slice() is a fresh array on every change (a new
|
||||
// arrival, a dismissal, a sibling toast timing out). Handing that
|
||||
// straight to Repeater would reset the model and rebuild every
|
||||
// delegate each time, blowing away whichever toast has a reply
|
||||
// field mid-typing. ScriptModel diffs by object identity
|
||||
// (Notification instances are unique QObjects), so only genuinely
|
||||
// added/removed notifications add/remove delegates — unrelated
|
||||
// toasts, and their slide-in animations, are untouched.
|
||||
model: ScriptModel {
|
||||
values: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
}
|
||||
|
||||
Toast {
|
||||
required property var modelData
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// One workspace in the overview: a proportional card holding live thumbnails
|
||||
// of its windows, labelled underneath. The focused workspace is outlined in the
|
||||
// accent colour, as GNOME outlines the current workspace.
|
||||
// of its windows, labeled underneath. The focused workspace is outlined in the
|
||||
// accent color, as GNOME outlines the current workspace.
|
||||
//
|
||||
// A card with a null `workspace` is the trailing "new workspace" affordance
|
||||
// that dynamic-workspace setups (GNOME's, and Hyprland's) always show.
|
||||
|
||||
@@ -114,7 +114,7 @@ Item {
|
||||
|
||||
width: parent.width
|
||||
// BlueZ reports a plain freedesktop name ("audio-headphones");
|
||||
// the symbolic variant is the one that can be recoloured.
|
||||
// the symbolic variant is the one that can be recolored.
|
||||
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
|
||||
iconFallback: "bluetooth-symbolic"
|
||||
label: deviceRow.modelData.name || deviceRow.modelData.address
|
||||
|
||||
@@ -29,7 +29,7 @@ Item {
|
||||
property real backlightValue: 0
|
||||
|
||||
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||
readonly property bool labelled: root.rowCount > 1
|
||||
readonly property bool labeled: root.rowCount > 1
|
||||
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
@@ -89,7 +89,7 @@ Item {
|
||||
// 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.
|
||||
// fallback, so a display is never an unlabeled slider.
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
value: modelData.value / 100
|
||||
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||
@@ -113,7 +113,7 @@ Item {
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.top: parent.top
|
||||
visible: root.labelled
|
||||
visible: root.labeled
|
||||
height: visible ? implicitHeight + 2 : 0
|
||||
text: row.label
|
||||
color: Theme.fgDim
|
||||
@@ -124,7 +124,7 @@ Item {
|
||||
|
||||
// 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
|
||||
// both a caption and a center line instead would conflict, and an
|
||||
// anchor set to undefined is not released.
|
||||
Item {
|
||||
id: control
|
||||
@@ -134,7 +134,7 @@ Item {
|
||||
height: 32
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
// recoloring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
id: glyph
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// The three system power profiles, as an expandable list.
|
||||
//
|
||||
// A row per profile rather than a cycling button: there are three, and cycling
|
||||
// through them means passing through one you did not want on a machine where
|
||||
// the change is immediate and audible. The same shape the audio device lists
|
||||
// use, so the panel reads consistently.
|
||||
//
|
||||
// The daemon owns the profile -- it survives a shell restart and anything else
|
||||
// on the system can change it -- so this reads back rather than assuming, the
|
||||
// same as monitor brightness.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: PowerProfiles.profiles
|
||||
|
||||
RowButton {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
icon: {
|
||||
switch (modelData) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
label: PowerProfiles.label(modelData)
|
||||
sublabel: PowerProfiles.detail(modelData)
|
||||
selected: modelData === PowerProfiles.active
|
||||
dimmed: PowerProfiles.busy
|
||||
onClicked: PowerProfiles.set(modelData)
|
||||
}
|
||||
}
|
||||
|
||||
// Only when it is true. A machine that cannot deliver the profile it is set
|
||||
// to is the one case where the label alone is misleading.
|
||||
Text {
|
||||
visible: PowerProfiles.degraded !== ""
|
||||
width: root.width
|
||||
text: "Limited right now: " + PowerProfiles.degraded
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
leftPadding: 10
|
||||
topPadding: 4
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ PanelWindow {
|
||||
if (root.visible) {
|
||||
KdeConnect.refresh();
|
||||
HomeAssistant.refresh();
|
||||
// The daemon owns the power profile and anything on the system can
|
||||
// change it, so the panel asks rather than trusting what it last saw.
|
||||
PowerProfiles.refresh();
|
||||
openAnim.restart();
|
||||
} else {
|
||||
panel.reset();
|
||||
|
||||
@@ -130,6 +130,21 @@ Item {
|
||||
onToggled: Caffeine.toggle()
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
|
||||
label: "Appearance"
|
||||
sublabel: ColorScheme.dark ? "Dark" : "Light"
|
||||
// "active" reads as the non-default state across this grid, and
|
||||
// dark is what Panama ships, so light is the lit one.
|
||||
active: !ColorScheme.dark
|
||||
// Writes the preference and stops. ColorScheme propagates it to
|
||||
// GTK, the portal, the terminals, the launcher, btop, tmux and
|
||||
// the lock screen; nothing here needs to know that list.
|
||||
onToggled: SystemSettings.commitPreference("colorScheme",
|
||||
ColorScheme.dark ? "light" : "dark")
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: "night-light-symbolic"
|
||||
@@ -190,6 +205,38 @@ Item {
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
// ── Power ───────────────────────────────────────────────────────────
|
||||
// Only where a power-profiles daemon is actually running. A desktop
|
||||
// without one should not show a control that cannot do anything.
|
||||
RowButton {
|
||||
visible: PowerProfiles.available
|
||||
width: content.width
|
||||
icon: {
|
||||
switch (PowerProfiles.active) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
label: "Power profile"
|
||||
sublabel: PowerProfiles.degraded !== ""
|
||||
? PowerProfiles.label(PowerProfiles.active) + " · limited"
|
||||
: PowerProfiles.label(PowerProfiles.active)
|
||||
selected: root.expandedSection === "power"
|
||||
onClicked: root.expand("power")
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "power"
|
||||
|
||||
PowerProfileList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sliders ─────────────────────────────────────────────────────────
|
||||
AudioSlider {
|
||||
width: content.width
|
||||
|
||||
@@ -95,7 +95,7 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Full-height rather than vertically centred so slot contents can anchor
|
||||
// Full-height rather than vertically centered so slot contents can anchor
|
||||
// their own verticalCenter to it without a size loop.
|
||||
Row {
|
||||
id: trailingRow
|
||||
|
||||
@@ -137,7 +137,18 @@ Item {
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.networks
|
||||
// root.networks is a fresh array on every signal-strength tick (the
|
||||
// sort comparator reads signalStrength/connected/known, so any of
|
||||
// those changing on ANY network recomputes the whole list). Handing
|
||||
// that straight to Repeater would reset the model and rebuild every
|
||||
// delegate each tick, blowing away whichever row has its password
|
||||
// Section open and focused. ScriptModel diffs by object identity
|
||||
// (WifiNetwork instances are unique QObjects) and turns a reorder
|
||||
// into move operations, so existing delegates -- and their expanded
|
||||
// state -- survive.
|
||||
model: ScriptModel {
|
||||
values: root.networks
|
||||
}
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
|
||||
ScrollColumn 1.0 ScrollColumn.qml
|
||||
Section 1.0 Section.qml
|
||||
WifiList 1.0 WifiList.qml
|
||||
PowerProfileList 1.0 PowerProfileList.qml
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Choosing the desktop accent.
|
||||
//
|
||||
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
|
||||
// dot, because the gradient is the thing being chosen -- the focused window
|
||||
// border, the bar hairline and every active state are the two colors meeting.
|
||||
// A row of flat circles would misrepresent all of them.
|
||||
//
|
||||
// Named accents rather than a color wheel: each name carries a curated pair
|
||||
// per scheme, so every choice stays legible in both light and dark. See
|
||||
// config/Theme.qml for the palette and the reasoning.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
spacing: 10
|
||||
|
||||
readonly property string current: DesktopPreferences.get("accentName") || "blue"
|
||||
|
||||
// The schema's own option list, not Theme.accents directly -- two lists
|
||||
// hand-kept in sync is how they drift. This is the same pattern
|
||||
// ChoiceRow.qml uses for every other enum row.
|
||||
readonly property var spec: PreferenceSchema.spec("accentName")
|
||||
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
|
||||
|
||||
Repeater {
|
||||
// The schema's option order is the palette's order, so blue is first
|
||||
// because it is what Panama ships.
|
||||
model: root.options
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string name: entry.modelData.value
|
||||
readonly property var pair: Theme.accents[entry.name]
|
||||
readonly property bool selected: entry.name === root.current
|
||||
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
|
||||
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
|
||||
|
||||
spacing: 5
|
||||
|
||||
// The hit target is the whole swatch+label unit, not just the
|
||||
// 46px circle: the label exists specifically so someone with a
|
||||
// color vision deficiency can identify an accent without it, and
|
||||
// a label that cannot itself be tapped defeats that.
|
||||
HoverHandler {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
if (!SystemSettings.commitPreference("accentName", entry.name))
|
||||
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 46
|
||||
height: 46
|
||||
radius: 23
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: "transparent"
|
||||
// The ring sits outside the gradient rather than over it, so a
|
||||
// selected swatch still shows its true colors.
|
||||
border.width: entry.selected ? 2 : 1
|
||||
border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: entry.selected ? 4 : 3
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: entry.start }
|
||||
GradientStop { position: 1.0; color: entry.end }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always shown, not a tooltip. Telling swatches apart by color is
|
||||
// exactly what someone with a color vision deficiency cannot do,
|
||||
// and it is the reason the palette is named rather than freeform --
|
||||
// hiding the names behind a hover would waste that.
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: entry.modelData.label
|
||||
color: entry.selected ? Theme.fg : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: entry.selected ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,16 @@ SettingsPage {
|
||||
? Wallpaper.lastError
|
||||
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||
|
||||
WallpaperControls {
|
||||
id: wallpaperControls
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
WallpaperPicker {
|
||||
id: wallpapers
|
||||
width: parent.width
|
||||
mode: wallpaperControls.mode
|
||||
selectedOutput: wallpaperControls.selectedOutput
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
@@ -76,12 +83,36 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Colour scheme"
|
||||
title: "Lock screen"
|
||||
subtitle: LockScreen.lastError !== ""
|
||||
? LockScreen.lastError
|
||||
: "A representative preview of the screen shown before authentication."
|
||||
|
||||
LockScreenPreview {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ChoiceRow { setting: "lockBackgroundMode" }
|
||||
SliderRow { setting: "lockBlurLevel"; zeroLabel: "Off" }
|
||||
ToggleRow { setting: "lockShowClock" }
|
||||
ToggleRow { setting: "lockShowDate" }
|
||||
ToggleRow { setting: "lockShowUser" }
|
||||
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Color 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 }
|
||||
ChoiceRow { setting: "colorScheme" }
|
||||
|
||||
// Drawn as the gradient each accent produces rather than a flat dot,
|
||||
// because the gradient is what is being chosen.
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -300,7 +331,10 @@ SettingsPage {
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
ToggleRow { setting: "showGpu"; divider: true }
|
||||
// Refresh interval was on the Home page, which split one concept across
|
||||
// two pages -- what the vitals show here, how often they update there.
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property var applications: AudioDevices.applications
|
||||
property bool pipewireReady: Pipewire.ready
|
||||
|
||||
readonly property int rowCount: applicationRepeater.count
|
||||
readonly property string statusText: {
|
||||
if (!root.pipewireReady)
|
||||
return "PipeWire is unavailable";
|
||||
if (root.applications.length === 0)
|
||||
return "Applications playing sound will appear here";
|
||||
return "";
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
id: applicationRepeater
|
||||
model: root.applications
|
||||
|
||||
ApplicationVolumeRow {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
application: modelData
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.statusText !== ""
|
||||
text: root.statusText
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: 18
|
||||
bottomPadding: 18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.modules.quicksettings
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property var application
|
||||
|
||||
readonly property real volume: AudioDevices.applicationVolume(root.application)
|
||||
readonly property bool muted: AudioDevices.applicationMuted(root.application)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 78
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.fg, 0.025)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.application?.nodes ?? []
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
id: applicationIcon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 11
|
||||
size: 20
|
||||
icon: root.application?.icon ?? "audio-x-generic-symbolic"
|
||||
iconFallback: "audio-x-generic-symbolic"
|
||||
tint: Theme.fg
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: applicationIcon.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.application?.label ?? "Unknown application"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: (root.application?.nodes?.length ?? 0) > 1
|
||||
text: `${root.application?.nodes?.length ?? 0} audio streams`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 6
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.muted || root.volume <= 0.001
|
||||
? "audio-volume-muted-symbolic"
|
||||
: "audio-volume-high-symbolic"
|
||||
iconFallback: "audio-volume-high-symbolic"
|
||||
onClicked: AudioDevices.setApplicationMuted(root.application, !root.muted)
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 7
|
||||
anchors.right: volumeText.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
onMoved: value => AudioDevices.setApplicationVolume(root.application, value)
|
||||
}
|
||||
|
||||
Text {
|
||||
id: volumeText
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
width: 38
|
||||
text: Math.round(root.volume * 100) + "%"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ SettingRow {
|
||||
|
||||
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
||||
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
|
||||
readonly property bool available: root.node?.audio
|
||||
readonly property bool available: !!root.node?.audio
|
||||
&& root.leftIndex >= 0 && root.rightIndex >= 0
|
||||
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// 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
|
||||
// polkit, and a canceled dialog surfaces as an error rather than as a value
|
||||
// that appears to have been accepted.
|
||||
|
||||
import QtQuick
|
||||
@@ -33,7 +33,7 @@ SettingsPage {
|
||||
SettingRow {
|
||||
label: "Set automatically"
|
||||
detail: DateTime.ntpEnabled
|
||||
? (DateTime.ntpSynchronised ? "Synchronised with a time server" : "Waiting to synchronise")
|
||||
? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize")
|
||||
: "The clock is set by hand"
|
||||
controlWidth: 48
|
||||
divider: false
|
||||
|
||||
@@ -50,7 +50,7 @@ Rectangle {
|
||||
|
||||
// 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
|
||||
// Follows the color 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 {
|
||||
@@ -129,7 +129,7 @@ Rectangle {
|
||||
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
|
||||
// body inset on top of it: QML's Rectangle border takes a color, not a
|
||||
// gradient, and the prism border is the whole signature here.
|
||||
Rectangle {
|
||||
id: frame
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
import "../../services/DisplayLayout.js" as DisplayLayout
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var displayService
|
||||
property string selectedOutput: ""
|
||||
property var draftLayout: []
|
||||
property bool interactionEnabled: true
|
||||
|
||||
signal selectionRequested(string output)
|
||||
|
||||
implicitHeight: content.implicitHeight
|
||||
readonly property var canvasData: DisplayLayout.canvasRects(
|
||||
root.draftLayout, canvas.width, canvas.height, 18)
|
||||
|
||||
function copied(layout): var {
|
||||
return (layout || []).map(record => Object.assign({}, record));
|
||||
}
|
||||
|
||||
function resetDraft(): void {
|
||||
root.draftLayout = root.copied(root.displayService.currentLayout());
|
||||
}
|
||||
|
||||
function setDraftPosition(output: string, x: real, y: real, snapToEdges: bool): bool {
|
||||
const next = root.copied(root.draftLayout);
|
||||
const record = next.find(candidate => candidate.name === output);
|
||||
if (!record)
|
||||
return false;
|
||||
record.x = Math.round(x);
|
||||
record.y = Math.round(y);
|
||||
root.draftLayout = snapToEdges ? DisplayLayout.snap(next, output, 16) : next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function nudge(output: string, dx: int, dy: int): bool {
|
||||
const record = root.draftLayout.find(candidate => candidate.name === output);
|
||||
return !!record && root.setDraftPosition(output, record.x + dx, record.y + dy, false);
|
||||
}
|
||||
|
||||
function applyDraft(): bool {
|
||||
return root.displayService.applyLayout(root.copied(root.draftLayout));
|
||||
}
|
||||
|
||||
function makePrimary(output: string): bool {
|
||||
const next = root.copied(root.draftLayout);
|
||||
if (!next.some(record => record.name === output))
|
||||
return false;
|
||||
for (const record of next)
|
||||
record.primary = record.name === output;
|
||||
root.draftLayout = DisplayLayout.normalize(next);
|
||||
return root.applyDraft();
|
||||
}
|
||||
|
||||
function canvasSnapshot(): var {
|
||||
return {
|
||||
bounds: root.canvasData.bounds,
|
||||
scale: root.canvasData.scale,
|
||||
rects: root.canvasData.rects.map(record => Object.assign({}, record))
|
||||
};
|
||||
}
|
||||
|
||||
Component.onCompleted: root.resetDraft()
|
||||
|
||||
Connections {
|
||||
target: root.displayService
|
||||
ignoreUnknownSignals: true
|
||||
function onMonitorsChanged(): void {
|
||||
if (!root.displayService.awaitingConfirmation)
|
||||
root.resetDraft();
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width
|
||||
spacing: 11
|
||||
|
||||
Rectangle {
|
||||
id: canvas
|
||||
width: parent.width
|
||||
height: root.width >= 620 ? 232 : 190
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.76)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
clip: true
|
||||
|
||||
// A restrained coordinate field makes the topology feel like a
|
||||
// precision instrument without turning it into a technical graph.
|
||||
Repeater {
|
||||
model: 4
|
||||
Rectangle {
|
||||
required property int index
|
||||
y: (index + 1) * canvas.height / 5
|
||||
width: canvas.width
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.025)
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.canvasData.rects
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
required property var modelData
|
||||
readonly property bool selected: root.selectedOutput === modelData.name
|
||||
readonly property var draft: root.draftLayout.find(
|
||||
record => record.name === modelData.name)
|
||||
|
||||
x: modelData.x
|
||||
y: modelData.y
|
||||
width: Math.max(64, modelData.width)
|
||||
height: Math.max(48, modelData.height)
|
||||
radius: 11
|
||||
color: tile.selected
|
||||
? Theme.alpha(Theme.bgHighlight, 0.92)
|
||||
: Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78)
|
||||
border.width: tile.selected || activeFocus ? 2 : 1
|
||||
border.color: activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14))
|
||||
opacity: root.interactionEnabled ? 1 : 0.5
|
||||
activeFocusOnTab: root.interactionEnabled
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: "Move " + tile.modelData.name
|
||||
Accessible.description: tile.draft && tile.draft.primary
|
||||
? "Primary display. Drag or use the arrow keys to move it."
|
||||
: "Drag or use the arrow keys to move this display."
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
radius: parent.radius - 2
|
||||
visible: tile.selected
|
||||
opacity: 0.24
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.accent }
|
||||
GradientStop { position: 1; color: Theme.accentSecondary }
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.margins: 11
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: tile.modelData.name
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: tile.draft
|
||||
? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}`
|
||||
: ""
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 7
|
||||
width: primaryText.implicitWidth + 12
|
||||
height: 20
|
||||
radius: Theme.pillRadius
|
||||
visible: tile.draft && tile.draft.primary
|
||||
color: Theme.alpha(Theme.accent, 0.2)
|
||||
|
||||
Text {
|
||||
id: primaryText
|
||||
anchors.centerIn: parent
|
||||
text: "Primary"
|
||||
color: Theme.accentAlt
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
enabled: root.interactionEnabled
|
||||
cursorShape: Qt.OpenHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.interactionEnabled
|
||||
onTapped: {
|
||||
root.selectionRequested(tile.modelData.name);
|
||||
tile.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
DragHandler {
|
||||
id: drag
|
||||
target: null
|
||||
enabled: root.interactionEnabled
|
||||
property real initialX: 0
|
||||
property real initialY: 0
|
||||
property bool moved: false
|
||||
|
||||
onActiveChanged: {
|
||||
if (active) {
|
||||
const record = root.draftLayout.find(
|
||||
candidate => candidate.name === tile.modelData.name);
|
||||
initialX = record ? record.x : 0;
|
||||
initialY = record ? record.y : 0;
|
||||
moved = false;
|
||||
root.selectionRequested(tile.modelData.name);
|
||||
tile.forceActiveFocus();
|
||||
} else if (moved) {
|
||||
const record = root.draftLayout.find(
|
||||
candidate => candidate.name === tile.modelData.name);
|
||||
if (record) {
|
||||
root.setDraftPosition(tile.modelData.name, record.x, record.y, true);
|
||||
root.applyDraft();
|
||||
}
|
||||
}
|
||||
}
|
||||
onTranslationChanged: {
|
||||
if (!active || root.canvasData.scale <= 0)
|
||||
return;
|
||||
moved = true;
|
||||
root.setDraftPosition(
|
||||
tile.modelData.name,
|
||||
initialX + translation.x / root.canvasData.scale,
|
||||
initialY + translation.y / root.canvasData.scale,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (!root.interactionEnabled)
|
||||
return;
|
||||
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
|
||||
let handled = true;
|
||||
if (event.key === Qt.Key_Left)
|
||||
root.nudge(tile.modelData.name, -step, 0);
|
||||
else if (event.key === Qt.Key_Right)
|
||||
root.nudge(tile.modelData.name, step, 0);
|
||||
else if (event.key === Qt.Key_Up)
|
||||
root.nudge(tile.modelData.name, 0, -step);
|
||||
else if (event.key === Qt.Key_Down)
|
||||
root.nudge(tile.modelData.name, 0, step);
|
||||
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
|
||||
root.applyDraft();
|
||||
else if (event.key === Qt.Key_Escape)
|
||||
root.resetDraft();
|
||||
else
|
||||
handled = false;
|
||||
event.accepted = handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
width: Math.max(0, parent.width - identifyButton.width
|
||||
- primaryButton.width - applyButton.width - 24)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.width >= 600
|
||||
? "Drag to arrange · arrows move 10 px · Shift moves 100 px"
|
||||
: "Drag or use the arrow keys"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: identifyButton
|
||||
text: "Identify"
|
||||
enabled: root.interactionEnabled
|
||||
onClicked: root.displayService.identify()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: primaryButton
|
||||
text: "Make primary"
|
||||
enabled: root.interactionEnabled && root.selectedOutput !== ""
|
||||
&& !root.draftLayout.find(record =>
|
||||
record.name === root.selectedOutput)?.primary
|
||||
onClicked: root.makePrimary(root.selectedOutput)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: applyButton
|
||||
text: "Apply"
|
||||
enabled: root.interactionEnabled
|
||||
onClicked: root.applyDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
property var modelData: null
|
||||
readonly property string connector: win.modelData?.name ?? "Display"
|
||||
readonly property int number: Math.max(1,
|
||||
Displays.monitors.findIndex(monitor => monitor.name === win.connector) + 1)
|
||||
readonly property string description: Displays.monitorNamed(win.connector)?.description ?? "Connected display"
|
||||
|
||||
screen: win.modelData
|
||||
visible: Displays.identifying
|
||||
implicitWidth: 260
|
||||
implicitHeight: 172
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
mask: Region {}
|
||||
|
||||
WlrLayershell.namespace: "qs-display-identify"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.96)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 32
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: String(win.number)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 68
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: win.connector
|
||||
color: Theme.accentAlt
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: win.description
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,20 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Displays.monitors.length > 1
|
||||
title: "Arrange displays"
|
||||
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
|
||||
|
||||
DisplayArrangement {
|
||||
width: parent.width
|
||||
displayService: Displays
|
||||
selectedOutput: root.selectedOutput
|
||||
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onSelectionRequested: output => root.selectedOutput = output
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Displays.monitors.length > 1
|
||||
title: "Connected display"
|
||||
|
||||
@@ -440,9 +440,9 @@ SettingsPage {
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:color"
|
||||
label: "Colour profiles"
|
||||
detail: "ICC profiles for displays, printers, and scanners"
|
||||
action: "Open colour"
|
||||
label: "Color profiles"
|
||||
detail: "Assigning an ICC profile here has no visible effect in this session: the colord daemon that loads it onto a display isn't running under Hyprland"
|
||||
action: "Open color"
|
||||
onTriggered: SystemSettings.openGnomePanel("color")
|
||||
}
|
||||
|
||||
|
||||
@@ -117,12 +117,6 @@ SettingsPage {
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Processor, memory, and graphics activity in the bar"
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: false }
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: summaryCards
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string backgroundMode: DesktopPreferences.get("lockBackgroundMode")
|
||||
property int blurLevel: DesktopPreferences.get("lockBlurLevel")
|
||||
property bool showClock: DesktopPreferences.get("lockShowClock")
|
||||
property bool showDate: DesktopPreferences.get("lockShowDate")
|
||||
property bool showUser: DesktopPreferences.get("lockShowUser")
|
||||
property bool fadeOnEmpty: DesktopPreferences.get("lockFadeOnEmpty")
|
||||
property bool use24Hour: DesktopPreferences.get("use24Hour")
|
||||
property string wallpaperPath: Wallpaper.active !== ""
|
||||
? Wallpaper.active
|
||||
: (Wallpaper.configured !== "" ? Wallpaper.configured : Wallpaper.shippedPath)
|
||||
|
||||
readonly property string previewMode: root.backgroundMode
|
||||
readonly property real blurStrength: Math.max(0, Math.min(1, root.blurLevel / 5))
|
||||
readonly property bool wallpaperVisible: wallpaper.visible
|
||||
readonly property bool clockVisible: clockLabel.visible
|
||||
readonly property bool dateVisible: dateLabel.visible
|
||||
readonly property bool userVisible: userLabel.visible
|
||||
readonly property bool passwordVisible: passwordField.visible
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 230
|
||||
radius: Theme.cardRadius
|
||||
clip: true
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.10)
|
||||
|
||||
Image {
|
||||
id: wallpaper
|
||||
anchors.fill: parent
|
||||
visible: root.backgroundMode === "wallpaper"
|
||||
source: visible && root.wallpaperPath !== "" ? root.wallpaperPath : ""
|
||||
asynchronous: true
|
||||
cache: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize.width: 960
|
||||
sourceSize.height: 540
|
||||
}
|
||||
|
||||
// Screenshot mode stays representative instead of taking a real desktop
|
||||
// capture inside Settings. The layered window silhouettes communicate the
|
||||
// selected softness without a live ShaderEffect or GPU repaint loop.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.backgroundMode === "screenshot"
|
||||
color: Theme.bgDark
|
||||
|
||||
Rectangle {
|
||||
x: parent.width * 0.10
|
||||
y: parent.height * 0.12
|
||||
width: parent.width * 0.45
|
||||
height: parent.height * 0.66
|
||||
radius: 14 + root.blurStrength * 10
|
||||
color: Theme.alpha(Theme.bgHighlight, 0.52 - root.blurStrength * 0.18)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.14)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: parent.width * 0.48
|
||||
y: parent.height * 0.24
|
||||
width: parent.width * 0.40
|
||||
height: parent.height * 0.57
|
||||
radius: 14 + root.blurStrength * 10
|
||||
color: Theme.alpha(Theme.bgPanel, 0.64 - root.blurStrength * 0.20)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accentSecondary, 0.13)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.backgroundMode === "wallpaper"
|
||||
? Theme.alpha(Theme.bg, 0.42)
|
||||
: Theme.alpha(Theme.bg, 0.12 + root.blurStrength * 0.16)
|
||||
}
|
||||
|
||||
Text {
|
||||
id: clockLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 26
|
||||
visible: root.showClock
|
||||
text: Qt.formatDateTime(clock.date, root.use24Hour ? "HH:mm" : "h:mm")
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 42
|
||||
font.weight: Font.Light
|
||||
}
|
||||
|
||||
Text {
|
||||
id: dateLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: clockLabel.visible ? clockLabel.bottom : parent.top
|
||||
anchors.topMargin: clockLabel.visible ? 1 : 35
|
||||
visible: root.showDate
|
||||
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: passwordField
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: root.showUser ? 48 : 30
|
||||
visible: !root.fadeOnEmpty
|
||||
width: Math.min(270, parent.width * 0.48)
|
||||
height: 36
|
||||
radius: height / 2
|
||||
color: Theme.alpha(Theme.bgPanel, 0.84)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.78)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Password"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.italic: true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: userLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 19
|
||||
visible: root.showUser
|
||||
text: Quickshell.env("USER") || "User"
|
||||
color: Theme.alpha(Theme.fg, 0.90)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Minutes
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ SettingsPage {
|
||||
|
||||
title: "Mouse & Touchpad"
|
||||
lede: InputDevices.hasTouchpad
|
||||
? "Pointer behaviour for your mouse and touchpad."
|
||||
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
|
||||
? "Pointer behavior for your mouse and touchpad."
|
||||
: "Pointer behavior. Touchpad settings appear when a touchpad is attached."
|
||||
|
||||
SettingsCard {
|
||||
title: "Mouse"
|
||||
|
||||
@@ -52,7 +52,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Idle behaviour"
|
||||
title: "Idle behavior"
|
||||
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."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Settings
|
||||
|
||||
The control centre for everything Panama owns. Anything the system owns —
|
||||
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
||||
The control center for everything Panama owns. Anything the system owns —
|
||||
hardware, accounts, printers — is delegated to GNOME Settings and labeled as
|
||||
such rather than half-reimplemented.
|
||||
|
||||
## System Health
|
||||
@@ -28,7 +28,7 @@ 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
|
||||
Users, Sharing, Color profiles, and Digital wellbeing handoffs open GNOME
|
||||
Settings because Fedora's system services own those areas.
|
||||
|
||||
## Adding a setting
|
||||
@@ -59,6 +59,49 @@ 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.
|
||||
|
||||
## Setting ownership
|
||||
|
||||
Every preference has **one primary page**, derived from its schema `group` and
|
||||
the route in `services/SettingsSearch.qml`. Search results always open that
|
||||
owner. A control may appear on a second page only when the same adaptation is
|
||||
part of another established mental model; otherwise use a labeled handoff to
|
||||
the owner instead of duplicating it.
|
||||
|
||||
### Intentional mirrors
|
||||
|
||||
| Setting | Primary page | Mirror | Why the mirror earns its place |
|
||||
|---|---|---|---|
|
||||
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
|
||||
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. |
|
||||
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
|
||||
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
|
||||
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
|
||||
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
|
||||
|
||||
Lock-screen visuals belong only to **Appearance**: background source, blur,
|
||||
clock, date, user name, and password-field presentation. **Power** owns when
|
||||
the session locks, while **Privacy** keeps only the established timing mirrors
|
||||
above. Visual controls must not be copied onto either page.
|
||||
|
||||
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
|
||||
Those values form one safety transaction: every connected output
|
||||
is applied, verified, confirmed, or restored together. Other pages may link to
|
||||
Displays, but must never expose a second geometry control or persist a partial
|
||||
layout.
|
||||
|
||||
Mirrors must remain the same schema-backed control, never a second preference
|
||||
or a copied default. Additions to this table require a concrete discoverability
|
||||
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
|
||||
|
||||
Window border color follows the same ownership rule. The inactive border is a
|
||||
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
|
||||
neutral contrast in light and dark modes. The focused Prism border is the
|
||||
**accent role**, driven by the chosen `accentName` and also owned by
|
||||
`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
|
||||
`ColorScheme.qml` restates the focused border alongside the inactive one on
|
||||
every scheme change, rather than leaving a scheme flip to erase a
|
||||
user-selected accent.
|
||||
|
||||
## The rows
|
||||
|
||||
| Component | For |
|
||||
@@ -122,9 +165,10 @@ file with `qs -p`, and discover its PID from the exact Config path in
|
||||
| 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/panama-home.json` | Home accessory favorites and aliases |
|
||||
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
|
||||
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
|
||||
| `$XDG_STATE_HOME/panama/hyprlock.conf` | Generated lock-screen appearance |
|
||||
|
||||
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
|
||||
skipped one would be worse than having no reset, because nothing would say so.
|
||||
|
||||
@@ -19,7 +19,7 @@ FocusScope {
|
||||
id: root
|
||||
|
||||
signal captured(string chord)
|
||||
signal cancelled
|
||||
signal canceled
|
||||
|
||||
property string message: ""
|
||||
|
||||
@@ -97,7 +97,7 @@ FocusScope {
|
||||
event.accepted = true;
|
||||
|
||||
if (event.key === 0x01000000) { // Escape
|
||||
root.cancelled();
|
||||
root.canceled();
|
||||
return;
|
||||
}
|
||||
if (root.isModifierOnly(event.key))
|
||||
@@ -127,6 +127,6 @@ FocusScope {
|
||||
|
||||
onActiveFocusChanged: {
|
||||
if (!activeFocus)
|
||||
root.cancelled();
|
||||
root.canceled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// 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;
|
||||
// repeat behavior, and pointer response are Hyprland's, so Panama owns them;
|
||||
// device-specific configuration stays with GNOME.
|
||||
|
||||
import QtQuick
|
||||
@@ -174,7 +174,7 @@ SettingsPage {
|
||||
Keybinds.rebind(bindRow.modelData.luaChord, chord);
|
||||
root.capturingChord = "";
|
||||
}
|
||||
onCancelled: root.capturingChord = ""
|
||||
onCanceled: root.capturingChord = ""
|
||||
}
|
||||
|
||||
Row {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Range, step, label, explanation, and unit all come from PreferenceSchema.
|
||||
//
|
||||
// Three behaviours worth knowing:
|
||||
// Three behaviors 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
|
||||
@@ -51,9 +51,11 @@ Item {
|
||||
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
|
||||
// Shown while dragging; null means "nothing pending, show what is stored".
|
||||
// Not -1: several schema entries (e.g. pointerSensitivity) are legitimately
|
||||
// negative, and -1 would be indistinguishable from a real committed value.
|
||||
property var pending: null
|
||||
readonly property real shown: root.pending !== null ? root.pending : root.stored
|
||||
|
||||
// Below this the label and a usable slider cannot share a line without one
|
||||
// of them becoming useless.
|
||||
@@ -61,7 +63,7 @@ Item {
|
||||
|
||||
// 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
|
||||
// would be a meter long next to a two-word label -- and this window is
|
||||
// tiled, so it really can be that wide.
|
||||
readonly property int controlSpan: 300
|
||||
|
||||
@@ -176,7 +178,7 @@ Item {
|
||||
id: commitTimer
|
||||
interval: 140
|
||||
onTriggered: {
|
||||
if (root.pending < 0)
|
||||
if (root.pending === null)
|
||||
return;
|
||||
SystemSettings.commitPreference(root.setting, root.pending);
|
||||
// Hand the display back to the stored value. If the write was
|
||||
@@ -188,6 +190,6 @@ Item {
|
||||
Timer {
|
||||
id: releaseTimer
|
||||
interval: 160
|
||||
onTriggered: root.pending = -1
|
||||
onTriggered: root.pending = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,15 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Applications"
|
||||
subtitle: "Control each application currently playing through PipeWire."
|
||||
|
||||
ApplicationMixer {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Sound feedback"
|
||||
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
||||
@@ -67,8 +76,8 @@ SettingsPage {
|
||||
title: "Advanced sound"
|
||||
|
||||
ActionRow {
|
||||
label: "Application volumes and profiles"
|
||||
detail: "Open Fedora's complete sound panel"
|
||||
label: "Device profiles"
|
||||
detail: "Open Fedora's complete device profile panel"
|
||||
divider: false
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("sound")
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// setting it edits.
|
||||
//
|
||||
// Rejected input is shown as rejected rather than silently dropped or quietly
|
||||
// sanitised. Several of these strings are serialised into an hl.config payload,
|
||||
// sanitised. Several of these strings are serialized into an hl.config payload,
|
||||
// where stripping an unexpected character would turn a typo into a different
|
||||
// working setting -- so the schema's pattern decides, the field turns red when
|
||||
// it fails, and nothing is committed until it passes.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// clock. This is the same control with the readout formatted as a time, so the
|
||||
// night light schedule says "5:30 PM" rather than a number you have to convert.
|
||||
//
|
||||
// Honours the 24-hour clock preference, because a user who has asked for 18:30
|
||||
// Honors the 24-hour clock preference, because a user who has asked for 18:30
|
||||
// everywhere else should not be shown 6:30 PM here.
|
||||
|
||||
import QtQuick
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string mode: DesktopPreferences.get("wallpaperMode")
|
||||
property var outputs: Wallpaper.outputNames()
|
||||
property string selectedOutput: root.outputs.length > 0 ? root.outputs[0] : ""
|
||||
|
||||
property var setModeAction: function(mode) { Wallpaper.setMode(mode); }
|
||||
property var setIntervalAction: function(minutes) { Wallpaper.setIntervalMinutes(minutes); }
|
||||
property var setShuffleAction: function(enabled) { Wallpaper.setShuffle(enabled); }
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 4
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Wallpaper mode"
|
||||
detail: "Use one image, rotate a collection, or choose per display"
|
||||
options: PreferenceSchema.spec("wallpaperMode").options
|
||||
current: root.mode
|
||||
onPicked: value => root.setModeAction(value)
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
visible: root.mode === "per-monitor"
|
||||
width: parent.width
|
||||
label: "Display"
|
||||
detail: "Choose which display the thumbnail grid assigns"
|
||||
options: root.outputs.map(output => ({ value: output, label: output }))
|
||||
current: root.selectedOutput
|
||||
onPicked: value => root.selectedOutput = value
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: root.mode === "slideshow"
|
||||
label: "Change background every"
|
||||
detail: "Time between slideshow images"
|
||||
controlWidth: 280
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: parent.left
|
||||
anchors.right: intervalText.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: (DesktopPreferences.get("wallpaperIntervalMinutes") - 5) / 1435
|
||||
onMoved: ratio => {
|
||||
const raw = 5 + ratio * 1435;
|
||||
root.setIntervalAction(Math.max(5, Math.min(1440, Math.round(raw / 5) * 5)));
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: intervalText
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 62
|
||||
text: DesktopPreferences.get("wallpaperIntervalMinutes") + " min"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: root.mode === "slideshow"
|
||||
label: "Shuffle"
|
||||
detail: "Show every selected image before repeating"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: DesktopPreferences.get("wallpaperShuffle") === true
|
||||
onToggled: enabled => root.setShuffleAction(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.mode === "slideshow"
|
||||
&& (DesktopPreferences.get("wallpaperSlideshowPaths") ?? []).length === 0
|
||||
text: "Select two or more images below to begin a slideshow."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: 5
|
||||
bottomPadding: 8
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ Item {
|
||||
// unreachable without a long scroll past pictures. Two rows by default,
|
||||
// all of them on request.
|
||||
property bool expanded: false
|
||||
property string mode: DesktopPreferences.get("wallpaperMode")
|
||||
property string selectedOutput: Wallpaper.outputNames()[0] ?? ""
|
||||
property var activeByOutput: Wallpaper.activeByOutput
|
||||
property var slideshowPaths: DesktopPreferences.get("wallpaperSlideshowPaths") ?? []
|
||||
property var assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({})
|
||||
property var setSingleAction: function(path) { Wallpaper.setSingle(path); }
|
||||
property var toggleSlideshowAction: function(path) { Wallpaper.toggleSlideshowPath(path); }
|
||||
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
|
||||
readonly property int collapsedRows: 2
|
||||
|
||||
readonly property var shown: root.expanded
|
||||
@@ -35,6 +43,27 @@ Item {
|
||||
readonly property int columns: Math.max(2, Math.floor(width / 190))
|
||||
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
||||
|
||||
function isCurrent(path: string): bool {
|
||||
return root.activeByOutput[root.selectedOutput] === path;
|
||||
}
|
||||
|
||||
function selected(path: string): bool {
|
||||
if (root.mode === "slideshow")
|
||||
return root.slideshowPaths.includes(path);
|
||||
if (root.mode === "per-monitor")
|
||||
return root.assignments[root.selectedOutput] === path;
|
||||
return root.isCurrent(path);
|
||||
}
|
||||
|
||||
function activate(path: string): void {
|
||||
if (root.mode === "slideshow")
|
||||
root.toggleSlideshowAction(path);
|
||||
else if (root.mode === "per-monitor")
|
||||
root.setAssignmentAction(root.selectedOutput, path);
|
||||
else
|
||||
root.setSingleAction(path);
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: grid
|
||||
|
||||
@@ -50,7 +79,8 @@ Item {
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool current: Wallpaper.active === tile.modelData
|
||||
readonly property bool current: root.isCurrent(tile.modelData)
|
||||
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
|
||||
|
||||
width: root.cellWidth
|
||||
height: Math.round(root.cellWidth * 9 / 16)
|
||||
@@ -114,6 +144,28 @@ Item {
|
||||
border.color: Theme.accent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 12
|
||||
visible: tile.member
|
||||
color: Theme.alpha(Theme.bgDark, 0.82)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.16)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "✓"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
@@ -132,7 +184,9 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 7
|
||||
text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData)
|
||||
text: tile.current
|
||||
? "Current wallpaper"
|
||||
: (tile.member ? "In slideshow" : Wallpaper.titleFor(tile.modelData))
|
||||
color: tile.current ? Theme.accent : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
@@ -144,7 +198,7 @@ Item {
|
||||
HoverHandler { id: hover }
|
||||
|
||||
TapHandler {
|
||||
onTapped: Wallpaper.set(tile.modelData)
|
||||
onTapped: root.activate(tile.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ ChoiceRow 1.0 ChoiceRow.qml
|
||||
ActionRow 1.0 ActionRow.qml
|
||||
TextRow 1.0 TextRow.qml
|
||||
DesktopPreview 1.0 DesktopPreview.qml
|
||||
LockScreenPreview 1.0 LockScreenPreview.qml
|
||||
PowerPage 1.0 PowerPage.qml
|
||||
DateTimePage 1.0 DateTimePage.qml
|
||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
WallpaperControls 1.0 WallpaperControls.qml
|
||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||
@@ -40,12 +42,16 @@ DockAppPicker 1.0 DockAppPicker.qml
|
||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||
DisplayArrangement 1.0 DisplayArrangement.qml
|
||||
DisplayIdentify 1.0 DisplayIdentify.qml
|
||||
WifiPanel 1.0 WifiPanel.qml
|
||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||
PasswordField 1.0 PasswordField.qml
|
||||
AudioBalance 1.0 AudioBalance.qml
|
||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||
ApplicationMixer 1.0 ApplicationMixer.qml
|
||||
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
|
||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||
LocationPicker 1.0 LocationPicker.qml
|
||||
FontPicker 1.0 FontPicker.qml
|
||||
@@ -55,3 +61,4 @@ PrivacyPage 1.0 PrivacyPage.qml
|
||||
RegionPage 1.0 RegionPage.qml
|
||||
SearchPicker 1.0 SearchPicker.qml
|
||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||
AccentPicker 1.0 AccentPicker.qml
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// The Alt-Tab overlay.
|
||||
//
|
||||
// Deliberately a list of names rather than thumbnails: at a glance you are
|
||||
// looking for "the other terminal", and a row of small live previews is both
|
||||
// slower to read and considerably more expensive to draw than the gesture
|
||||
// deserves. The dock already renders app identity this way, so the two agree.
|
||||
//
|
||||
// Only present while a switch is in progress -- there is nothing to keep alive
|
||||
// between gestures, and a hidden always-loaded overlay is a surface that can
|
||||
// go wrong while nobody is looking at it.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Loader {
|
||||
id: root
|
||||
|
||||
// Plain `modelData`, not `required property var screen`. Variants supplies
|
||||
// modelData, and shell.qml's own comment warns about exactly this: declaring
|
||||
// `required property var screen` means the screen never resolves, the window
|
||||
// is constructed and silently never maps, and NOTHING is logged. Bar and
|
||||
// Dock both take the screen this way.
|
||||
property var modelData: null
|
||||
|
||||
active: WindowSwitcherState.open
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
screen: root.modelData
|
||||
// Overlay so it sits above the focused window it is describing.
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.namespace: "qs-switcher"
|
||||
// Nothing here is clickable: the gesture is driven entirely from the
|
||||
// keyboard, and taking input would steal focus from the compositor
|
||||
// mid-switch, which is the one thing that would break it.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(560, parent.width - 96)
|
||||
implicitHeight: layout.implicitHeight + 24
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.97)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: WindowSwitcherState.windows
|
||||
|
||||
Rectangle {
|
||||
id: row
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool current: row.index === WindowSwitcherState.index
|
||||
readonly property string appId: row.modelData?.wayland?.appId ?? ""
|
||||
readonly property var entry: DesktopEntries.heuristicLookup(row.appId)
|
||||
|
||||
width: parent.width
|
||||
height: 44
|
||||
radius: 10
|
||||
border.width: 0
|
||||
color: row.current ? Theme.alpha(Theme.accent, 0.20) : "transparent"
|
||||
|
||||
Image {
|
||||
id: icon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 24
|
||||
height: 24
|
||||
sourceSize.width: 24
|
||||
sourceSize.height: 24
|
||||
source: row.entry?.icon ? Quickshell.iconPath(row.entry.icon, true) : ""
|
||||
visible: source !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: icon.visible ? icon.right : parent.left
|
||||
anchors.leftMargin: icon.visible ? 12 : 14
|
||||
anchors.right: appName.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// A window with no title yet is still a window you
|
||||
// can switch to; naming it after its application is
|
||||
// better than an empty row.
|
||||
text: row.modelData?.title || row.entry?.name || row.appId
|
||||
elide: Text.ElideRight
|
||||
color: row.current ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: row.current ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
Text {
|
||||
id: appName
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: (row.entry?.name ?? "") !== "" && row.entry.name !== row.modelData?.title
|
||||
text: row.entry?.name ?? ""
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,11 @@ def watch(start: int, end: int) -> int:
|
||||
return 1
|
||||
|
||||
loop = GLib.MainLoop()
|
||||
try:
|
||||
registry = EDataServer.SourceRegistry.new_sync(None)
|
||||
except Exception:
|
||||
_write_snapshot(_unavailable_snapshot())
|
||||
return 1
|
||||
debounce_source = 0
|
||||
subscriptions: list[int] = []
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ class DoctorConfig:
|
||||
runtime_dir: Path
|
||||
path: str
|
||||
timeout: float
|
||||
# Defaults to this file's own directory, where its sibling helpers
|
||||
# (panama-action, panama-brightness, calendar-agenda) actually live.
|
||||
scripts_dir: Path = Path(__file__).resolve().parent
|
||||
# Repair actions (service restarts, the Quickshell restart-shell action)
|
||||
# can legitimately run longer than a quick health-check probe -- a
|
||||
# Quickshell restart alone waits for the old process to exit, the new one
|
||||
# to start, and settle. Reusing `timeout` here would kill a slow-but-
|
||||
# successful repair and report it as failed even though a following
|
||||
# health scan would show everything recovered. See run_repair_command.
|
||||
repair_timeout: float = 15.0
|
||||
|
||||
@property
|
||||
def command_env(self) -> dict[str, str]:
|
||||
@@ -102,7 +112,7 @@ class RepairResult:
|
||||
|
||||
CHECK_ORDER = (
|
||||
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
|
||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire",
|
||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
|
||||
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
||||
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
||||
@@ -114,8 +124,13 @@ SYSTEMCTL_COMMANDS = {
|
||||
"hypridle": ("systemctl", "--user", "is-active", "--quiet", "hypridle.service"),
|
||||
"vicinae": ("systemctl", "--user", "is-active", "--quiet", "vicinae.service"),
|
||||
"pipewire": ("systemctl", "--user", "is-active", "--quiet", "pipewire.service"),
|
||||
"nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"),
|
||||
"rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.service"),
|
||||
# System-scope, not --user: RustDesk ships an enabled *system* service
|
||||
# (`rustdesk --service`, root-owned) that spawns the session --server and
|
||||
# --tray on its own -- see autostart.lua's comment on why Panama doesn't
|
||||
# start it a second time. Querying --user here always reports inactive,
|
||||
# since no such user-scope unit exists, regardless of whether the real
|
||||
# service is running.
|
||||
"rustdesk": ("systemctl", "is-active", "--quiet", "rustdesk.service"),
|
||||
}
|
||||
REPAIR_COMMANDS = MappingProxyType({
|
||||
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
|
||||
@@ -159,6 +174,7 @@ CHECK_TITLES = {
|
||||
"desktop.portals": "Desktop portals",
|
||||
"desktop.hyprpaper": "Hyprpaper",
|
||||
"desktop.hypridle": "Hypridle",
|
||||
"desktop.hyprlock": "Lock screen",
|
||||
"desktop.vicinae": "Vicinae",
|
||||
"input.pipewire": "PipeWire",
|
||||
"input.clipboard": "Clipboard",
|
||||
@@ -192,11 +208,25 @@ def config_from_environment() -> DoctorConfig:
|
||||
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
|
||||
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
|
||||
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
|
||||
# Sibling helpers (panama-action, panama-brightness, calendar-agenda) live
|
||||
# next to this file. PATH is not a reliable way to find them -- nothing in
|
||||
# this repository puts the Quickshell scripts directory on PATH -- so they
|
||||
# are invoked by resolved path instead, the same way check_hyprlock already
|
||||
# resolves panama-lock.
|
||||
scripts_dir = environment_path("PANAMA_DOCTOR_SCRIPTS_DIR", Path(__file__).resolve().parent)
|
||||
try:
|
||||
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
|
||||
except ValueError:
|
||||
timeout = 3.0
|
||||
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
|
||||
timeout = max(0.05, min(timeout, 15.0))
|
||||
try:
|
||||
repair_timeout = float(os.environ.get("PANAMA_DOCTOR_REPAIR_TIMEOUT", "15"))
|
||||
except ValueError:
|
||||
repair_timeout = 15.0
|
||||
# Never shorter than the probe timeout, and bounded so a hung repair still
|
||||
# gives up rather than blocking the caller indefinitely.
|
||||
repair_timeout = max(timeout, min(repair_timeout, 30.0))
|
||||
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), timeout, scripts_dir, repair_timeout)
|
||||
|
||||
|
||||
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
|
||||
@@ -221,7 +251,7 @@ def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=config.timeout,
|
||||
timeout=config.repair_timeout,
|
||||
check=False,
|
||||
env=config.command_env,
|
||||
cwd=cwd,
|
||||
@@ -273,6 +303,16 @@ def service_check(check_id: str, title: str, service: str, config: DoctorConfig,
|
||||
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
|
||||
|
||||
|
||||
def process_check(check_id: str, title: str, process: str, config: DoctorConfig, action: Action | None = None) -> Check:
|
||||
"""Like service_check, for autostarted apps with no systemd unit behind them."""
|
||||
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", process), config)
|
||||
if result.state == "ok":
|
||||
return Check(check_id, group_for(check_id), title, "ok", "Process is running.")
|
||||
if result.state in {"missing", "unavailable"}:
|
||||
return Check(check_id, group_for(check_id), title, "error", "Required process probe is unavailable.")
|
||||
return Check(check_id, group_for(check_id), title, "warning", "Process is not running.", action)
|
||||
|
||||
|
||||
def ipc_target(config: DoctorConfig, target: str) -> CommandResult:
|
||||
result = run_command(("qs", "ipc", "show"), config)
|
||||
if result.state != "ok":
|
||||
@@ -320,8 +360,43 @@ def check_portals(config: DoctorConfig) -> Check:
|
||||
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
|
||||
|
||||
|
||||
def check_hyprlock(config: DoctorConfig) -> Check:
|
||||
helper = config.root / "config/dot/quickshell/scripts/panama-lock"
|
||||
tracked_fallback = config.config_home / "hypr/hyprlock.conf"
|
||||
generated_config = config.state_home / "panama/hyprlock.conf"
|
||||
# The doctor seals PATH for every probe. Invoke the authored helper with a
|
||||
# fixed system-only PATH so its bash shebang and jq dependency remain
|
||||
# available without inheriting arbitrary parent executables.
|
||||
result = run_command(("/usr/bin/env", "PATH=/usr/bin:/bin", str(helper), "status"), config)
|
||||
|
||||
if result.state != "ok":
|
||||
if tracked_fallback.is_file():
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is unavailable; the tracked fallback remains available.")
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||
|
||||
try:
|
||||
state = json.loads(result.stdout)
|
||||
generated = state["generated"]
|
||||
path = state["path"]
|
||||
fallback = state["fallback"]
|
||||
error = state["error"]
|
||||
if not isinstance(generated, bool) or not isinstance(path, str) \
|
||||
or not isinstance(fallback, bool) or not isinstance(error, str):
|
||||
raise ValueError
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
if tracked_fallback.is_file():
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is invalid; the tracked fallback remains available.")
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||
|
||||
if generated and not fallback and generated_config.is_file():
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "ok", "Managed lock-screen configuration is available.")
|
||||
if tracked_fallback.is_file():
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "The tracked lock-screen fallback is in use.")
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||
|
||||
|
||||
def check_brightness(config: DoctorConfig) -> Check:
|
||||
result = run_command(("panama-brightness", "list"), config)
|
||||
result = run_command((str(config.scripts_dir / "panama-brightness"), "list"), config)
|
||||
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
||||
if result.state == "timeout":
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
|
||||
@@ -342,9 +417,18 @@ def check_brightness(config: DoctorConfig) -> Check:
|
||||
|
||||
|
||||
def check_nextcloud(config: DoctorConfig) -> Check:
|
||||
if not (config.config_home / "autostart" / "nextcloud.desktop").is_file():
|
||||
# The client's autostart entry is Title-cased ("Nextcloud.desktop") on
|
||||
# current nextcloud-client packages, but that has changed case before --
|
||||
# match either so a future package update doesn't silently reintroduce
|
||||
# this as "autostart is not configured" again.
|
||||
autostart_dir = config.config_home / "autostart"
|
||||
if not any(autostart_dir.glob("[Nn]extcloud.desktop")):
|
||||
return Check("integration.nextcloud", "integrations", "Nextcloud", "unconfigured", "Nextcloud autostart is not configured.")
|
||||
return service_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud"))
|
||||
# No systemd unit backs this (see the autostart.lua comment on why Panama
|
||||
# doesn't start it as one): the client is a plain autostarted process, so
|
||||
# check for the process directly instead of a systemctl service that was
|
||||
# never going to exist.
|
||||
return process_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud"))
|
||||
|
||||
|
||||
def check_rustdesk(config: DoctorConfig) -> Check:
|
||||
@@ -382,7 +466,7 @@ def check_home_assistant(config: DoctorConfig) -> Check:
|
||||
|
||||
|
||||
def check_calendar(config: DoctorConfig) -> Check:
|
||||
result = run_command(("calendar-agenda", "probe"), config)
|
||||
result = run_command((str(config.scripts_dir / "calendar-agenda"), "probe"), config)
|
||||
action = Action("open", "Open Date & Time", target="datetime")
|
||||
if result.state == "missing":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
|
||||
@@ -514,7 +598,7 @@ def unavailable_versions() -> list[dict[str, str]]:
|
||||
def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||
probes: dict[str, Callable[[], Check]] = {
|
||||
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
||||
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
||||
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
||||
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
||||
@@ -547,6 +631,11 @@ def snapshot(config: DoctorConfig) -> dict[str, object]:
|
||||
|
||||
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
|
||||
command = REPAIR_COMMANDS[check_id]
|
||||
if check_id == "desktop.quickshell":
|
||||
# panama-action is a sibling helper script, not a PATH-resolved
|
||||
# executable; see check_brightness and check_calendar for the same
|
||||
# resolution against the same bug.
|
||||
command = (str(config.scripts_dir / command[0]), *command[1:])
|
||||
exit_code, _ = run_repair_command(command, config)
|
||||
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
|
||||
else "The authored repair command could not be completed."
|
||||
|
||||
@@ -25,6 +25,14 @@ generated="$state_dir/hypridle.conf"
|
||||
dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d"
|
||||
dropin="$dropin_dir/panama.conf"
|
||||
|
||||
# Set by generate() to the mktemp path it is currently writing, so concurrent
|
||||
# invocations (e.g. rapid settings changes each spawning `apply`) never share
|
||||
# a tmp file and interleave writes into a corrupt hypridle.conf. Cleared once
|
||||
# the atomic mv below lands, so this is a no-op on a normal exit.
|
||||
generated_tmp=""
|
||||
cleanup() { rm -f "$generated_tmp" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
read_setting() {
|
||||
local key="$1" fallback="$2"
|
||||
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
|
||||
@@ -53,13 +61,17 @@ generate() {
|
||||
load
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
# Unique per invocation, in the same directory as the destination so the
|
||||
# final mv is an atomic same-filesystem rename rather than a copy.
|
||||
generated_tmp="$(mktemp "$generated.XXXXXX")"
|
||||
|
||||
{
|
||||
printf '# Generated by panama-idle from %s\n' "$settings"
|
||||
printf '# Do not edit: it is rewritten whenever the idle settings change.\n'
|
||||
printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n'
|
||||
|
||||
printf 'general {\n'
|
||||
printf ' lock_cmd = pidof hyprlock || hyprlock\n'
|
||||
printf ' lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run\n'
|
||||
if [[ "$lock_on_sleep" == "true" ]]; then
|
||||
printf ' before_sleep_cmd = loginctl lock-session\n'
|
||||
fi
|
||||
@@ -91,9 +103,10 @@ generate() {
|
||||
printf ' on-timeout = systemctl suspend\n'
|
||||
printf '}\n'
|
||||
fi
|
||||
} >"$generated.tmp"
|
||||
} >"$generated_tmp"
|
||||
|
||||
mv "$generated.tmp" "$generated"
|
||||
mv "$generated_tmp" "$generated"
|
||||
generated_tmp=""
|
||||
}
|
||||
|
||||
install_dropin() {
|
||||
|
||||
@@ -39,13 +39,36 @@ import re
|
||||
import sys
|
||||
|
||||
|
||||
def daemon_origin():
|
||||
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||
def secrets_name_owner_pid():
|
||||
"""PID currently owning the org.freedesktop.secrets D-Bus name, if any.
|
||||
|
||||
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||
outside the app slice, so the cgroup tells the two apart.
|
||||
This is the only reliable way to identify which daemon actually answers
|
||||
Secret Service calls right now.
|
||||
"""
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
result = bus.call_sync(
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus",
|
||||
"GetConnectionUnixProcessID",
|
||||
GLib.Variant("(s)", ("org.freedesktop.secrets",)),
|
||||
GLib.VariantType("(u)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
return result.unpack()[0]
|
||||
except Exception: # noqa: BLE001 - no name owner is a legitimate state
|
||||
return None
|
||||
|
||||
|
||||
def any_keyring_daemon_running():
|
||||
try:
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
@@ -55,19 +78,46 @@ def daemon_origin():
|
||||
cmdline = handle.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
continue
|
||||
if "gnome-keyring-daemon" not in cmdline:
|
||||
continue
|
||||
if "gnome-keyring-daemon" in cmdline:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def daemon_origin():
|
||||
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||
|
||||
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||
outside the app slice, so the cgroup tells the two apart.
|
||||
|
||||
A machine can have two gnome-keyring-daemon processes at once -- a
|
||||
lingering PAM one alongside its D-Bus-activated replacement -- so which
|
||||
process this reports on matters: it must be the one that actually owns
|
||||
org.freedesktop.secrets right now, not merely the first one /proc happens
|
||||
to enumerate.
|
||||
"""
|
||||
owner_pid = secrets_name_owner_pid()
|
||||
if owner_pid is None:
|
||||
return "unknown" if any_keyring_daemon_running() else "none"
|
||||
|
||||
try:
|
||||
with open(f"/proc/{pid}/cgroup", "r") as handle:
|
||||
with open(f"/proc/{owner_pid}/cmdline", "rb") as handle:
|
||||
cmdline = handle.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if "gnome-keyring-daemon" not in cmdline:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
with open(f"/proc/{owner_pid}/cgroup", "r") as handle:
|
||||
cgroup = handle.read()
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
||||
return "dbus"
|
||||
return "pam"
|
||||
except OSError:
|
||||
pass
|
||||
return "none"
|
||||
|
||||
|
||||
def load_service():
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user