Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9fc1fdbbbb | ||
|
|
dd8d93c387 | ||
|
|
b8832a0174 | ||
|
|
3f07d25858 | ||
|
|
df1dcdfad5 | ||
|
|
91f7273f41 | ||
|
|
f9eba1e8c5 | ||
|
|
b1edfb6fe4 | ||
|
|
1afa41526a | ||
|
|
ccf46c40ef |
@@ -21,3 +21,4 @@ __pycache__/
|
|||||||
/config/dot/gtk-3.0/settings.ini
|
/config/dot/gtk-3.0/settings.ini
|
||||||
/config/dot/gtk-4.0/settings.ini
|
/config/dot/gtk-4.0/settings.ini
|
||||||
/config/dot/tmux/current-theme.conf
|
/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)
|
# 2.4) Run flatpak updates (user then system)
|
||||||
flatpak update -y
|
flatpak update -y
|
||||||
sudo flatpak update
|
sudo flatpak update -y
|
||||||
|
|
||||||
# 2.5) Optional firmware via fwupd
|
# 2.5) Optional firmware via fwupd
|
||||||
if $firmware; then
|
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
|
sudo fwupdmgr update
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ than presenting inert Hyprland controls.
|
|||||||
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
||||||
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
||||||
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
|
| Wi-Fi QR | 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 |
|
| 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 favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||||
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
|
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ The mental model is unchanged from Forge:
|
|||||||
| `SUPER + SHIFT + H/J/K/L` | Move window |
|
| `SUPER + SHIFT + H/J/K/L` | Move window |
|
||||||
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
||||||
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
|
| `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 + [` / `]` / `=` | Shrink / expand / reset split |
|
||||||
| `SUPER + Q` | Close |
|
| `SUPER + Q` | Close |
|
||||||
| `SUPER + U` | Fullscreen |
|
| `SUPER + U` | Fullscreen |
|
||||||
|
|||||||
@@ -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("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
|
||||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||||
|
|
||||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
|
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
|
||||||
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
-- 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.
|
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
-- instead. They still work here; see the session notes in autostart.lua.
|
-- instead. They still work here; see the session notes in autostart.lua.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
-- ── GPU selection ───────────────────────────────────────────────────────────
|
-- ── GPU selection ───────────────────────────────────────────────────────────
|
||||||
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
|
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
|
||||||
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
|
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
|
||||||
@@ -47,7 +49,7 @@ end
|
|||||||
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
|
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
|
||||||
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
|
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
|
||||||
-- XCursor path, which is what we want.
|
-- XCursor path, which is what we want.
|
||||||
hl.env("XCURSOR_THEME", "oreo_blue_cursors")
|
hl.env("XCURSOR_THEME", prefs.get("cursorTheme", "oreo_blue_cursors"))
|
||||||
hl.env("XCURSOR_SIZE", "24")
|
hl.env("XCURSOR_SIZE", "24")
|
||||||
|
|
||||||
-- ── Toolkits ────────────────────────────────────────────────────────────────
|
-- ── Toolkits ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
general {
|
general {
|
||||||
# `pidof` guard prevents stacking lockers if this fires twice.
|
# `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
|
before_sleep_cmd = loginctl lock-session
|
||||||
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
|
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# hyprlock — lock screen
|
# hyprlock — lock screen.
|
||||||
#
|
#
|
||||||
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
|
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||||
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
|
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||||
|
# next colour scheme change.
|
||||||
|
#
|
||||||
|
# The colours 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.
|
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -45,7 +54,7 @@ background {
|
|||||||
vibrancy_darkness = 0.05
|
vibrancy_darkness = 0.05
|
||||||
|
|
||||||
# Shown if the screenshot is unavailable.
|
# Shown if the screenshot is unavailable.
|
||||||
color = rgba(34, 36, 54, 1.0)
|
color = rgba(@BG@, 1.0)
|
||||||
|
|
||||||
zindex = -1
|
zindex = -1
|
||||||
}
|
}
|
||||||
@@ -54,7 +63,7 @@ background {
|
|||||||
label {
|
label {
|
||||||
monitor =
|
monitor =
|
||||||
text = cmd[update:1000] date +"%-I:%M"
|
text = cmd[update:1000] date +"%-I:%M"
|
||||||
color = rgba(200, 211, 245, 1.0)
|
color = rgba(@FG@, 1.0)
|
||||||
font_size = 120
|
font_size = 120
|
||||||
font_family = Adwaita Sans Light
|
font_family = Adwaita Sans Light
|
||||||
position = 0, 260
|
position = 0, 260
|
||||||
@@ -65,7 +74,7 @@ label {
|
|||||||
label {
|
label {
|
||||||
monitor =
|
monitor =
|
||||||
text = cmd[update:60000] date +"%A, %B %-d"
|
text = cmd[update:60000] date +"%A, %B %-d"
|
||||||
color = rgba(130, 139, 184, 1.0)
|
color = rgba(@MUTED@, 1.0)
|
||||||
font_size = 24
|
font_size = 24
|
||||||
font_family = Adwaita Sans
|
font_family = Adwaita Sans
|
||||||
position = 0, 160
|
position = 0, 160
|
||||||
@@ -84,18 +93,18 @@ input-field {
|
|||||||
outline_thickness = 2
|
outline_thickness = 2
|
||||||
rounding = 26
|
rounding = 26
|
||||||
|
|
||||||
outer_color = rgba(130, 170, 255, 0.9)
|
outer_color = rgba(@ACCENT@, 0.9)
|
||||||
inner_color = rgba(46, 47, 61, 0.85)
|
inner_color = rgba(@FIELD@, 0.85)
|
||||||
font_color = rgba(200, 211, 245, 1.0)
|
font_color = rgba(@FG@, 1.0)
|
||||||
check_color = rgba(130, 170, 255, 1.0)
|
check_color = rgba(@ACCENT@, 1.0)
|
||||||
fail_color = rgba(255, 117, 127, 1.0)
|
fail_color = rgba(@ERROR@, 1.0)
|
||||||
|
|
||||||
dots_size = 0.25
|
dots_size = 0.25
|
||||||
dots_spacing = 0.3
|
dots_spacing = 0.3
|
||||||
dots_center = true
|
dots_center = true
|
||||||
|
|
||||||
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
|
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
|
||||||
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
|
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||||
|
|
||||||
fade_on_empty = false
|
fade_on_empty = false
|
||||||
hide_input = false
|
hide_input = false
|
||||||
@@ -105,7 +114,7 @@ input-field {
|
|||||||
label {
|
label {
|
||||||
monitor =
|
monitor =
|
||||||
text = $USER
|
text = $USER
|
||||||
color = rgba(200, 211, 245, 0.9)
|
color = rgba(@FG@, 0.9)
|
||||||
font_size = 16
|
font_size = 16
|
||||||
font_family = Adwaita Sans
|
font_family = Adwaita Sans
|
||||||
position = 0, -110
|
position = 0, -110
|
||||||
@@ -10,9 +10,9 @@ local prefs = require("prefs")
|
|||||||
hl.config({
|
hl.config({
|
||||||
input = {
|
input = {
|
||||||
kb_layout = prefs.get("keyboardLayout", "us"),
|
kb_layout = prefs.get("keyboardLayout", "us"),
|
||||||
kb_variant = "",
|
kb_variant = prefs.get("keyboardVariant", ""),
|
||||||
kb_model = "",
|
kb_model = "",
|
||||||
kb_options = "",
|
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
|
||||||
kb_rules = "",
|
kb_rules = "",
|
||||||
|
|
||||||
numlock_by_default = prefs.get("numlockByDefault", true),
|
numlock_by_default = prefs.get("numlockByDefault", true),
|
||||||
|
|||||||
@@ -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 + 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 + 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 + 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 colour 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" })
|
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||||
|
|
||||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
|
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||||
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
-- showing what you are choosing between.
|
||||||
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
--
|
||||||
|
-- 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.
|
-- Jump back to the previously focused window.
|
||||||
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last 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:
|
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
||||||
-- moving right past the last workspace creates a new one, and moving left from
|
-- moving right past the last workspace creates a new one, and moving left from
|
||||||
-- the first clamps instead of wrapping.
|
-- the first clamps instead of wrapping.
|
||||||
|
|
||||||
|
-- Behaviour 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 + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||||
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||||
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
||||||
|
|||||||
+80
-23
@@ -2,7 +2,7 @@
|
|||||||
-- Look and feel -- Tokyo Night Moon
|
-- Look and feel -- Tokyo Night Moon
|
||||||
--
|
--
|
||||||
-- Colours here must stay in sync with quickshell/config/Theme.qml.
|
-- Colours here must stay in sync with quickshell/config/Theme.qml.
|
||||||
-- accent #82aaff borders / focus
|
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
|
||||||
-- bg #222436 base
|
-- bg #222436 base
|
||||||
--
|
--
|
||||||
-- Performance note: every animation below is event-driven. Nothing uses the
|
-- Performance note: every animation below is event-driven. Nothing uses the
|
||||||
@@ -14,6 +14,30 @@
|
|||||||
|
|
||||||
local prefs = require("prefs")
|
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({
|
hl.config({
|
||||||
general = {
|
general = {
|
||||||
gaps_in = prefs.get("gapsIn", 5),
|
gaps_in = prefs.get("gapsIn", 5),
|
||||||
@@ -22,21 +46,24 @@ hl.config({
|
|||||||
border_size = prefs.get("borderSize", 2),
|
border_size = prefs.get("borderSize", 2),
|
||||||
|
|
||||||
col = {
|
col = {
|
||||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
-- The focused accent role, on a diagonal so the pair is visible
|
||||||
-- is visible on both a tall and a wide window. Same two colours as
|
-- on both a tall and a wide window. Driven by the chosen
|
||||||
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
|
-- accentName (see the `accents` table above); services/
|
||||||
-- tmux theme this palette came from.
|
-- ColorScheme.qml applies the same values live, and restates them
|
||||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
-- from Theme.accent/Theme.accentSecondary on every scheme change
|
||||||
-- Unfocused windows get no colour at all. The gradient only means
|
-- too, since each accent carries a separate pair per scheme.
|
||||||
-- something if exactly one window on screen is wearing it.
|
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
|
||||||
-- Follows the colour scheme: a dark neutral is invisible against a
|
-- The neutral inactive role follows the colour scheme because a
|
||||||
-- light desktop. services/ColorScheme.qml applies changes live;
|
-- dark neutral disappears against a light desktop.
|
||||||
-- this is the value a fresh session starts from.
|
-- services/ColorScheme.qml applies the same values live; this is
|
||||||
|
-- the value a fresh session starts from.
|
||||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||||
},
|
},
|
||||||
|
|
||||||
resize_on_border = true,
|
resize_on_border = prefs.get("resizeOnBorder", true),
|
||||||
|
extend_border_grab_area = prefs.getInt("borderGrabArea", 15),
|
||||||
|
hover_icon_on_border = prefs.get("hoverIconOnBorder", true),
|
||||||
|
|
||||||
-- Enables the per-window "immediate" rule used for games in rules.lua.
|
-- Enables the per-window "immediate" rule used for games in rules.lua.
|
||||||
-- Harmless on its own; tearing only happens where a rule opts in.
|
-- Harmless on its own; tearing only happens where a rule opts in.
|
||||||
@@ -44,16 +71,22 @@ hl.config({
|
|||||||
|
|
||||||
layout = "dwindle",
|
layout = "dwindle",
|
||||||
|
|
||||||
snap = { enabled = true },
|
snap = {
|
||||||
|
enabled = true,
|
||||||
|
window_gap = prefs.getInt("snapWindowGap", 10),
|
||||||
|
monitor_gap = prefs.getInt("snapMonitorGap", 10),
|
||||||
|
respect_gaps = prefs.get("snapRespectGaps", false),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
decoration = {
|
decoration = {
|
||||||
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
||||||
-- next to each other read as the same object family.
|
-- next to each other read as the same object family.
|
||||||
rounding = prefs.get("windowRounding", 18),
|
rounding = prefs.get("windowRounding", 18),
|
||||||
rounding_power = 2,
|
rounding_power = prefs.get("roundingPower", 2),
|
||||||
|
|
||||||
active_opacity = 1.0,
|
active_opacity = prefs.get("activeOpacity", 1.0),
|
||||||
|
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
|
||||||
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
||||||
|
|
||||||
blur = {
|
blur = {
|
||||||
@@ -83,9 +116,13 @@ hl.config({
|
|||||||
shadow = {
|
shadow = {
|
||||||
enabled = prefs.get("shadowEnabled", true),
|
enabled = prefs.get("shadowEnabled", true),
|
||||||
range = prefs.get("shadowRange", 20),
|
range = prefs.get("shadowRange", 20),
|
||||||
render_power = 3,
|
render_power = prefs.getInt("shadowRenderPower", 3),
|
||||||
sharp = false,
|
sharp = prefs.get("shadowSharp", false),
|
||||||
color = "rgba(15161eee)",
|
color = "rgba(15161eee)",
|
||||||
|
-- Deliberately not a setting: a two-axis offset needs a control we
|
||||||
|
-- do not have, and a slider bound to half a value is worse than
|
||||||
|
-- leaving it alone. SystemSettings understands the vec2 shape
|
||||||
|
-- already, so adding it later is only a matter of the widget.
|
||||||
offset = { 0, 4 },
|
offset = { 0, 4 },
|
||||||
scale = 1.0,
|
scale = 1.0,
|
||||||
},
|
},
|
||||||
@@ -93,11 +130,13 @@ hl.config({
|
|||||||
-- New in 0.56. Kept deliberately faint: in this direction the gradient
|
-- New in 0.56. Kept deliberately faint: in this direction the gradient
|
||||||
-- border is the signature, and a strong halo would compete with it.
|
-- border is the signature, and a strong halo would compete with it.
|
||||||
-- This is just enough to lift the focused window off the wallpaper.
|
-- This is just enough to lift the focused window off the wallpaper.
|
||||||
|
-- Derived from the same accent as active_border above, not hardcoded,
|
||||||
|
-- so the halo never disagrees with the border it surrounds.
|
||||||
glow = {
|
glow = {
|
||||||
enabled = prefs.get("glowEnabled", true),
|
enabled = prefs.get("glowEnabled", true),
|
||||||
range = prefs.get("glowRange", 8),
|
range = prefs.get("glowRange", 8),
|
||||||
render_power = 2,
|
render_power = 2,
|
||||||
color = "rgba(82aaff33)",
|
color = "rgba(" .. accentStart .. "33)",
|
||||||
color_inactive = "rgba(00000000)",
|
color_inactive = "rgba(00000000)",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -110,14 +149,32 @@ hl.config({
|
|||||||
dwindle = {
|
dwindle = {
|
||||||
-- Keep the split orientation a window was created with. Closest match
|
-- Keep the split orientation a window was created with. Closest match
|
||||||
-- to how the Forge extension behaved on GNOME.
|
-- to how the Forge extension behaved on GNOME.
|
||||||
preserve_split = true,
|
preserve_split = prefs.get("preserveSplit", true),
|
||||||
smart_resizing = true,
|
smart_resizing = true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
|
||||||
|
-- but Settings offers master as a choice, and a layout you can select and
|
||||||
|
-- cannot configure is barely a choice at all.
|
||||||
|
master = {
|
||||||
|
mfact = prefs.get("masterFactor", 0.55),
|
||||||
|
orientation = prefs.get("masterOrientation", "left"),
|
||||||
|
new_status = prefs.get("masterNewStatus", "slave"),
|
||||||
|
new_on_top = prefs.get("masterNewOnTop", false),
|
||||||
|
},
|
||||||
|
|
||||||
misc = {
|
misc = {
|
||||||
force_default_wallpaper = 0,
|
force_default_wallpaper = 0,
|
||||||
disable_hyprland_logo = true,
|
-- Stored as "show the logo / show the splash" and written as Hyprland's
|
||||||
disable_splash_rendering = true,
|
-- `disable_*`, matching the `invert` flag on these entries in
|
||||||
|
-- PreferenceSchema so both sides agree about which way round they are.
|
||||||
|
disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
|
||||||
|
disable_splash_rendering = not prefs.get("hyprlandSplash", false),
|
||||||
|
|
||||||
|
-- Keep native Wayland selection paste and GTK's matching preference
|
||||||
|
-- in lockstep. DesktopStyle applies the GTK half only after this value
|
||||||
|
-- has been read back and stored by SystemSettings.
|
||||||
|
middle_click_paste = prefs.get("middleClickPaste", true),
|
||||||
|
|
||||||
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
||||||
-- followed the preference, the compositor and the shell would disagree
|
-- followed the preference, the compositor and the shell would disagree
|
||||||
@@ -172,8 +229,8 @@ hl.config({
|
|||||||
},
|
},
|
||||||
|
|
||||||
ecosystem = {
|
ecosystem = {
|
||||||
no_update_news = true,
|
no_update_news = not prefs.get("hyprlandUpdateNews", false),
|
||||||
no_donation_nag = true,
|
no_donation_nag = not prefs.get("hyprlandDonationNag", false),
|
||||||
},
|
},
|
||||||
|
|
||||||
xwayland = {
|
xwayland = {
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
local prefs = require("prefs")
|
local prefs = require("prefs")
|
||||||
|
|
||||||
-- Per-output overrides written by Panama Settings, keyed by output name:
|
-- 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. Colour management and bit depth
|
||||||
-- stay here, because those are the settings with a documented reason attached
|
-- stay here, because those are the settings with a documented reason attached
|
||||||
@@ -71,6 +74,26 @@ local function valid_transform(transform)
|
|||||||
and transform <= 3
|
and transform <= 3
|
||||||
end
|
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)
|
local function display_entry(output)
|
||||||
if type(output) ~= "string" or output == ""
|
if type(output) ~= "string" or output == ""
|
||||||
or output:match("^[%w_.-]+$") == nil then
|
or output:match("^[%w_.-]+$") == nil then
|
||||||
@@ -85,9 +108,23 @@ local function display_entry(output)
|
|||||||
or not valid_transform(entry.transform) then
|
or not valid_transform(entry.transform) then
|
||||||
return nil
|
return nil
|
||||||
end
|
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
|
return entry
|
||||||
end
|
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_mode = "4500x3000@60"
|
||||||
local shipped_scale = 1.5
|
local shipped_scale = 1.5
|
||||||
local shipped_transform = 0
|
local shipped_transform = 0
|
||||||
@@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2")
|
|||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "DP-2",
|
output = "DP-2",
|
||||||
mode = dp2 and dp2.mode or shipped_mode,
|
mode = dp2 and dp2.mode or shipped_mode,
|
||||||
position = "0x0",
|
position = display_position(dp2, "0x0"),
|
||||||
scale = dp2 and dp2.scale or shipped_scale,
|
scale = dp2 and dp2.scale or shipped_scale,
|
||||||
transform = dp2 and dp2.transform or shipped_transform,
|
transform = dp2 and dp2.transform or shipped_transform,
|
||||||
|
|
||||||
@@ -119,7 +156,7 @@ for output, _ in pairs(displays) do
|
|||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = output,
|
output = output,
|
||||||
mode = entry.mode,
|
mode = entry.mode,
|
||||||
position = "auto",
|
position = display_position(entry, "auto"),
|
||||||
scale = entry.scale,
|
scale = entry.scale,
|
||||||
transform = entry.transform,
|
transform = entry.transform,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,6 +206,147 @@ Singleton {
|
|||||||
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||||
|
group: "windows",
|
||||||
|
label: "Focused window opacity",
|
||||||
|
detail: "Fade even the focused window; 1.0 is fully opaque",
|
||||||
|
hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||||
|
group: "windows",
|
||||||
|
label: "Fullscreen opacity",
|
||||||
|
detail: "Applied instead of the focused opacity when a window is fullscreen",
|
||||||
|
hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
|
||||||
|
group: "windows",
|
||||||
|
label: "Corner shape",
|
||||||
|
detail: "2 is a circular corner; higher values approach a squircle",
|
||||||
|
hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Window edges ────────────────────────────────────────────────────
|
||||||
|
// How the pointer interacts with a window's border, and how windows
|
||||||
|
// behave near each other. All shipped by looks.lua with no way to
|
||||||
|
// change any of it.
|
||||||
|
{
|
||||||
|
key: "resizeOnBorder", type: "bool", def: true, group: "edges",
|
||||||
|
label: "Resize by dragging the border",
|
||||||
|
detail: "Drag a window's edge to resize it, instead of only with the keyboard",
|
||||||
|
hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Border grab area",
|
||||||
|
detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
|
||||||
|
hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
|
||||||
|
label: "Show the resize cursor",
|
||||||
|
detail: "Change the pointer when it is over a resizable border",
|
||||||
|
hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Snap distance between windows",
|
||||||
|
detail: "How close two floating windows must be before they snap together",
|
||||||
|
hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Snap distance to screen edges",
|
||||||
|
detail: "How close a floating window must be to an edge before it snaps to it",
|
||||||
|
hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapRespectGaps", type: "bool", def: false, group: "edges",
|
||||||
|
label: "Snapping respects gaps",
|
||||||
|
detail: "Snapped windows keep the configured gap instead of touching",
|
||||||
|
hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
{
|
||||||
|
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
|
||||||
|
group: "master",
|
||||||
|
label: "Master area size",
|
||||||
|
detail: "How much of the screen the master window takes",
|
||||||
|
hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterOrientation", type: "enum", def: "left", group: "master",
|
||||||
|
label: "Master area position",
|
||||||
|
detail: "Which side of the screen the master window occupies",
|
||||||
|
options: [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "right", label: "Right" },
|
||||||
|
{ value: "top", label: "Top" },
|
||||||
|
{ value: "bottom", label: "Bottom" },
|
||||||
|
{ value: "center", label: "Centre" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterNewStatus", type: "enum", def: "slave", group: "master",
|
||||||
|
label: "New windows become",
|
||||||
|
detail: "Whether a new window takes the master area or joins the stack",
|
||||||
|
options: [
|
||||||
|
{ value: "master", label: "The master window" },
|
||||||
|
{ value: "slave", label: "Part of the stack" },
|
||||||
|
{ value: "inherit", label: "Whatever the focused window is" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterNewOnTop", type: "bool", def: false, group: "master",
|
||||||
|
label: "Add new windows at the top",
|
||||||
|
detail: "New stack windows go above the others rather than below",
|
||||||
|
hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hyprland's own notices ──────────────────────────────────────────
|
||||||
|
// Panama turns all four off on the user's behalf. That is a defensible
|
||||||
|
// default and was not a decision anyone could reverse without editing
|
||||||
|
// looks.lua, which is precisely the kind of thing this app exists to
|
||||||
|
// stop.
|
||||||
|
{
|
||||||
|
key: "hyprlandLogo", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Hyprland wallpaper",
|
||||||
|
detail: "The stock background Hyprland draws when no wallpaper is set",
|
||||||
|
hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandSplash", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Splash text",
|
||||||
|
detail: "The line of text Hyprland renders over the stock background",
|
||||||
|
hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Update announcements",
|
||||||
|
detail: "The window Hyprland opens after an update to describe what changed",
|
||||||
|
hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Donation reminders",
|
||||||
|
detail: "The prompt Hyprland shows twice a year asking for support",
|
||||||
|
hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Effects ─────────────────────────────────────────────────────────
|
// ── Effects ─────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
||||||
@@ -241,6 +382,19 @@ Singleton {
|
|||||||
detail: "How far the shadow spreads from the window edge",
|
detail: "How far the shadow spreads from the window edge",
|
||||||
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "shadowSharp", type: "bool", def: false, group: "effects",
|
||||||
|
label: "Hard-edged shadow",
|
||||||
|
detail: "A crisp shadow instead of a soft falloff",
|
||||||
|
hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
|
||||||
|
group: "effects",
|
||||||
|
label: "Shadow falloff",
|
||||||
|
detail: "How sharply the shadow fades out. Higher is tighter to the window",
|
||||||
|
hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
||||||
label: "Focus glow",
|
label: "Focus glow",
|
||||||
@@ -282,7 +436,7 @@ Singleton {
|
|||||||
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "keyboardOptions", type: "string", def: "", group: "input",
|
key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
|
||||||
// XKB option names are colon-separated pairs in a comma-separated
|
// XKB option names are colon-separated pairs in a comma-separated
|
||||||
// list, e.g. "compose:ralt,caps:escape".
|
// list, e.g. "compose:ralt,caps:escape".
|
||||||
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
||||||
@@ -313,7 +467,7 @@ Singleton {
|
|||||||
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
|
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",
|
label: "Pointer focus",
|
||||||
detail: "What moving the pointer does to which window is focused",
|
detail: "What moving the pointer does to which window is focused",
|
||||||
// These labels were wrong, and wrong in the worst way: value 1 was
|
// These labels were wrong, and wrong in the worst way: value 1 was
|
||||||
@@ -341,7 +495,7 @@ Singleton {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
|
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
|
||||||
group: "input",
|
group: "pointer",
|
||||||
label: "Pointer speed",
|
label: "Pointer speed",
|
||||||
detail: "Zero is flat, unaccelerated response",
|
detail: "Zero is flat, unaccelerated response",
|
||||||
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
|
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
|
||||||
@@ -349,7 +503,7 @@ Singleton {
|
|||||||
{
|
{
|
||||||
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||||
unit: "s",
|
unit: "s",
|
||||||
group: "input",
|
group: "pointer",
|
||||||
label: "Hide pointer after",
|
label: "Hide pointer after",
|
||||||
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
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
|
// Reported as a float even though it is only ever set to whole
|
||||||
@@ -394,6 +548,12 @@ Singleton {
|
|||||||
detail: "Swap the primary and secondary buttons",
|
detail: "Swap the primary and secondary buttons",
|
||||||
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
|
||||||
|
label: "Middle-click paste",
|
||||||
|
detail: "Paste the primary selection in GTK and native Wayland applications",
|
||||||
|
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Touchpad ────────────────────────────────────────────────────────
|
// ── Touchpad ────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -587,6 +747,66 @@ Singleton {
|
|||||||
label: "Wallpaper",
|
label: "Wallpaper",
|
||||||
detail: "Shown on every output"
|
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 ───────────────────────────────────────────
|
// ── Idle, lock, and sleep ───────────────────────────────────────────
|
||||||
// Written into a generated hypridle config; see scripts/panama-idle.
|
// Written into a generated hypridle config; see scripts/panama-idle.
|
||||||
@@ -651,6 +871,44 @@ Singleton {
|
|||||||
{ value: "light", label: "Light" }
|
{ value: "light", label: "Light" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "accentName", type: "enum", def: "blue", group: "appearance",
|
||||||
|
label: "Accent colour",
|
||||||
|
detail: "Drives the focused window border, the bar hairline, and every active state",
|
||||||
|
// NAMED accents, not a free colour. Each name carries a curated
|
||||||
|
// pair per scheme, because one hex cannot serve both: a colour
|
||||||
|
// 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
|
||||||
|
// choices GNOME applications expose independently of that palette:
|
||||||
|
// their icons and pointer. DesktopStyle only accepts names found in
|
||||||
|
// the read-only XDG catalog before storing them.
|
||||||
|
{
|
||||||
|
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Pointer theme",
|
||||||
|
detail: "The pointer design used by applications and Hyprland"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Application icons",
|
||||||
|
detail: "The icon set used by GTK applications"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Typography ──────────────────────────────────────────────────────
|
// ── Typography ──────────────────────────────────────────────────────
|
||||||
// The single largest thing in this desktop that used to be changeable
|
// The single largest thing in this desktop that used to be changeable
|
||||||
@@ -681,6 +939,91 @@ Singleton {
|
|||||||
label: "Interface text size",
|
label: "Interface text size",
|
||||||
detail: "The base size the rest of the shell's type scales from"
|
detail: "The base size the rest of the shell's type scales from"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Application font",
|
||||||
|
detail: "Used by menus, controls, and labels in applications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Application text size",
|
||||||
|
detail: "The base text size used by applications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Document font",
|
||||||
|
detail: "Used for document content when an application follows the system choice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Document text size",
|
||||||
|
detail: "The default text size for document content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Monospace font",
|
||||||
|
detail: "Used by terminals, editors, and code fields that follow the system choice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Monospace text size",
|
||||||
|
detail: "The default text size for terminals and code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fontHinting", type: "enum", def: "slight", group: "typography",
|
||||||
|
label: "Font hinting",
|
||||||
|
detail: "How strongly text aligns to the pixel grid",
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "slight", label: "Slight" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "full", label: "Full" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
|
||||||
|
label: "Text smoothing",
|
||||||
|
detail: "How application text softens its edges",
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "grayscale", label: "Grayscale" },
|
||||||
|
{ value: "rgba", label: "Subpixel" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Application titlebars ──────────────────────────────────────────
|
||||||
|
// These affect applications that honour 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.
|
||||||
|
{
|
||||||
|
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
|
||||||
|
label: "Button side",
|
||||||
|
detail: "Place application titlebar buttons on the left or right",
|
||||||
|
options: [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "right", label: "Right" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
|
||||||
|
label: "Maximize button",
|
||||||
|
detail: "Show a maximize button in application titlebars that support it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
|
||||||
|
label: "Double-click titlebar",
|
||||||
|
detail: "Choose what a double-click on an application titlebar does",
|
||||||
|
options: [
|
||||||
|
{ value: "toggle-maximize", label: "Toggle maximize" },
|
||||||
|
{ value: "none", label: "Do nothing" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// ── Accessibility ───────────────────────────────────────────────────
|
// ── Accessibility ───────────────────────────────────────────────────
|
||||||
// Backed by gsettings so GTK applications agree with the shell, and
|
// Backed by gsettings so GTK applications agree with the shell, and
|
||||||
@@ -868,7 +1211,7 @@ Singleton {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Display configuration ───────────────────────────────────────────
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
// { "<output>": { mode, scale, transform } }, applied by
|
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
||||||
// hypr/monitors.lua on top of the shipped values. Colour management and
|
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||||
// bit depth are deliberately not here: those carry a documented
|
// bit depth are deliberately not here: those carry a documented
|
||||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||||
@@ -877,7 +1220,7 @@ Singleton {
|
|||||||
key: "displays", type: "json", def: ({}), group: "display",
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
internal: true,
|
internal: true,
|
||||||
label: "Display configuration",
|
label: "Display configuration",
|
||||||
detail: "Resolution, scale, and rotation per connected display"
|
detail: "Resolution, scale, rotation, position, and primary display"
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Per-application notification rules ──────────────────────────────
|
// ── Per-application notification rules ──────────────────────────────
|
||||||
|
|||||||
@@ -42,13 +42,41 @@ Singleton {
|
|||||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||||
|
|
||||||
// The pair. `accent` is the primary and carries every state meaning
|
// ── The accent ──────────────────────────────────────────────────────────
|
||||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
//
|
||||||
// theme — it never appears alone, only as the far end of a gradient. That
|
// `accent` is the primary and carries every state meaning (focused, active,
|
||||||
// restraint is the whole point: the two colours meeting is the signature,
|
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||||
// so the pink stops being special the moment it's used as a flat fill.
|
// gradient. That restraint is the whole point: the two colours meeting is
|
||||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
// the signature, so the second colour stops being special the moment it is
|
||||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
// used as a flat fill.
|
||||||
|
//
|
||||||
|
// NAMED accents rather than a free colour. Each name carries a curated
|
||||||
|
// triple per scheme, because an arbitrary hex cannot work in both: a colour
|
||||||
|
// 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 colour.
|
||||||
|
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 accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||||
|
|||||||
@@ -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 : "",
|
mode: monitor ? monitor.mode : "",
|
||||||
scale: monitor ? monitor.scale : 0,
|
scale: monitor ? monitor.scale : 0,
|
||||||
transform: monitor ? monitor.transform : -1,
|
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,
|
modes: monitor ? monitor.modes.length : 0,
|
||||||
awaiting: Displays.awaitingConfirmation,
|
awaiting: Displays.awaitingConfirmation,
|
||||||
canConfirm: Displays.canConfirm,
|
canConfirm: Displays.canConfirm,
|
||||||
@@ -36,6 +39,49 @@ ShellRoot {
|
|||||||
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
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 {
|
function refreshIdentityFixture(): string {
|
||||||
const modes = Displays.normaliseModes([
|
const modes = Displays.normaliseModes([
|
||||||
"[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 {
|
function applyBad(kind: string): bool {
|
||||||
const monitor = Displays.monitors[0];
|
const monitor = Displays.monitors[0];
|
||||||
if (!monitor) return false;
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ Pill {
|
|||||||
horizontalPadding: 8
|
horizontalPadding: 8
|
||||||
onActivated: ShellState.toggle("activity")
|
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 {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: {
|
text: {
|
||||||
|
|||||||
@@ -25,10 +25,18 @@ PanelWindow {
|
|||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
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: {
|
readonly property var activities: {
|
||||||
const result = [];
|
const result = [];
|
||||||
if (PrivacyState.recordingActive)
|
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)
|
if (PrivacyState.screenSharingActive)
|
||||||
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
||||||
if (PrivacyState.cameraActive)
|
if (PrivacyState.cameraActive)
|
||||||
@@ -38,11 +46,12 @@ PanelWindow {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function elapsed(): string {
|
// Pure formatter, no ticking property read here -- callers decide what
|
||||||
const total = Capture.recordingSeconds;
|
// seconds value to pass, and only they take on the per-second dependency.
|
||||||
const seconds = String(total % 60).padStart(2, "0");
|
function formatElapsed(totalSeconds: int): string {
|
||||||
const minutes = Math.floor(total / 60) % 60;
|
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||||
const hours = Math.floor(total / 3600);
|
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}`;
|
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +173,11 @@ PanelWindow {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
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
|
color: Theme.fgDim
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Pill {
|
|||||||
horizontalPadding: 8
|
horizontalPadding: 8
|
||||||
onActivated: ShellState.openDateMenu("agenda")
|
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.visible: root.hovered && root.visible
|
||||||
ToolTip.delay: 500
|
ToolTip.delay: 500
|
||||||
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,11 @@ Pill {
|
|||||||
|
|
||||||
onActivated: ShellState.toggleDateMenu("agenda")
|
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 {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: Qt.formatDateTime(clock.date, root.format)
|
text: Qt.formatDateTime(clock.date, root.format)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell.Services.Mpris
|
import Quickshell.Services.Mpris
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Pill {
|
Pill {
|
||||||
@@ -34,6 +35,9 @@ Pill {
|
|||||||
onActivated: if (root.player?.canTogglePlaying)
|
onActivated: if (root.player?.canTogglePlaying)
|
||||||
root.player.togglePlaying()
|
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
|
// Scroll up = previous, down = next — the same direction as the workspace
|
||||||
// switcher, so the whole bar scrolls consistently.
|
// switcher, so the whole bar scrolls consistently.
|
||||||
onScrolled: delta => {
|
onScrolled: delta => {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ Pill {
|
|||||||
|
|
||||||
onActivated: root.requestQuickSettings()
|
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 ───────────────────────────────────────────────────────────────
|
// ── Audio ───────────────────────────────────────────────────────────────
|
||||||
// Without a tracker, volume and muted silently read as zero/false.
|
// Without a tracker, volume and muted silently read as zero/false.
|
||||||
PwObjectTracker {
|
PwObjectTracker {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import qs.widgets
|
|||||||
Pill {
|
Pill {
|
||||||
id: root
|
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
|
interactive: false
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import qs.widgets
|
|||||||
Pill {
|
Pill {
|
||||||
id: root
|
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
|
interactive: false
|
||||||
visible: Weather.available
|
visible: Weather.available
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,12 +48,22 @@ PanelWindow {
|
|||||||
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
|
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
|
||||||
|
|
||||||
// ── Intellihide ─────────────────────────────────────────────────────────
|
// ── 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
|
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
|
||||||
// be computed. "Is anything on this workspace at all" is the robust proxy,
|
// 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
|
// and it is what Dash-to-Dock's all-windows intellihide felt like in
|
||||||
// practice: an empty workspace keeps the dock out.
|
// 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: {
|
readonly property bool workspaceOccupied: {
|
||||||
const ws = Hyprland.focusedWorkspace;
|
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
|
||||||
return !!ws && ws.toplevels.values.length > 0;
|
return !!ws && ws.toplevels.values.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,15 +93,31 @@ PanelWindow {
|
|||||||
onTriggered: root.revealed = false
|
onTriggered: root.revealed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other modules (the bar, the capture overlay) read this.
|
// Other modules (the bar, the capture overlay) read this. It is one
|
||||||
onRevealedChanged: ShellState.dockRevealed = revealed
|
// 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
|
// wantRevealed's first evaluation emits no change signal when it lands on
|
||||||
// false (the default), so the initial state has to be taken explicitly —
|
// 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.
|
// otherwise a shell started on a busy workspace would leave the dock up.
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
revealed = wantRevealed;
|
revealed = wantRevealed;
|
||||||
ShellState.dockRevealed = revealed;
|
root._syncShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Input region ────────────────────────────────────────────────────────
|
// ── Input region ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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.
|
// A single banner: a notification card that slides in and times itself out.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell.Services.Notifications
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
@@ -14,28 +13,25 @@ Item {
|
|||||||
// into a keyboard focus request on the layer surface.
|
// into a keyboard focus request on the layer surface.
|
||||||
signal replyFocusChanged(bool focused)
|
signal replyFocusChanged(bool focused)
|
||||||
|
|
||||||
|
// Mirrors the signal above so the dismiss timer below can read it.
|
||||||
|
property bool replyFocused: false
|
||||||
|
|
||||||
implicitHeight: card.implicitHeight
|
implicitHeight: card.implicitHeight
|
||||||
|
|
||||||
// Critical notifications stay until dismissed (the setting is 0). An app
|
// Critical notifications stay until dismissed (the setting is 0). An app
|
||||||
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
||||||
// "server decides", which is our default.
|
// "server decides", which is our default. Shared with Notifs.qml, which
|
||||||
readonly property int timeoutMs: {
|
// gives a DND-hidden transient notification the same lifetime.
|
||||||
if (root.notification.urgency === NotificationUrgency.Critical)
|
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: hover
|
id: hover
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
// Hovering holds the banner open; the countdown restarts on leave.
|
// Hovering, or actively typing a reply, holds the banner open; the
|
||||||
running: root.timeoutMs > 0 && !hover.hovered
|
// countdown restarts (from the top, same as hover) once both let go.
|
||||||
|
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
|
||||||
interval: root.timeoutMs
|
interval: root.timeoutMs
|
||||||
onTriggered: Notifs.dropPopup(root.notification)
|
onTriggered: Notifs.dropPopup(root.notification)
|
||||||
}
|
}
|
||||||
@@ -45,7 +41,10 @@ Item {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
notification: root.notification
|
notification: root.notification
|
||||||
onDismissed: Notifs.dropPopup(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.
|
// Slide in from the right edge. Runs once, on creation.
|
||||||
NumberAnimation on x {
|
NumberAnimation on x {
|
||||||
|
|||||||
@@ -45,7 +45,17 @@ PanelWindow {
|
|||||||
spacing: Theme.itemSpacing
|
spacing: Theme.itemSpacing
|
||||||
|
|
||||||
Repeater {
|
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 {
|
Toast {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|||||||
@@ -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) {
|
if (root.visible) {
|
||||||
KdeConnect.refresh();
|
KdeConnect.refresh();
|
||||||
HomeAssistant.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();
|
openAnim.restart();
|
||||||
} else {
|
} else {
|
||||||
panel.reset();
|
panel.reset();
|
||||||
|
|||||||
@@ -130,6 +130,21 @@ Item {
|
|||||||
onToggled: Caffeine.toggle()
|
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 {
|
Toggle {
|
||||||
width: root.cellWidth
|
width: root.cellWidth
|
||||||
icon: "night-light-symbolic"
|
icon: "night-light-symbolic"
|
||||||
@@ -190,6 +205,38 @@ Item {
|
|||||||
color: Theme.alpha(Theme.fg, 0.1)
|
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 ─────────────────────────────────────────────────────────
|
// ── Sliders ─────────────────────────────────────────────────────────
|
||||||
AudioSlider {
|
AudioSlider {
|
||||||
width: content.width
|
width: content.width
|
||||||
|
|||||||
@@ -137,7 +137,18 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
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 {
|
Column {
|
||||||
id: entry
|
id: entry
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
|
|||||||
ScrollColumn 1.0 ScrollColumn.qml
|
ScrollColumn 1.0 ScrollColumn.qml
|
||||||
Section 1.0 Section.qml
|
Section 1.0 Section.qml
|
||||||
WifiList 1.0 WifiList.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 colours meeting.
|
||||||
|
// A row of flat circles would misrepresent all of them.
|
||||||
|
//
|
||||||
|
// Named accents rather than a colour 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
|
||||||
|
// colour 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 colours.
|
||||||
|
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 colour is
|
||||||
|
// exactly what someone with a colour 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,10 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property string expandedPicker: ""
|
||||||
|
|
||||||
title: "Appearance"
|
title: "Appearance"
|
||||||
lede: "Drag anything below. The preview above is your real geometry, to scale."
|
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||||
|
|
||||||
header: Component {
|
header: Component {
|
||||||
Column {
|
Column {
|
||||||
@@ -48,9 +50,16 @@ SettingsPage {
|
|||||||
? Wallpaper.lastError
|
? Wallpaper.lastError
|
||||||
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
: "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 {
|
WallpaperPicker {
|
||||||
id: wallpapers
|
id: wallpapers
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
mode: wallpaperControls.mode
|
||||||
|
selectedOutput: wallpaperControls.selectedOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
@@ -73,17 +82,41 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
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 {
|
SettingsCard {
|
||||||
title: "Colour scheme"
|
title: "Colour scheme"
|
||||||
subtitle: ColorScheme.lastError !== ""
|
subtitle: ColorScheme.lastError !== ""
|
||||||
? 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."
|
: "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 {
|
SettingsCard {
|
||||||
title: "Typography"
|
title: "Shell typography"
|
||||||
subtitle: Fonts.lastError !== ""
|
subtitle: Fonts.lastError !== ""
|
||||||
? Fonts.lastError
|
? Fonts.lastError
|
||||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
||||||
@@ -123,6 +156,137 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Application typography"
|
||||||
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
|
? DesktopStyle.lastError
|
||||||
|
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Application font"
|
||||||
|
detail: DesktopStyle.applicationFont
|
||||||
|
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "application-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.interfaceFonts
|
||||||
|
current: DesktopStyle.applicationFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setApplicationFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "applicationFontSize" }
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Document font"
|
||||||
|
detail: DesktopStyle.documentFont
|
||||||
|
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "document-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.interfaceFonts
|
||||||
|
current: DesktopStyle.documentFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setDocumentFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "documentFontSize" }
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Monospace font"
|
||||||
|
detail: DesktopStyle.monospaceFont
|
||||||
|
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "monospace-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.monospaceFonts
|
||||||
|
current: DesktopStyle.monospaceFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setMonospaceFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "monospaceFontSize" }
|
||||||
|
ChoiceRow { setting: "fontHinting" }
|
||||||
|
ChoiceRow { setting: "fontAntialiasing"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Icons & pointer"
|
||||||
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
|
? DesktopStyle.lastError
|
||||||
|
: "Installed themes only. The pointer updates in applications and Hyprland together."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Application icons"
|
||||||
|
detail: DesktopStyle.iconTheme
|
||||||
|
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
|
||||||
|
enabled: DesktopStyle.catalogLoaded
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
visible: root.expandedPicker === "icon-theme"
|
||||||
|
width: parent.width
|
||||||
|
items: DesktopStyle.iconThemes
|
||||||
|
current: DesktopStyle.iconTheme
|
||||||
|
placeholder: "Search icon themes"
|
||||||
|
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
|
||||||
|
onPicked: value => {
|
||||||
|
if (DesktopStyle.setIconTheme(value))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Pointer theme"
|
||||||
|
detail: DesktopStyle.cursorTheme
|
||||||
|
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
|
||||||
|
enabled: DesktopStyle.catalogLoaded
|
||||||
|
divider: false
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
visible: root.expandedPicker === "cursor-theme"
|
||||||
|
width: parent.width
|
||||||
|
items: DesktopStyle.cursorThemes
|
||||||
|
current: DesktopStyle.cursorTheme
|
||||||
|
placeholder: "Search pointer themes"
|
||||||
|
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
|
||||||
|
onPicked: value => {
|
||||||
|
if (DesktopStyle.setCursorTheme(value))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Titlebars"
|
||||||
|
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||||
|
|
||||||
|
ChoiceRow { setting: "titlebarButtonSide" }
|
||||||
|
ToggleRow { setting: "titlebarMaximizeButton" }
|
||||||
|
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Windows"
|
title: "Windows"
|
||||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||||
@@ -131,7 +295,10 @@ SettingsPage {
|
|||||||
SliderRow { setting: "gapsIn" }
|
SliderRow { setting: "gapsIn" }
|
||||||
SliderRow { setting: "gapsOut" }
|
SliderRow { setting: "gapsOut" }
|
||||||
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
||||||
SliderRow { setting: "inactiveOpacity"; divider: false }
|
SliderRow { setting: "roundingPower" }
|
||||||
|
SliderRow { setting: "inactiveOpacity" }
|
||||||
|
SliderRow { setting: "activeOpacity" }
|
||||||
|
SliderRow { setting: "fullscreenOpacity"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -143,6 +310,8 @@ SettingsPage {
|
|||||||
SliderRow { setting: "blurPasses" }
|
SliderRow { setting: "blurPasses" }
|
||||||
ToggleRow { setting: "shadowEnabled" }
|
ToggleRow { setting: "shadowEnabled" }
|
||||||
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
||||||
|
SliderRow { setting: "shadowRenderPower" }
|
||||||
|
ToggleRow { setting: "shadowSharp" }
|
||||||
ToggleRow { setting: "glowEnabled" }
|
ToggleRow { setting: "glowEnabled" }
|
||||||
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
||||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
@@ -162,7 +331,10 @@ SettingsPage {
|
|||||||
|
|
||||||
ToggleRow { setting: "showCpu" }
|
ToggleRow { setting: "showCpu" }
|
||||||
ToggleRow { setting: "showMemory" }
|
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.
|
// Only worth asking when there is a choice to make.
|
||||||
ChoiceGrid {
|
ChoiceGrid {
|
||||||
@@ -182,11 +354,4 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Theme"
|
|
||||||
subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
|
||||||
|
|
||||||
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
|
||||||
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ SettingsPage {
|
|||||||
lede: "Choose what opens your files and links, and what starts with your session."
|
lede: "Choose what opens your files and links, and what starts with your session."
|
||||||
|
|
||||||
property string expandedRole: ""
|
property string expandedRole: ""
|
||||||
|
property bool addingAutostart: false
|
||||||
readonly property var applications: DesktopEntries.applications.values
|
readonly property var applications: DesktopEntries.applications.values
|
||||||
readonly property var roles: [
|
readonly property var roles: [
|
||||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
||||||
@@ -178,7 +179,28 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "User autostart"
|
title: "User autostart"
|
||||||
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
|
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Add an application"
|
||||||
|
detail: root.addingAutostart
|
||||||
|
? "Search the applications installed on this machine"
|
||||||
|
: "Start another installed application when you sign in"
|
||||||
|
action: root.addingAutostart ? "Close" : "Choose"
|
||||||
|
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
|
||||||
|
enabled: !DefaultApps.busy
|
||||||
|
onTriggered: root.addingAutostart = !root.addingAutostart
|
||||||
|
}
|
||||||
|
|
||||||
|
AutostartAppPicker {
|
||||||
|
visible: root.addingAutostart
|
||||||
|
width: parent.width
|
||||||
|
existing: DefaultApps.autostartEntries.map(entry => entry.id)
|
||||||
|
onPicked: id => {
|
||||||
|
DefaultApps.addAutostart(id);
|
||||||
|
root.addingAutostart = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ SettingRow {
|
|||||||
|
|
||||||
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
||||||
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
|
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.leftIndex >= 0 && root.rightIndex >= 0
|
||||||
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Adds an installed application to the user's freedesktop autostart directory.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.config
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var existing
|
||||||
|
signal picked(string id)
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
function desktopId(entry: var): string {
|
||||||
|
const id = String(entry?.id ?? "");
|
||||||
|
return id.endsWith(".desktop") ? id : id + ".desktop";
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var matches: {
|
||||||
|
const needle = search.text.trim().toLowerCase();
|
||||||
|
if (needle === "")
|
||||||
|
return [];
|
||||||
|
const out = [];
|
||||||
|
for (const entry of DesktopEntries.applications.values) {
|
||||||
|
const desktopId = root.desktopId(entry);
|
||||||
|
if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0)
|
||||||
|
continue;
|
||||||
|
const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase();
|
||||||
|
if (haystack.indexOf(needle) >= 0)
|
||||||
|
out.push(entry);
|
||||||
|
if (out.length >= 8)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: search
|
||||||
|
width: parent.width
|
||||||
|
placeholder: "Search installed applications"
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.matches
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: candidate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: String(candidate.modelData.name || root.desktopId(candidate.modelData))
|
||||||
|
detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData))
|
||||||
|
divider: candidate.index < root.matches.length - 1
|
||||||
|
controlWidth: 86
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Add"
|
||||||
|
onClicked: {
|
||||||
|
root.picked(root.desktopId(candidate.modelData));
|
||||||
|
search.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: search.text.trim() !== "" && root.matches.length === 0
|
||||||
|
label: "No matching applications"
|
||||||
|
detail: "Only installed desktop applications can start with the session"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,45 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GNOME's Multitasking panel, in Hyprland's terms.
|
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||||
|
// Only meaningful when the layout above is Master and stack. Hidden
|
||||||
|
// otherwise, because a card of settings that do nothing under the layout
|
||||||
|
// you are actually running is worse than not offering the layout at all.
|
||||||
|
SettingsCard {
|
||||||
|
visible: DesktopPreferences.get("windowLayout") === "master"
|
||||||
|
title: "Master and stack"
|
||||||
|
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
|
||||||
|
|
||||||
|
SliderRow { setting: "masterFactor" }
|
||||||
|
ChoiceRow { setting: "masterOrientation" }
|
||||||
|
ChoiceRow { setting: "masterNewStatus" }
|
||||||
|
ToggleRow { setting: "masterNewOnTop"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Window edges"
|
||||||
|
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
|
||||||
|
|
||||||
|
ToggleRow { setting: "resizeOnBorder" }
|
||||||
|
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
|
||||||
|
ToggleRow { setting: "hoverIconOnBorder" }
|
||||||
|
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
|
||||||
|
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
|
||||||
|
ToggleRow { setting: "snapRespectGaps"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland's own interruptions. Panama turns all four off, which is a
|
||||||
|
// defensible default and was not previously a decision anyone could
|
||||||
|
// reverse without editing looks.lua.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Hyprland notices"
|
||||||
|
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
|
||||||
|
|
||||||
|
ToggleRow { setting: "hyprlandLogo" }
|
||||||
|
ToggleRow { setting: "hyprlandSplash" }
|
||||||
|
ToggleRow { setting: "hyprlandUpdateNews" }
|
||||||
|
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Workspaces & focus"
|
title: "Workspaces & focus"
|
||||||
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||||
@@ -74,8 +113,7 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "workspaceBackAndForth" }
|
ToggleRow { setting: "workspaceBackAndForth" }
|
||||||
ToggleRow { setting: "allowWorkspaceCycles" }
|
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||||
ToggleRow { setting: "focusOnActivate" }
|
ToggleRow { setting: "focusOnActivate" }
|
||||||
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
|
||||||
ChoiceRow { setting: "followMouse"; divider: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
|||||||
@@ -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 {
|
SettingsCard {
|
||||||
visible: Displays.monitors.length > 1
|
visible: Displays.monitors.length > 1
|
||||||
title: "Connected display"
|
title: "Connected display"
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ SettingsPage {
|
|||||||
ActionRow {
|
ActionRow {
|
||||||
objectName: "health-fedora-handoff:color"
|
objectName: "health-fedora-handoff:color"
|
||||||
label: "Colour profiles"
|
label: "Colour profiles"
|
||||||
detail: "ICC profiles for displays, printers, and scanners"
|
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 colour"
|
action: "Open colour"
|
||||||
onTriggered: SystemSettings.openGnomePanel("color")
|
onTriggered: SystemSettings.openGnomePanel("color")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,12 +117,6 @@ SettingsPage {
|
|||||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "System vitals"
|
|
||||||
subtitle: "Processor, memory, and graphics activity in the bar"
|
|
||||||
SliderRow { setting: "vitalsIntervalMs"; divider: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
id: summaryCards
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,12 @@ SettingsPage {
|
|||||||
ChoiceRow { setting: "accelProfile" }
|
ChoiceRow { setting: "accelProfile" }
|
||||||
ToggleRow { setting: "naturalScroll" }
|
ToggleRow { setting: "naturalScroll" }
|
||||||
SliderRow { setting: "scrollFactor" }
|
SliderRow { setting: "scrollFactor" }
|
||||||
ToggleRow { setting: "leftHanded"; divider: false }
|
ToggleRow { setting: "leftHanded" }
|
||||||
|
ToggleRow {
|
||||||
|
setting: "middleClickPaste"
|
||||||
|
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
|||||||
@@ -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
|
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
|
||||||
file.
|
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 labelled 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 behaviour 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 colour 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
|
## The rows
|
||||||
|
|
||||||
| Component | For |
|
| Component | For |
|
||||||
@@ -125,6 +168,7 @@ file with `qs -p`, and discover its PID from the exact Config path in
|
|||||||
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
|
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
|
||||||
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
|
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
|
||||||
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
|
| `$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
|
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
|
||||||
skipped one would be worse than having no reset, because nothing would say so.
|
skipped one would be worse than having no reset, because nothing would say so.
|
||||||
|
|||||||
@@ -21,6 +21,25 @@ SettingsPage {
|
|||||||
// when nothing is being captured. Held here rather than per row so that
|
// when nothing is being captured. Held here rather than per row so that
|
||||||
// starting a new capture cancels any other.
|
// starting a new capture cancels any other.
|
||||||
property string capturingChord: ""
|
property string capturingChord: ""
|
||||||
|
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
|
||||||
|
|
||||||
|
function xkbOptions(): var {
|
||||||
|
return root.storedXkbOptions
|
||||||
|
.split(",")
|
||||||
|
.map(option => option.trim())
|
||||||
|
.filter(option => option !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentXkbOption(prefix: string): string {
|
||||||
|
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setXkbOption(prefix: string, option: string): void {
|
||||||
|
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
|
||||||
|
if (option !== "")
|
||||||
|
options.push(option);
|
||||||
|
SystemSettings.commitPreference("keyboardOptions", options.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
title: "Input & Shortcuts"
|
title: "Input & Shortcuts"
|
||||||
lede: "The Forge mental model, carried forward into native tiling."
|
lede: "The Forge mental model, carried forward into native tiling."
|
||||||
@@ -35,6 +54,52 @@ SettingsPage {
|
|||||||
// they are real controls.
|
// they are real controls.
|
||||||
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||||
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Caps Lock"
|
||||||
|
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
|
||||||
|
current: root.currentXkbOption("caps:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Standard" },
|
||||||
|
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
|
||||||
|
{ value: "caps:escape", label: "Escape" },
|
||||||
|
{ value: "caps:ctrl_modifier", label: "Control" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("caps:", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Compose key"
|
||||||
|
detail: "Type accented characters and symbols with memorable key sequences"
|
||||||
|
current: root.currentXkbOption("compose:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Off" },
|
||||||
|
{ value: "compose:ralt", label: "Right Alt" },
|
||||||
|
{ value: "compose:rwin", label: "Right Super" },
|
||||||
|
{ value: "compose:menu", label: "Menu" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("compose:", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Layout switching"
|
||||||
|
detail: "Used when Keyboard layout contains more than one comma-separated layout"
|
||||||
|
current: root.currentXkbOption("grp:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Off" },
|
||||||
|
{ value: "grp:win_space_toggle", label: "Super + Space" },
|
||||||
|
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
|
||||||
|
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
|
||||||
|
{ value: "grp:caps_toggle", label: "Caps Lock" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("grp:", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets preserve every option outside their own category. The raw
|
||||||
|
// value remains visible for less common xkeyboard-config features.
|
||||||
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||||
SliderRow { setting: "keyRepeatDelay" }
|
SliderRow { setting: "keyRepeatDelay" }
|
||||||
SliderRow { setting: "keyRepeatRate" }
|
SliderRow { setting: "keyRepeatRate" }
|
||||||
|
|||||||
@@ -51,9 +51,11 @@ Item {
|
|||||||
return typeof value === "number" ? value : root.minimum;
|
return typeof value === "number" ? value : root.minimum;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shown while dragging; -1 means "nothing pending, show what is stored".
|
// Shown while dragging; null means "nothing pending, show what is stored".
|
||||||
property real pending: -1
|
// Not -1: several schema entries (e.g. pointerSensitivity) are legitimately
|
||||||
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
|
// 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
|
// Below this the label and a usable slider cannot share a line without one
|
||||||
// of them becoming useless.
|
// of them becoming useless.
|
||||||
@@ -176,7 +178,7 @@ Item {
|
|||||||
id: commitTimer
|
id: commitTimer
|
||||||
interval: 140
|
interval: 140
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (root.pending < 0)
|
if (root.pending === null)
|
||||||
return;
|
return;
|
||||||
SystemSettings.commitPreference(root.setting, root.pending);
|
SystemSettings.commitPreference(root.setting, root.pending);
|
||||||
// Hand the display back to the stored value. If the write was
|
// Hand the display back to the stored value. If the write was
|
||||||
@@ -188,6 +190,6 @@ Item {
|
|||||||
Timer {
|
Timer {
|
||||||
id: releaseTimer
|
id: releaseTimer
|
||||||
interval: 160
|
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 {
|
SettingsCard {
|
||||||
title: "Sound feedback"
|
title: "Sound feedback"
|
||||||
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
||||||
@@ -67,8 +76,8 @@ SettingsPage {
|
|||||||
title: "Advanced sound"
|
title: "Advanced sound"
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Application volumes and profiles"
|
label: "Device profiles"
|
||||||
detail: "Open Fedora's complete sound panel"
|
detail: "Open Fedora's complete device profile panel"
|
||||||
divider: false
|
divider: false
|
||||||
action: "Open panel"
|
action: "Open panel"
|
||||||
onTriggered: SystemSettings.openGnomePanel("sound")
|
onTriggered: SystemSettings.openGnomePanel("sound")
|
||||||
|
|||||||
@@ -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,
|
// unreachable without a long scroll past pictures. Two rows by default,
|
||||||
// all of them on request.
|
// all of them on request.
|
||||||
property bool expanded: false
|
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 int collapsedRows: 2
|
||||||
|
|
||||||
readonly property var shown: root.expanded
|
readonly property var shown: root.expanded
|
||||||
@@ -35,6 +43,27 @@ Item {
|
|||||||
readonly property int columns: Math.max(2, Math.floor(width / 190))
|
readonly property int columns: Math.max(2, Math.floor(width / 190))
|
||||||
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
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 {
|
Grid {
|
||||||
id: grid
|
id: grid
|
||||||
|
|
||||||
@@ -50,7 +79,8 @@ Item {
|
|||||||
|
|
||||||
required property var modelData
|
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
|
width: root.cellWidth
|
||||||
height: Math.round(root.cellWidth * 9 / 16)
|
height: Math.round(root.cellWidth * 9 / 16)
|
||||||
@@ -114,6 +144,28 @@ Item {
|
|||||||
border.color: Theme.accent
|
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 {
|
Rectangle {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
@@ -132,7 +184,9 @@ Item {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.margins: 7
|
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
|
color: tile.current ? Theme.accent : Theme.fg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
@@ -144,7 +198,7 @@ Item {
|
|||||||
HoverHandler { id: hover }
|
HoverHandler { id: hover }
|
||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
onTapped: Wallpaper.set(tile.modelData)
|
onTapped: root.activate(tile.modelData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,22 +29,29 @@ ChoiceRow 1.0 ChoiceRow.qml
|
|||||||
ActionRow 1.0 ActionRow.qml
|
ActionRow 1.0 ActionRow.qml
|
||||||
TextRow 1.0 TextRow.qml
|
TextRow 1.0 TextRow.qml
|
||||||
DesktopPreview 1.0 DesktopPreview.qml
|
DesktopPreview 1.0 DesktopPreview.qml
|
||||||
|
LockScreenPreview 1.0 LockScreenPreview.qml
|
||||||
PowerPage 1.0 PowerPage.qml
|
PowerPage 1.0 PowerPage.qml
|
||||||
DateTimePage 1.0 DateTimePage.qml
|
DateTimePage 1.0 DateTimePage.qml
|
||||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||||
|
WallpaperControls 1.0 WallpaperControls.qml
|
||||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||||
|
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||||
DockAppPicker 1.0 DockAppPicker.qml
|
DockAppPicker 1.0 DockAppPicker.qml
|
||||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||||
ChoiceGrid 1.0 ChoiceGrid.qml
|
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
|
DisplayArrangement 1.0 DisplayArrangement.qml
|
||||||
|
DisplayIdentify 1.0 DisplayIdentify.qml
|
||||||
WifiPanel 1.0 WifiPanel.qml
|
WifiPanel 1.0 WifiPanel.qml
|
||||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||||
PasswordField 1.0 PasswordField.qml
|
PasswordField 1.0 PasswordField.qml
|
||||||
AudioBalance 1.0 AudioBalance.qml
|
AudioBalance 1.0 AudioBalance.qml
|
||||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||||
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||||
|
ApplicationMixer 1.0 ApplicationMixer.qml
|
||||||
|
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
|
||||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||||
LocationPicker 1.0 LocationPicker.qml
|
LocationPicker 1.0 LocationPicker.qml
|
||||||
FontPicker 1.0 FontPicker.qml
|
FontPicker 1.0 FontPicker.qml
|
||||||
@@ -54,3 +61,4 @@ PrivacyPage 1.0 PrivacyPage.qml
|
|||||||
RegionPage 1.0 RegionPage.qml
|
RegionPage 1.0 RegionPage.qml
|
||||||
SearchPicker 1.0 SearchPicker.qml
|
SearchPicker 1.0 SearchPicker.qml
|
||||||
OnlineAccountsPage 1.0 OnlineAccountsPage.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
|
return 1
|
||||||
|
|
||||||
loop = GLib.MainLoop()
|
loop = GLib.MainLoop()
|
||||||
|
try:
|
||||||
registry = EDataServer.SourceRegistry.new_sync(None)
|
registry = EDataServer.SourceRegistry.new_sync(None)
|
||||||
|
except Exception:
|
||||||
|
_write_snapshot(_unavailable_snapshot())
|
||||||
|
return 1
|
||||||
debounce_source = 0
|
debounce_source = 0
|
||||||
subscriptions: list[int] = []
|
subscriptions: list[int] = []
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
|
|||||||
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
|
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
|
||||||
|
|
||||||
|
|
||||||
def discovered_desktop_ids() -> set[str]:
|
def discovered_desktop_files() -> dict[str, Path]:
|
||||||
desktop_ids: set[str] = set()
|
desktop_files: dict[str, Path] = {}
|
||||||
for root in xdg_data_roots():
|
for root in xdg_data_roots():
|
||||||
applications = root / "applications"
|
applications = root / "applications"
|
||||||
if not applications.is_dir():
|
if not applications.is_dir():
|
||||||
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
relative = path.relative_to(applications)
|
relative = path.relative_to(applications)
|
||||||
desktop_ids.add("-".join(relative.parts))
|
desktop_files.setdefault("-".join(relative.parts), path)
|
||||||
return desktop_ids
|
return desktop_files
|
||||||
|
|
||||||
|
|
||||||
|
def discovered_desktop_ids() -> set[str]:
|
||||||
|
return set(discovered_desktop_files())
|
||||||
|
|
||||||
|
|
||||||
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
|
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
|
||||||
@@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None:
|
|||||||
run(command)
|
run(command)
|
||||||
|
|
||||||
|
|
||||||
def update_hidden(path: Path, *, hidden: bool) -> None:
|
def with_hidden(original: str, *, hidden: bool) -> str:
|
||||||
try:
|
|
||||||
original = path.read_text(encoding="utf-8")
|
|
||||||
except (OSError, UnicodeError) as error:
|
|
||||||
raise BoundaryError("That autostart entry could not be read.") from error
|
|
||||||
|
|
||||||
lines = original.splitlines()
|
lines = original.splitlines()
|
||||||
output: list[str] = []
|
output: list[str] = []
|
||||||
section = ""
|
section = ""
|
||||||
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
|
|||||||
raise BoundaryError("That autostart entry is not a desktop file.")
|
raise BoundaryError("That autostart entry is not a desktop file.")
|
||||||
if not wrote_hidden:
|
if not wrote_hidden:
|
||||||
output.append(f"Hidden={'true' if hidden else 'false'}")
|
output.append(f"Hidden={'true' if hidden else 'false'}")
|
||||||
|
return "\n".join(output) + "\n"
|
||||||
|
|
||||||
mode = path.stat().st_mode
|
|
||||||
|
def write_atomic(path: Path, text: str, *, mode: int) -> None:
|
||||||
|
temporary_path: Path | None = None
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||||
) as temporary:
|
) as temporary:
|
||||||
temporary.write("\n".join(output) + "\n")
|
temporary.write(text)
|
||||||
temporary.flush()
|
temporary.flush()
|
||||||
os.fsync(temporary.fileno())
|
os.fsync(temporary.fileno())
|
||||||
temporary_path = Path(temporary.name)
|
temporary_path = Path(temporary.name)
|
||||||
temporary_path.chmod(mode)
|
temporary_path.chmod(mode)
|
||||||
os.replace(temporary_path, path)
|
os.replace(temporary_path, path)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
if "temporary_path" in locals():
|
if temporary_path is not None:
|
||||||
temporary_path.unlink(missing_ok=True)
|
temporary_path.unlink(missing_ok=True)
|
||||||
raise BoundaryError("That autostart entry could not be updated.") from error
|
raise BoundaryError("That autostart entry could not be updated.") from error
|
||||||
|
|
||||||
|
|
||||||
|
def update_hidden(path: Path, *, hidden: bool) -> None:
|
||||||
|
try:
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise BoundaryError("That autostart entry could not be read.") from error
|
||||||
|
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
def add_autostart(desktop_id: str) -> None:
|
||||||
|
desktop_files = discovered_desktop_files()
|
||||||
|
require_desktop_id(desktop_id, discovered=set(desktop_files))
|
||||||
|
source = desktop_files[desktop_id]
|
||||||
|
directory = autostart_directory()
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as error:
|
||||||
|
raise BoundaryError("The user autostart directory could not be created.") from error
|
||||||
|
|
||||||
|
target = directory / desktop_id
|
||||||
|
if target.is_symlink():
|
||||||
|
raise BoundaryError("That autostart entry is not available.")
|
||||||
|
if target.exists():
|
||||||
|
if not target.is_file():
|
||||||
|
raise BoundaryError("That autostart entry is not available.")
|
||||||
|
update_hidden(target, hidden=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
original = source.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise BoundaryError("That application could not be read.") from error
|
||||||
|
write_atomic(target, with_hidden(original, hidden=False), mode=0o644)
|
||||||
|
|
||||||
|
|
||||||
def set_autostart(desktop_id: str, enabled_text: str) -> None:
|
def set_autostart(desktop_id: str, enabled_text: str) -> None:
|
||||||
if enabled_text not in {"true", "false"}:
|
if enabled_text not in {"true", "false"}:
|
||||||
raise BoundaryError("Autostart state must be true or false.")
|
raise BoundaryError("Autostart state must be true or false.")
|
||||||
@@ -260,10 +298,12 @@ def main(arguments: list[str]) -> int:
|
|||||||
set_default(arguments[1], arguments[2])
|
set_default(arguments[1], arguments[2])
|
||||||
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
||||||
set_autostart(arguments[1], arguments[2])
|
set_autostart(arguments[1], arguments[2])
|
||||||
|
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
||||||
|
add_autostart(arguments[1])
|
||||||
else:
|
else:
|
||||||
raise BoundaryError(
|
raise BoundaryError(
|
||||||
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
||||||
"set-autostart DESKTOP_ID true|false"
|
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
|
||||||
)
|
)
|
||||||
except BoundaryError as error:
|
except BoundaryError as error:
|
||||||
print(str(error), file=sys.stderr)
|
print(str(error), file=sys.stderr)
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Report installed cursor and icon themes from the standard XDG roots.
|
||||||
|
|
||||||
|
This helper is intentionally read-only and argument-free. Theme paths come
|
||||||
|
only from XDG_DATA_HOME and XDG_DATA_DIRS; directory symlinks are not followed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def icon_roots() -> list[Path]:
|
||||||
|
home = Path(os.environ.get("HOME") or "/nonexistent")
|
||||||
|
data_home = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
|
||||||
|
data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
|
||||||
|
|
||||||
|
roots = [data_home / "icons"]
|
||||||
|
roots.extend(Path(directory) / "icons" for directory in data_dirs.split(":") if directory)
|
||||||
|
# XDG paths are required to be absolute. Ignoring malformed relative
|
||||||
|
# entries also prevents the helper's working directory becoming an
|
||||||
|
# accidental caller-controlled search root.
|
||||||
|
return [root for root in roots if root.is_absolute()]
|
||||||
|
|
||||||
|
|
||||||
|
def is_real_directory(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
return stat.S_ISDIR(path.lstat().st_mode)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_real_file(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
return stat.S_ISREG(path.lstat().st_mode)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def has_icon_directories(index_path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
with index_path.open(encoding="utf-8", errors="replace") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if line.startswith(("#", ";")) or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
if key.strip() == "Directories":
|
||||||
|
return bool(value.strip())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def catalog() -> dict[str, list[str]]:
|
||||||
|
cursor_themes: set[str] = set()
|
||||||
|
icon_themes: set[str] = set()
|
||||||
|
|
||||||
|
for root in icon_roots():
|
||||||
|
if not is_real_directory(root):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries = list(os.scandir(root))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_dir(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
theme = Path(entry.path)
|
||||||
|
if is_real_directory(theme / "cursors"):
|
||||||
|
cursor_themes.add(entry.name)
|
||||||
|
|
||||||
|
index_path = theme / "index.theme"
|
||||||
|
if is_real_file(index_path) and has_icon_directories(index_path):
|
||||||
|
icon_themes.add(entry.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cursorThemes": sorted(cursor_themes, key=str.casefold),
|
||||||
|
"iconThemes": sorted(icon_themes, key=str.casefold),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 1:
|
||||||
|
print("panama-desktop-style takes no arguments", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(json.dumps(catalog(), ensure_ascii=False, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -61,6 +61,16 @@ class DoctorConfig:
|
|||||||
runtime_dir: Path
|
runtime_dir: Path
|
||||||
path: str
|
path: str
|
||||||
timeout: float
|
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
|
@property
|
||||||
def command_env(self) -> dict[str, str]:
|
def command_env(self) -> dict[str, str]:
|
||||||
@@ -102,7 +112,7 @@ class RepairResult:
|
|||||||
|
|
||||||
CHECK_ORDER = (
|
CHECK_ORDER = (
|
||||||
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
|
"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",
|
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||||
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
||||||
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
||||||
@@ -130,7 +140,7 @@ RUNTIME_LINK_TARGETS = (
|
|||||||
("vicinae", Path("config/dot/vicinae")),
|
("vicinae", Path("config/dot/vicinae")),
|
||||||
)
|
)
|
||||||
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
|
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
|
||||||
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
|
PROCESS_NAMES = ("vicinae", "hyprpaper", "hypridle")
|
||||||
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
||||||
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||||
PROBE_ENVIRONMENT_KEYS = (
|
PROBE_ENVIRONMENT_KEYS = (
|
||||||
@@ -159,6 +169,7 @@ CHECK_TITLES = {
|
|||||||
"desktop.portals": "Desktop portals",
|
"desktop.portals": "Desktop portals",
|
||||||
"desktop.hyprpaper": "Hyprpaper",
|
"desktop.hyprpaper": "Hyprpaper",
|
||||||
"desktop.hypridle": "Hypridle",
|
"desktop.hypridle": "Hypridle",
|
||||||
|
"desktop.hyprlock": "Lock screen",
|
||||||
"desktop.vicinae": "Vicinae",
|
"desktop.vicinae": "Vicinae",
|
||||||
"input.pipewire": "PipeWire",
|
"input.pipewire": "PipeWire",
|
||||||
"input.clipboard": "Clipboard",
|
"input.clipboard": "Clipboard",
|
||||||
@@ -192,11 +203,25 @@ def config_from_environment() -> DoctorConfig:
|
|||||||
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
|
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")))
|
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])
|
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:
|
try:
|
||||||
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
|
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
timeout = 3.0
|
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:
|
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
|
||||||
@@ -221,7 +246,7 @@ def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path
|
|||||||
command,
|
command,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=config.timeout,
|
timeout=config.repair_timeout,
|
||||||
check=False,
|
check=False,
|
||||||
env=config.command_env,
|
env=config.command_env,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
@@ -320,8 +345,43 @@ def check_portals(config: DoctorConfig) -> Check:
|
|||||||
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
|
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:
|
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")
|
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
||||||
if result.state == "timeout":
|
if result.state == "timeout":
|
||||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
|
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
|
||||||
@@ -382,7 +442,7 @@ def check_home_assistant(config: DoctorConfig) -> Check:
|
|||||||
|
|
||||||
|
|
||||||
def check_calendar(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")
|
action = Action("open", "Open Date & Time", target="datetime")
|
||||||
if result.state == "missing":
|
if result.state == "missing":
|
||||||
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
|
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
|
||||||
@@ -436,7 +496,21 @@ def executable_check(check_id: str, title: str, executable: str, config: DoctorC
|
|||||||
|
|
||||||
|
|
||||||
def check_processes(config: DoctorConfig) -> Check:
|
def check_processes(config: DoctorConfig) -> Check:
|
||||||
counts: list[int] = []
|
# `qs` is both the long-running shell and every short-lived IPC client.
|
||||||
|
# Counting it with pgrep races the other parallel health probes and reports
|
||||||
|
# duplicates whenever one of them happens to call `qs ipc`. The instance
|
||||||
|
# list is the authoritative view and contains only actual shells.
|
||||||
|
quickshell = run_command(("qs", "list"), config)
|
||||||
|
if quickshell.state == "ok":
|
||||||
|
quickshell_count = sum(
|
||||||
|
line.startswith("Instance ") for line in quickshell.stdout.splitlines()
|
||||||
|
)
|
||||||
|
elif quickshell.state == "failed":
|
||||||
|
quickshell_count = 0
|
||||||
|
else:
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
|
||||||
|
|
||||||
|
counts: list[int] = [quickshell_count]
|
||||||
for name in PROCESS_NAMES:
|
for name in PROCESS_NAMES:
|
||||||
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
||||||
if result.state == "ok":
|
if result.state == "ok":
|
||||||
@@ -500,7 +574,7 @@ def unavailable_versions() -> list[dict[str, str]]:
|
|||||||
def collect_checks(config: DoctorConfig) -> list[Check]:
|
def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||||
probes: dict[str, Callable[[], 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.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),
|
"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),
|
"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),
|
"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),
|
||||||
@@ -533,6 +607,11 @@ def snapshot(config: DoctorConfig) -> dict[str, object]:
|
|||||||
|
|
||||||
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
|
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
|
||||||
command = REPAIR_COMMANDS[check_id]
|
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)
|
exit_code, _ = run_repair_command(command, config)
|
||||||
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
|
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
|
||||||
else "The authored repair command could not be completed."
|
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_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d"
|
||||||
dropin="$dropin_dir/panama.conf"
|
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() {
|
read_setting() {
|
||||||
local key="$1" fallback="$2"
|
local key="$1" fallback="$2"
|
||||||
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
|
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
|
||||||
@@ -53,13 +61,17 @@ generate() {
|
|||||||
load
|
load
|
||||||
mkdir -p "$state_dir"
|
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 '# Generated by panama-idle from %s\n' "$settings"
|
||||||
printf '# Do not edit: it is rewritten whenever the idle settings change.\n'
|
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 '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n'
|
||||||
|
|
||||||
printf 'general {\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
|
if [[ "$lock_on_sleep" == "true" ]]; then
|
||||||
printf ' before_sleep_cmd = loginctl lock-session\n'
|
printf ' before_sleep_cmd = loginctl lock-session\n'
|
||||||
fi
|
fi
|
||||||
@@ -91,9 +103,10 @@ generate() {
|
|||||||
printf ' on-timeout = systemctl suspend\n'
|
printf ' on-timeout = systemctl suspend\n'
|
||||||
printf '}\n'
|
printf '}\n'
|
||||||
fi
|
fi
|
||||||
} >"$generated.tmp"
|
} >"$generated_tmp"
|
||||||
|
|
||||||
mv "$generated.tmp" "$generated"
|
mv "$generated_tmp" "$generated"
|
||||||
|
generated_tmp=""
|
||||||
}
|
}
|
||||||
|
|
||||||
install_dropin() {
|
install_dropin() {
|
||||||
|
|||||||
@@ -39,13 +39,36 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def daemon_origin():
|
def secrets_name_owner_pid():
|
||||||
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
"""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
|
This is the only reliable way to identify which daemon actually answers
|
||||||
above: it is the one that cannot have the login password. PAM's daemon lives
|
Secret Service calls right now.
|
||||||
outside the app slice, so the cgroup tells the two apart.
|
|
||||||
"""
|
"""
|
||||||
|
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:
|
try:
|
||||||
for pid in os.listdir("/proc"):
|
for pid in os.listdir("/proc"):
|
||||||
if not pid.isdigit():
|
if not pid.isdigit():
|
||||||
@@ -55,19 +78,46 @@ def daemon_origin():
|
|||||||
cmdline = handle.read().decode("utf-8", "replace")
|
cmdline = handle.read().decode("utf-8", "replace")
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
if "gnome-keyring-daemon" not in cmdline:
|
if "gnome-keyring-daemon" in cmdline:
|
||||||
continue
|
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:
|
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()
|
cgroup = handle.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
||||||
return "dbus"
|
return "dbus"
|
||||||
return "pam"
|
return "pam"
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return "none"
|
|
||||||
|
|
||||||
|
|
||||||
def load_service():
|
def load_service():
|
||||||
|
|||||||
Executable
+381
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Generates Panama's hyprlock configuration into the state directory. The
|
||||||
|
# gitignored fallback config (rendered from the tracked .template at link
|
||||||
|
# time) is never rewritten by this script and remains the fallback if
|
||||||
|
# generation fails, so a malformed preference can never leave the session
|
||||||
|
# without a working locker.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||||
|
state_home="${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||||
|
settings="$config_home/panama/settings.json"
|
||||||
|
state_dir="$state_home/panama"
|
||||||
|
generated="$state_dir/hyprlock.conf"
|
||||||
|
status_file="$state_dir/hyprlock-status.json"
|
||||||
|
fallback="$config_home/hypr/hyprlock.conf"
|
||||||
|
temporary="$generated.tmp.$$"
|
||||||
|
status_temporary="$status_file.tmp.$$"
|
||||||
|
|
||||||
|
settings_valid=false
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$temporary" "$status_temporary" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
read_string() {
|
||||||
|
local key="$1" default="$2" value
|
||||||
|
if [[ "$settings_valid" != true ]]; then
|
||||||
|
printf '%s' "$default"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
value="$(jq -er --arg key "$key" \
|
||||||
|
'if has($key) and (.[$key] | type) == "string" then .[$key] else empty end' \
|
||||||
|
"$settings" 2>/dev/null)" || value="$default"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_bool() {
|
||||||
|
local key="$1" default="$2" value
|
||||||
|
if [[ "$settings_valid" != true ]]; then
|
||||||
|
printf '%s' "$default"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
value="$(jq -er --arg key "$key" \
|
||||||
|
'if has($key) and (.[$key] | type) == "boolean" then (.[$key] | tostring) else empty end' \
|
||||||
|
"$settings" 2>/dev/null)" || value="$default"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_int() {
|
||||||
|
local key="$1" default="$2" low="$3" high="$4" value
|
||||||
|
if [[ "$settings_valid" != true ]]; then
|
||||||
|
printf '%s' "$default"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
value="$(jq -er --arg key "$key" \
|
||||||
|
'if has($key) and (.[$key] | type) == "number" and (.[$key] | floor) == .[$key]
|
||||||
|
then (.[$key] | tostring) else empty end' "$settings" 2>/dev/null)" || value="$default"
|
||||||
|
if [[ ! "$value" =~ ^-?[0-9]+$ ]] || (( value < low || value > high )); then
|
||||||
|
value="$default"
|
||||||
|
fi
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_object() {
|
||||||
|
local key="$1"
|
||||||
|
if [[ "$settings_valid" != true ]]; then
|
||||||
|
printf '{}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
jq -c --arg key "$key" \
|
||||||
|
'if has($key) and (.[$key] | type) == "object" then .[$key] else {} end' \
|
||||||
|
"$settings" 2>/dev/null || printf '{}'
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_path() {
|
||||||
|
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
|
||||||
|
# hand -- there is no shared source between QML and a shell script), primary
|
||||||
|
# hue per scheme only: the lock screen shows a flat focus ring, not the
|
||||||
|
# two-stop gradient the window border does. Falls back to blue for an unknown
|
||||||
|
# name, matching Theme.accentPair's own fallback.
|
||||||
|
accent_hex() {
|
||||||
|
local name="$1" scheme="$2"
|
||||||
|
case "$name" in
|
||||||
|
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
|
||||||
|
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
|
||||||
|
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
|
||||||
|
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
|
||||||
|
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
|
||||||
|
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
|
||||||
|
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
|
||||||
|
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# hyprlock wants "R, G, B" decimal, not hex.
|
||||||
|
hex_to_rgb() {
|
||||||
|
local hex="$1"
|
||||||
|
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_preferences() {
|
||||||
|
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
|
||||||
|
settings_valid=true
|
||||||
|
else
|
||||||
|
settings_valid=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
background_mode="$(read_string lockBackgroundMode screenshot)"
|
||||||
|
[[ "$background_mode" == screenshot || "$background_mode" == wallpaper || "$background_mode" == solid ]] \
|
||||||
|
|| background_mode=screenshot
|
||||||
|
|
||||||
|
blur_level="$(read_int lockBlurLevel 3 0 5)"
|
||||||
|
show_clock="$(read_bool lockShowClock true)"
|
||||||
|
show_date="$(read_bool lockShowDate true)"
|
||||||
|
show_user="$(read_bool lockShowUser true)"
|
||||||
|
fade_on_empty="$(read_bool lockFadeOnEmpty false)"
|
||||||
|
use_24_hour="$(read_bool use24Hour false)"
|
||||||
|
|
||||||
|
color_scheme="$(read_string colorScheme dark)"
|
||||||
|
[[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark
|
||||||
|
|
||||||
|
accent_name="$(read_string accentName blue)"
|
||||||
|
case "$accent_name" in
|
||||||
|
blue|orchid|teal|green|amber|orange|rose|slate) ;;
|
||||||
|
*) accent_name=blue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
wallpaper_mode="$(read_string wallpaperMode single)"
|
||||||
|
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|
||||||
|
|| wallpaper_mode=single
|
||||||
|
wallpaper_path="$(read_string wallpaperPath '')"
|
||||||
|
if ! valid_path "$wallpaper_path"; then
|
||||||
|
wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
|
||||||
|
fi
|
||||||
|
wallpaper_assignments="$(read_object wallpaperPerMonitor)"
|
||||||
|
|
||||||
|
case "$blur_level" in
|
||||||
|
0) blur_passes=0; blur_size=1 ;;
|
||||||
|
1) blur_passes=1; blur_size=3 ;;
|
||||||
|
2) blur_passes=2; blur_size=5 ;;
|
||||||
|
3) blur_passes=3; blur_size=8 ;;
|
||||||
|
4) blur_passes=4; blur_size=10 ;;
|
||||||
|
5) blur_passes=5; blur_size=12 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$color_scheme" == light ]]; then
|
||||||
|
background_color='rgba(225, 226, 231, 1.0)'
|
||||||
|
foreground_color='rgba(55, 96, 191, 1.0)'
|
||||||
|
dim_color='rgba(97, 114, 176, 1.0)'
|
||||||
|
error_color='rgba(245, 42, 101, 1.0)'
|
||||||
|
field_color='rgba(208, 213, 227, 0.85)'
|
||||||
|
dim_hex='6172b0'
|
||||||
|
error_hex='f52a65'
|
||||||
|
else
|
||||||
|
background_color='rgba(34, 36, 54, 1.0)'
|
||||||
|
foreground_color='rgba(200, 211, 245, 1.0)'
|
||||||
|
dim_color='rgba(130, 139, 184, 1.0)'
|
||||||
|
error_color='rgba(255, 117, 127, 1.0)'
|
||||||
|
field_color='rgba(46, 47, 61, 0.85)'
|
||||||
|
dim_hex='828bb8'
|
||||||
|
error_hex='ff757f'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The focus ring is the accent role, not a scheme-relative one: it follows
|
||||||
|
# accentName the same way ColorScheme.qml's active_border does, and needs
|
||||||
|
# both the accent and the scheme since each accent carries a separate pair.
|
||||||
|
accent_rgb="$(hex_to_rgb "$(accent_hex "$accent_name" "$color_scheme")")"
|
||||||
|
accent_color="rgba($accent_rgb, 1.0)"
|
||||||
|
accent_ring_color="rgba($accent_rgb, 0.9)"
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor_names() {
|
||||||
|
hyprctl -j monitors 2>/dev/null \
|
||||||
|
| jq -r '.[]? | .name | select(type == "string") | select(test("^[A-Za-z0-9_.-]+$"))' \
|
||||||
|
2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_background() {
|
||||||
|
local monitor="$1" path="$2"
|
||||||
|
printf 'background {\n'
|
||||||
|
printf ' monitor = %s\n' "$monitor"
|
||||||
|
if [[ -n "$path" ]]; then
|
||||||
|
printf ' path = %s\n' "$path"
|
||||||
|
fi
|
||||||
|
printf ' blur_passes = %s\n' "$blur_passes"
|
||||||
|
printf ' blur_size = %s\n' "$blur_size"
|
||||||
|
printf ' noise = 0.0117\n'
|
||||||
|
printf ' contrast = 0.9\n'
|
||||||
|
printf ' brightness = 0.8\n'
|
||||||
|
printf ' vibrancy = 0.17\n'
|
||||||
|
printf ' vibrancy_darkness = 0.05\n'
|
||||||
|
printf ' color = %s\n' "$background_color"
|
||||||
|
printf ' zindex = -1\n'
|
||||||
|
printf '}\n\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_backgrounds() {
|
||||||
|
local output assigned saw_output=false
|
||||||
|
case "$background_mode" in
|
||||||
|
screenshot)
|
||||||
|
emit_background '' screenshot
|
||||||
|
;;
|
||||||
|
solid)
|
||||||
|
emit_background '' ''
|
||||||
|
;;
|
||||||
|
wallpaper)
|
||||||
|
while IFS= read -r output; do
|
||||||
|
[[ -n "$output" ]] || continue
|
||||||
|
saw_output=true
|
||||||
|
assigned="$wallpaper_path"
|
||||||
|
if [[ "$wallpaper_mode" == per-monitor ]]; then
|
||||||
|
candidate="$(jq -r --arg output "$output" \
|
||||||
|
'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \
|
||||||
|
<<<"$wallpaper_assignments" 2>/dev/null || true)"
|
||||||
|
if valid_path "$candidate"; then
|
||||||
|
assigned="$candidate"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
emit_background "$output" "$assigned"
|
||||||
|
done < <(monitor_names)
|
||||||
|
if [[ "$saw_output" != true ]]; then
|
||||||
|
emit_background '' "$wallpaper_path"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_config() {
|
||||||
|
printf '# Generated by Panama. Do not edit.\n\n'
|
||||||
|
printf 'general {\n'
|
||||||
|
printf ' hide_cursor = true\n'
|
||||||
|
printf ' fractional_scaling = 2\n'
|
||||||
|
printf ' screencopy_mode = 0\n'
|
||||||
|
printf ' fail_timeout = 2000\n'
|
||||||
|
printf '}\n\n'
|
||||||
|
printf 'auth {\n'
|
||||||
|
printf ' pam:enabled = true\n'
|
||||||
|
printf ' pam:module = hyprlock\n'
|
||||||
|
printf '}\n\n'
|
||||||
|
|
||||||
|
emit_backgrounds
|
||||||
|
|
||||||
|
if [[ "$show_clock" == true ]]; then
|
||||||
|
printf 'label {\n'
|
||||||
|
printf ' monitor =\n'
|
||||||
|
if [[ "$use_24_hour" == true ]]; then
|
||||||
|
printf ' text = cmd[update:1000] date +"%%H:%%M"\n'
|
||||||
|
else
|
||||||
|
printf ' text = cmd[update:1000] date +"%%-I:%%M"\n'
|
||||||
|
fi
|
||||||
|
printf ' color = %s\n' "$foreground_color"
|
||||||
|
printf ' font_size = 120\n'
|
||||||
|
printf ' font_family = Adwaita Sans Light\n'
|
||||||
|
printf ' position = 0, 260\n'
|
||||||
|
printf ' halign = center\n'
|
||||||
|
printf ' valign = center\n'
|
||||||
|
printf '}\n\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$show_date" == true ]]; then
|
||||||
|
printf 'label {\n'
|
||||||
|
printf ' monitor =\n'
|
||||||
|
printf ' text = cmd[update:60000] date +"%%A, %%B %%-d"\n'
|
||||||
|
printf ' color = %s\n' "$dim_color"
|
||||||
|
printf ' font_size = 24\n'
|
||||||
|
printf ' font_family = Adwaita Sans\n'
|
||||||
|
printf ' position = 0, 160\n'
|
||||||
|
printf ' halign = center\n'
|
||||||
|
printf ' valign = center\n'
|
||||||
|
printf '}\n\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'input-field {\n'
|
||||||
|
printf ' monitor =\n'
|
||||||
|
printf ' size = 340, 52\n'
|
||||||
|
printf ' position = 0, -40\n'
|
||||||
|
printf ' halign = center\n'
|
||||||
|
printf ' valign = center\n'
|
||||||
|
printf ' outline_thickness = 2\n'
|
||||||
|
printf ' rounding = 26\n'
|
||||||
|
printf ' outer_color = %s\n' "$accent_ring_color"
|
||||||
|
printf ' inner_color = %s\n' "$field_color"
|
||||||
|
printf ' font_color = %s\n' "$foreground_color"
|
||||||
|
printf ' check_color = %s\n' "$accent_color"
|
||||||
|
printf ' fail_color = %s\n' "$error_color"
|
||||||
|
printf ' dots_size = 0.25\n'
|
||||||
|
printf ' dots_spacing = 0.3\n'
|
||||||
|
printf ' dots_center = true\n'
|
||||||
|
printf ' placeholder_text = <span foreground="##%s"><i>Password</i></span>\n' "$dim_hex"
|
||||||
|
printf ' fail_text = <span foreground="##%s"><i>$FAIL ($ATTEMPTS)</i></span>\n' "$error_hex"
|
||||||
|
printf ' fade_on_empty = %s\n' "$fade_on_empty"
|
||||||
|
printf ' hide_input = false\n'
|
||||||
|
printf '}\n\n'
|
||||||
|
|
||||||
|
if [[ "$show_user" == true ]]; then
|
||||||
|
printf 'label {\n'
|
||||||
|
printf ' monitor =\n'
|
||||||
|
printf ' text = $USER\n'
|
||||||
|
printf ' color = %s\n' "$foreground_color"
|
||||||
|
printf ' font_size = 16\n'
|
||||||
|
printf ' font_family = Adwaita Sans\n'
|
||||||
|
printf ' position = 0, -110\n'
|
||||||
|
printf ' halign = center\n'
|
||||||
|
printf ' valign = center\n'
|
||||||
|
printf '}\n'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_status() {
|
||||||
|
local generated_value="$1" path_value="$2" fallback_value="$3" error_value="$4"
|
||||||
|
mkdir -p "$state_dir" 2>/dev/null || return 0
|
||||||
|
[[ -w "$state_dir" ]] || return 0
|
||||||
|
jq -nc --argjson generated "$generated_value" --arg path "$path_value" \
|
||||||
|
--argjson fallback "$fallback_value" --arg error "$error_value" \
|
||||||
|
'{generated:$generated,path:$path,fallback:$fallback,error:$error}' \
|
||||||
|
>"$status_temporary" 2>/dev/null || return 0
|
||||||
|
mv "$status_temporary" "$status_file" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
generate() {
|
||||||
|
load_preferences
|
||||||
|
mkdir -p "$state_dir" || return 1
|
||||||
|
if ! emit_config >"$temporary"; then
|
||||||
|
rm -f "$temporary" 2>/dev/null || true
|
||||||
|
write_status false "$fallback" true "The lock-screen configuration could not be generated."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$temporary" ]] \
|
||||||
|
|| ! rg -q '^auth \{' "$temporary" \
|
||||||
|
|| ! rg -q '^background \{' "$temporary" \
|
||||||
|
|| ! rg -q '^input-field \{' "$temporary"; then
|
||||||
|
rm -f "$temporary"
|
||||||
|
write_status false "$fallback" true "The lock-screen configuration could not be generated."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! mv "$temporary" "$generated"; then
|
||||||
|
rm -f "$temporary" 2>/dev/null || true
|
||||||
|
write_status false "$fallback" true "The lock-screen configuration could not be generated."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
write_status true "$generated" false ""
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
if [[ -r "$status_file" ]] \
|
||||||
|
&& jq -e '.generated | type == "boolean"' "$status_file" >/dev/null 2>&1 \
|
||||||
|
&& jq -e '.path | type == "string"' "$status_file" >/dev/null 2>&1; then
|
||||||
|
jq -c '{generated,path,fallback:(.fallback == true),error:(.error // "")}' "$status_file"
|
||||||
|
elif [[ -s "$generated" ]]; then
|
||||||
|
jq -nc --arg path "$generated" '{generated:true,path:$path,fallback:false,error:""}'
|
||||||
|
else
|
||||||
|
jq -nc --arg path "$fallback" \
|
||||||
|
'{generated:false,path:$path,fallback:true,error:"The generated lock-screen configuration is unavailable."}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
generate)
|
||||||
|
generate
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
status
|
||||||
|
;;
|
||||||
|
run)
|
||||||
|
if generate >/dev/null 2>&1; then
|
||||||
|
exec hyprlock -c "$generated"
|
||||||
|
fi
|
||||||
|
write_status false "$fallback" true "The lock-screen configuration could not be generated."
|
||||||
|
exec hyprlock -c "$fallback"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'usage: panama-lock [generate|status|run]\n' >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -71,7 +71,7 @@ adjust_microphone() {
|
|||||||
|
|
||||||
brightness_percent() {
|
brightness_percent() {
|
||||||
local output="$1" percent
|
local output="$1" percent
|
||||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
percent="$(awk -F, 'NR == 1 { value=$4; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||||
[[ $percent =~ ^[0-9]+$ ]] || return 1
|
[[ $percent =~ ^[0-9]+$ ]] || return 1
|
||||||
printf '%s\n' "$percent"
|
printf '%s\n' "$percent"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,15 +67,19 @@ cmd_list() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_set() {
|
cmd_set() {
|
||||||
local profile="${1:-}"
|
local profile="${1:-}" status
|
||||||
# Constrained rather than passed through: this reaches a system service.
|
# Constrained rather than passed through: this reaches a system service.
|
||||||
[[ "$profile" =~ ^[a-z-]+$ ]] || {
|
[[ "$profile" =~ ^[a-z-]+$ ]] || {
|
||||||
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
|
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
# pipefail is set above, so $? here is busctl's real exit status, not
|
||||||
|
# head's -- a failed write (daemon stopped, profile rejected) must be
|
||||||
|
# reported rather than always claimed as a success.
|
||||||
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
|
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
|
||||||
| head -2 >&2
|
| head -2 >&2
|
||||||
return 0
|
status=$?
|
||||||
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-list}" in
|
case "${1:-list}" in
|
||||||
|
|||||||
@@ -16,21 +16,116 @@
|
|||||||
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
|
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
|
||||||
# scheme in light mode, so it is generated from a template here instead.
|
# scheme in light mode, so it is generated from a template here instead.
|
||||||
#
|
#
|
||||||
# panama-theme-apps dark|light
|
# panama-theme-apps dark|light [accent]
|
||||||
#
|
#
|
||||||
# kitty gets it twice: the generated include file so terminals opened later
|
# kitty gets it twice: the generated include file so terminals opened later
|
||||||
# start correct, and a live `set-colors` over its control socket so terminals
|
# start correct, and a live `set-colors` over its control socket so terminals
|
||||||
# already open change now. Without the second, a scheme change appears to do
|
# already open change now. Without the second, a scheme change appears to do
|
||||||
# nothing until you open a new window.
|
# nothing until you open a new window.
|
||||||
|
#
|
||||||
|
# kitty's active_border_color and hyprlock's focus ring are also the accent
|
||||||
|
# role, not just the scheme -- see accent_hex() below, which mirrors the same
|
||||||
|
# lookup hypr/looks.lua and scripts/panama-lock carry for the same reason.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
scheme="${1:-dark}"
|
scheme="${1:-dark}"
|
||||||
case "$scheme" in
|
case "$scheme" in
|
||||||
dark|light) ;;
|
dark|light) ;;
|
||||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
*) printf 'usage: panama-theme-apps [dark|light] [accent]\n' >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Falls back to blue for an unknown or omitted name, matching Theme.qml's own
|
||||||
|
# accentPair fallback -- so a caller that only ever knew about scheme (a stale
|
||||||
|
# ColorScheme.qml, a manual invocation) still gets a real accent instead of an
|
||||||
|
# empty string reaching sed.
|
||||||
|
accent="${2:-blue}"
|
||||||
|
case "$accent" in
|
||||||
|
blue|orchid|teal|green|amber|orange|rose|slate) ;;
|
||||||
|
*) accent=blue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
|
||||||
|
# hand -- there is no shared source between QML and a shell script), primary
|
||||||
|
# hue per scheme only: both consumers below draw a flat colour, not the
|
||||||
|
# two-stop gradient the window border does.
|
||||||
|
accent_hex() {
|
||||||
|
local name="$1" scheme="$2"
|
||||||
|
case "$name" in
|
||||||
|
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
|
||||||
|
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
|
||||||
|
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
|
||||||
|
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
|
||||||
|
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
|
||||||
|
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
|
||||||
|
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
|
||||||
|
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# hyprlock wants "R, G, B" decimal, not hex.
|
||||||
|
hex_to_rgb() {
|
||||||
|
local hex="$1"
|
||||||
|
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
accent_border_hex="$(accent_hex "$accent" "$scheme")"
|
||||||
|
|
||||||
|
# ── hyprlock ─────────────────────────────────────────────────────────────────
|
||||||
|
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
|
||||||
|
# hyprlock`), so it reads this file each time and needs no restart.
|
||||||
|
#
|
||||||
|
# It takes rgba(r, g, b, a) in DECIMAL, not hex, so the palette is expressed as
|
||||||
|
# "R, G, B" triples here rather than the hex used everywhere else. The two
|
||||||
|
# _HEX values are the exception: they sit inside Pango markup, where hyprlock
|
||||||
|
# wants ##rrggbb.
|
||||||
|
lock_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
|
||||||
|
lock_template="$lock_dir/hyprlock.conf.template"
|
||||||
|
|
||||||
|
if [[ "$scheme" == "light" ]]; then
|
||||||
|
lock_fg="55, 96, 191" # #3760bf
|
||||||
|
lock_muted="97, 114, 176" # #6172b0
|
||||||
|
lock_error="245, 42, 101" # #f52a65
|
||||||
|
lock_bg="225, 226, 231" # #e1e2e7
|
||||||
|
lock_field="208, 213, 227" # #d0d5e3
|
||||||
|
lock_muted_hex="6172b0"
|
||||||
|
lock_error_hex="f52a65"
|
||||||
|
else
|
||||||
|
lock_fg="200, 211, 245" # #c8d3f5
|
||||||
|
lock_muted="130, 139, 184" # #828bb8
|
||||||
|
lock_error="255, 117, 127" # #ff757f
|
||||||
|
lock_bg="34, 36, 54" # #222436
|
||||||
|
lock_field="46, 47, 61" # #2e2f3d
|
||||||
|
lock_muted_hex="828bb8"
|
||||||
|
lock_error_hex="ff757f"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The focus ring is the accent role, not a scheme-relative one -- follows
|
||||||
|
# accentName the same way scripts/panama-lock's own generator does.
|
||||||
|
lock_accent="$(hex_to_rgb "$accent_border_hex")"
|
||||||
|
|
||||||
|
status_hyprlock="skipped"
|
||||||
|
if [[ -r "$lock_template" ]]; then
|
||||||
|
# Written atomically: a lock triggered mid-write would otherwise read a
|
||||||
|
# truncated config and fall back to hyprlock's own defaults, which is a
|
||||||
|
# bright grey screen with none of this desktop's identity.
|
||||||
|
if sed -e "s/@FG@/$lock_fg/g" \
|
||||||
|
-e "s/@MUTED@/$lock_muted/g" \
|
||||||
|
-e "s/@ACCENT@/$lock_accent/g" \
|
||||||
|
-e "s/@ERROR@/$lock_error/g" \
|
||||||
|
-e "s/@BG@/$lock_bg/g" \
|
||||||
|
-e "s/@FIELD@/$lock_field/g" \
|
||||||
|
-e "s/@MUTED_HEX@/$lock_muted_hex/g" \
|
||||||
|
-e "s/@ERROR_HEX@/$lock_error_hex/g" \
|
||||||
|
"$lock_template" >"$lock_dir/hyprlock.conf.tmp" 2>/dev/null \
|
||||||
|
&& mv "$lock_dir/hyprlock.conf.tmp" "$lock_dir/hyprlock.conf" 2>/dev/null; then
|
||||||
|
status_hyprlock="written"
|
||||||
|
else
|
||||||
|
rm -f "$lock_dir/hyprlock.conf.tmp"
|
||||||
|
status_hyprlock="failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ── tmux ─────────────────────────────────────────────────────────────────────
|
# ── tmux ─────────────────────────────────────────────────────────────────────
|
||||||
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
|
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
|
||||||
# machine state rather than configuration. Running servers are re-sourced so an
|
# machine state rather than configuration. Running servers are re-sourced so an
|
||||||
@@ -116,9 +211,16 @@ theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
|||||||
status_kitty="skipped"
|
status_kitty="skipped"
|
||||||
if [[ -r "$theme_file" ]]; then
|
if [[ -r "$theme_file" ]]; then
|
||||||
# Written atomically: kitty may read this while a new window is starting.
|
# Written atomically: kitty may read this while a new window is starting.
|
||||||
|
# The accent override is appended after the copied theme rather than
|
||||||
|
# edited into the tracked theme file: kitty applies settings top to
|
||||||
|
# bottom, so a later active_border_color line wins, and this file is
|
||||||
|
# itself generated -- the tracked per-scheme theme stays scheme-only.
|
||||||
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
|
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
|
||||||
|
&& printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \
|
||||||
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
|
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
|
||||||
status_kitty="written"
|
status_kitty="written"
|
||||||
|
else
|
||||||
|
rm -f "$kitty_dir/current-theme.conf.tmp"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Live-apply to running terminals. kitty appends its PID to the socket name
|
# Live-apply to running terminals. kitty appends its PID to the socket name
|
||||||
@@ -129,7 +231,8 @@ if [[ -r "$theme_file" ]]; then
|
|||||||
applied=0
|
applied=0
|
||||||
while read -r socket; do
|
while read -r socket; do
|
||||||
[[ -n "$socket" ]] || continue
|
[[ -n "$socket" ]] || continue
|
||||||
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
|
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1 \
|
||||||
|
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then
|
||||||
applied=$((applied + 1))
|
applied=$((applied + 1))
|
||||||
fi
|
fi
|
||||||
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
|
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
|
||||||
@@ -146,4 +249,5 @@ jq -cn \
|
|||||||
--arg gtk "$status_gtk" \
|
--arg gtk "$status_gtk" \
|
||||||
--arg btop "$status_btop" \
|
--arg btop "$status_btop" \
|
||||||
--arg tmux "$status_tmux" \
|
--arg tmux "$status_tmux" \
|
||||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
|
--arg hyprlock "$status_hyprlock" \
|
||||||
|
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
|
||||||
|
|||||||
@@ -26,6 +26,16 @@
|
|||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
|
# The payload file (created in cmd_qr) is tracked at script scope so it can be
|
||||||
|
# removed no matter how the script exits -- success, an emit_error exit 0, or a
|
||||||
|
# signal -- rather than only on a clean function return.
|
||||||
|
payload_file=""
|
||||||
|
|
||||||
|
cleanup_payload() {
|
||||||
|
[[ -n $payload_file ]] && rm -f -- "$payload_file"
|
||||||
|
}
|
||||||
|
trap cleanup_payload EXIT
|
||||||
|
|
||||||
emit_error() {
|
emit_error() {
|
||||||
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -35,22 +45,33 @@ command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
|
|||||||
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
|
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
|
||||||
|
|
||||||
cmd_list() {
|
cmd_list() {
|
||||||
local rows=() name ssid psk
|
local rows=() name type ssid psk
|
||||||
while IFS= read -r name; do
|
while IFS= read -r name; do
|
||||||
[[ -n "$name" ]] || continue
|
[[ -n "$name" ]] || continue
|
||||||
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
|
||||||
|
# nmcli's terse mode backslash-escapes ':' and '\' WITHIN a field so a
|
||||||
|
# combined NAME,TYPE line stays splittable -- but a plain awk -F:
|
||||||
|
# doesn't know that, so a name containing either character (e.g.
|
||||||
|
# "Cafe: Guest") gets split in the wrong place and TYPE no longer
|
||||||
|
# lines up, silently dropping the connection from this list. Querying
|
||||||
|
# one field at a time with escaping turned off (-e no) sidesteps the
|
||||||
|
# problem entirely: there's nothing to split, so each value comes
|
||||||
|
# back exactly as stored.
|
||||||
|
type="$(nmcli -e no -g connection.type connection show "$name" 2>/dev/null)"
|
||||||
|
[[ "$type" == "802-11-wireless" ]] || continue
|
||||||
|
|
||||||
|
ssid="$(nmcli -e no -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||||
[[ -n "$ssid" ]] || ssid="$name"
|
[[ -n "$ssid" ]] || ssid="$name"
|
||||||
|
|
||||||
# Only networks whose passphrase this user can actually read are
|
# Only networks whose passphrase this user can actually read are
|
||||||
# shareable. An enterprise network has no passphrase to share at all,
|
# shareable. An enterprise network has no passphrase to share at all,
|
||||||
# and a QR code for one would simply not work.
|
# and a QR code for one would simply not work.
|
||||||
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
|
psk="$(nmcli -e no -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
|
||||||
|
|
||||||
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
|
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
|
||||||
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
|
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
|
||||||
'{name: $name, ssid: $ssid, shareable: $shareable}')")
|
'{name: $name, ssid: $ssid, shareable: $shareable}')")
|
||||||
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
|
done < <(nmcli -e no -t -f NAME connection show 2>/dev/null)
|
||||||
| awk -F: '$2 == "802-11-wireless" { print $1 }')
|
|
||||||
|
|
||||||
if [[ ${#rows[@]} -eq 0 ]]; then
|
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||||
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
|
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
|
||||||
@@ -77,11 +98,16 @@ cmd_qr() {
|
|||||||
local name="${1:-}"
|
local name="${1:-}"
|
||||||
[[ -n "$name" ]] || emit_error 'no network named'
|
[[ -n "$name" ]] || emit_error 'no network named'
|
||||||
|
|
||||||
local ssid hidden psk_file payload_file out_dir out_file
|
local ssid hidden psk_file out_dir out_file
|
||||||
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
# -e no here too: $name is the literal connection name (list emits it
|
||||||
|
# un-escaped -- see cmd_list), and nmcli's terse escaping is a one-way
|
||||||
|
# transform on VALUES, not something connection-show lookups expect on
|
||||||
|
# their NAME argument. Escaping $ssid/$psk here would feed escape_field
|
||||||
|
# an already-escaped value below and double-escape it.
|
||||||
|
ssid="$(nmcli -e no -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||||
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
|
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
|
||||||
|
|
||||||
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
|
hidden="$(nmcli -e no -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
|
||||||
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
|
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
|
||||||
|
|
||||||
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
|
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
|
||||||
@@ -96,13 +122,12 @@ cmd_qr() {
|
|||||||
# Built in a file rather than a variable that could be echoed, and piped to
|
# Built in a file rather than a variable that could be echoed, and piped to
|
||||||
# qrencode on stdin so the passphrase never appears in argv.
|
# qrencode on stdin so the passphrase never appears in argv.
|
||||||
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
|
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
|
||||||
trap 'rm -f "$payload_file"' RETURN
|
|
||||||
|
|
||||||
{
|
{
|
||||||
printf 'WIFI:T:WPA;S:'
|
printf 'WIFI:T:WPA;S:'
|
||||||
printf '%s' "$ssid" | escape_field
|
printf '%s' "$ssid" | escape_field
|
||||||
printf ';P:'
|
printf ';P:'
|
||||||
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
|
nmcli -e no -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
|
||||||
printf ';H:%s;;' "$hidden"
|
printf ';H:%s;;' "$hidden"
|
||||||
} >"$payload_file"
|
} >"$payload_file"
|
||||||
|
|
||||||
|
|||||||
@@ -25,25 +25,14 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property string cursorTheme: ""
|
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0
|
readonly property bool busy: runner.running || root.pending.length > 0
|
||||||
|
|
||||||
|
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||||
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
||||||
readonly property real textScale: DesktopPreferences.get("textScale")
|
readonly property real textScale: DesktopPreferences.get("textScale")
|
||||||
|
|
||||||
Process {
|
|
||||||
id: themeQuery
|
|
||||||
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
// gsettings quotes strings: 'oreo_blue_cursors'
|
|
||||||
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A short queue, because applying one setting takes several commands and
|
// A short queue, because applying one setting takes several commands and
|
||||||
// Process runs one at a time.
|
// Process runs one at a time.
|
||||||
property var pending: []
|
property var pending: []
|
||||||
@@ -71,7 +60,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
themeQuery.running = true;
|
|
||||||
settle.restart();
|
settle.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +90,6 @@ Singleton {
|
|||||||
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
||||||
];
|
];
|
||||||
// setcursor needs a theme name; skip it rather than guess if gsettings
|
|
||||||
// has not answered yet. The next change will catch up.
|
|
||||||
if (root.cursorTheme !== "")
|
|
||||||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||||
root.enqueue(commands);
|
root.enqueue(commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import Quickshell
|
|||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
|
import "AudioStreams.js" as AudioStreams
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
@@ -18,6 +20,13 @@ Singleton {
|
|||||||
!node.isStream
|
!node.isStream
|
||||||
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
|
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
|
||||||
|
|
||||||
|
readonly property var playbackStreams: Pipewire.nodes.values.filter(node =>
|
||||||
|
node.ready && node.audio
|
||||||
|
&& (node.type & PwNodeType.AudioOutStream) === PwNodeType.AudioOutStream)
|
||||||
|
|
||||||
|
readonly property var applications: AudioStreams.group(
|
||||||
|
root.playbackStreams, PwNodeType.AudioOutStream)
|
||||||
|
|
||||||
function nodes(output: bool): var {
|
function nodes(output: bool): var {
|
||||||
return output ? root.outputs : root.inputs;
|
return output ? root.outputs : root.inputs;
|
||||||
}
|
}
|
||||||
@@ -40,4 +49,20 @@ Singleton {
|
|||||||
return "Unknown device";
|
return "Unknown device";
|
||||||
return node.description || node.nickname || node.name || "Unknown device";
|
return node.description || node.nickname || node.name || "Unknown device";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applicationVolume(application: var): real {
|
||||||
|
return AudioStreams.volume(application);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applicationMuted(application: var): bool {
|
||||||
|
return AudioStreams.muted(application);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApplicationVolume(application: var, value: real): bool {
|
||||||
|
return AudioStreams.setVolume(application, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApplicationMuted(application: var, muted: bool): bool {
|
||||||
|
return AudioStreams.setMuted(application, muted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
function property(node, key) {
|
||||||
|
const value = node && node.properties ? node.properties[key] : "";
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupKey(node) {
|
||||||
|
return property(node, "application.id")
|
||||||
|
|| property(node, "application.process.binary")
|
||||||
|
|| property(node, "application.name")
|
||||||
|
|| `node:${node.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(node) {
|
||||||
|
return property(node, "application.name")
|
||||||
|
|| String(node.description || "").trim()
|
||||||
|
|| property(node, "media.name")
|
||||||
|
|| "Unknown application";
|
||||||
|
}
|
||||||
|
|
||||||
|
function icon(node) {
|
||||||
|
return property(node, "application.icon_name")
|
||||||
|
|| "audio-x-generic-symbolic";
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(nodes, audioOutStreamFlag) {
|
||||||
|
const groups = [];
|
||||||
|
const byKey = {};
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
if (!node || node.ready !== true || !node.audio
|
||||||
|
|| (node.type & audioOutStreamFlag) !== audioOutStreamFlag)
|
||||||
|
continue;
|
||||||
|
const key = groupKey(node);
|
||||||
|
if (!byKey[key]) {
|
||||||
|
byKey[key] = { key, label: label(node), icon: icon(node), nodes: [] };
|
||||||
|
groups.push(byKey[key]);
|
||||||
|
}
|
||||||
|
byKey[key].nodes.push(node);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioNodes(application) {
|
||||||
|
return (application && application.nodes || []).filter(node => node && node.audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
function volume(application) {
|
||||||
|
const nodes = audioNodes(application);
|
||||||
|
return nodes.length === 0 ? 0
|
||||||
|
: nodes.reduce((sum, node) => sum + node.audio.volume, 0) / nodes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function muted(application) {
|
||||||
|
const nodes = audioNodes(application);
|
||||||
|
return nodes.length > 0 && nodes.every(node => node.audio.muted === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(application, value) {
|
||||||
|
const next = Math.max(0, Math.min(1, Number(value)));
|
||||||
|
if (!Number.isFinite(next)) return false;
|
||||||
|
const nodes = audioNodes(application);
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.audio.muted = false;
|
||||||
|
node.audio.volume = next;
|
||||||
|
}
|
||||||
|
return nodes.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMuted(application, mutedValue) {
|
||||||
|
const nodes = audioNodes(application);
|
||||||
|
for (const node of nodes) node.audio.muted = mutedValue === true;
|
||||||
|
return nodes.length > 0;
|
||||||
|
}
|
||||||
@@ -138,6 +138,9 @@ Singleton {
|
|||||||
Process {
|
Process {
|
||||||
id: writer
|
id: writer
|
||||||
onExited: {
|
onExited: {
|
||||||
|
reader.exited = false;
|
||||||
|
reader.streamFinished = false;
|
||||||
|
reader.settled = false;
|
||||||
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
||||||
reader.running = true;
|
reader.running = true;
|
||||||
}
|
}
|
||||||
@@ -145,9 +148,36 @@ Singleton {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: reader
|
id: reader
|
||||||
|
|
||||||
|
property string outputText: ""
|
||||||
|
property bool exited: false
|
||||||
|
property bool streamFinished: false
|
||||||
|
property bool settled: false
|
||||||
|
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
const actual = parseInt(this.text.trim());
|
reader.outputText = this.text;
|
||||||
|
reader.streamFinished = true;
|
||||||
|
root.settleReader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `running` can still read true at the moment onStreamFinished fires --
|
||||||
|
// the same exited/streamFinished ordering hazard HomeAssistantConfig.qml
|
||||||
|
// guards against -- so pump() must not be re-entered from here directly.
|
||||||
|
// Settle on whichever of exited/streamFinished arrives last instead.
|
||||||
|
onExited: (code, status) => {
|
||||||
|
reader.exited = true;
|
||||||
|
root.settleReader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleReader(): void {
|
||||||
|
if (reader.settled || !reader.exited || !reader.streamFinished)
|
||||||
|
return;
|
||||||
|
reader.settled = true;
|
||||||
|
|
||||||
|
const actual = parseInt(reader.outputText.trim());
|
||||||
if (!isNaN(actual)) {
|
if (!isNaN(actual)) {
|
||||||
root.displays = root.displays.map(display =>
|
root.displays = root.displays.map(display =>
|
||||||
display.bus === root.writingBus
|
display.bus === root.writingBus
|
||||||
@@ -159,5 +189,3 @@ Singleton {
|
|||||||
root.pump();
|
root.pump();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ Singleton {
|
|||||||
property int fixtureNow: 0
|
property int fixtureNow: 0
|
||||||
property bool watchWanted: false
|
property bool watchWanted: false
|
||||||
|
|
||||||
|
// Counts watch attempts that exit without ever emitting a snapshot, so a
|
||||||
|
// helper that is missing or crashes on launch (e.g. no evolution-data-server)
|
||||||
|
// is reported instead of retried forever in silence.
|
||||||
|
property int consecutiveWatchFailures: 0
|
||||||
|
property bool watchProducedSnapshot: false
|
||||||
|
readonly property int maxConsecutiveWatchFailures: 3
|
||||||
|
|
||||||
readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000)
|
readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000)
|
||||||
readonly property var nextEvent: {
|
readonly property var nextEvent: {
|
||||||
const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch);
|
const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch);
|
||||||
@@ -135,14 +142,20 @@ Singleton {
|
|||||||
function _restartWatch(): void {
|
function _restartWatch(): void {
|
||||||
if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart)
|
if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart)
|
||||||
return;
|
return;
|
||||||
|
// Once the helper has been declared unavailable, don't flash back to
|
||||||
|
// "loading" on every retry -- only a real snapshot should clear it.
|
||||||
|
if (root.phase !== "unavailable")
|
||||||
root.phase = root.events.length > 0 ? root.phase : "loading";
|
root.phase = root.events.length > 0 ? root.phase : "loading";
|
||||||
root.watchWanted = false;
|
root.watchWanted = false;
|
||||||
|
root.watchProducedSnapshot = false;
|
||||||
startTimer.restart();
|
startTimer.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function consumeSnapshot(data: string): void {
|
function consumeSnapshot(data: string): void {
|
||||||
if (root.fixtureMode || data.trim() === "")
|
if (root.fixtureMode || data.trim() === "")
|
||||||
return;
|
return;
|
||||||
|
root.watchProducedSnapshot = true;
|
||||||
|
root.consecutiveWatchFailures = 0;
|
||||||
try {
|
try {
|
||||||
const snapshot = JSON.parse(data);
|
const snapshot = JSON.parse(data);
|
||||||
if (snapshot.ok !== true) {
|
if (snapshot.ok !== true) {
|
||||||
@@ -263,6 +276,7 @@ Singleton {
|
|||||||
root.errors = [];
|
root.errors = [];
|
||||||
root.selectedDate = new Date();
|
root.selectedDate = new Date();
|
||||||
root.phase = "loading";
|
root.phase = "loading";
|
||||||
|
root.consecutiveWatchFailures = 0;
|
||||||
root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
|
root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
|
||||||
root._restartWatch();
|
root._restartWatch();
|
||||||
}
|
}
|
||||||
@@ -275,6 +289,16 @@ Singleton {
|
|||||||
onRead: data => root.consumeSnapshot(data)
|
onRead: data => root.consumeSnapshot(data)
|
||||||
}
|
}
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
|
if (!root.watchProducedSnapshot) {
|
||||||
|
root.consecutiveWatchFailures += 1;
|
||||||
|
// The helper died before ever emitting a snapshot, repeatedly --
|
||||||
|
// stop pretending this is still loading and surface the same
|
||||||
|
// "unavailable" state probe()/collect_snapshot() report.
|
||||||
|
if (root.consecutiveWatchFailures >= root.maxConsecutiveWatchFailures) {
|
||||||
|
root.phase = "unavailable";
|
||||||
|
root.errors = [{ code: "eds-unavailable" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
if (root.watchWanted && !root.fixtureMode)
|
if (root.watchWanted && !root.fixtureMode)
|
||||||
restartTimer.restart();
|
restartTimer.restart();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,20 +402,35 @@ esac'
|
|||||||
id: recProc
|
id: recProc
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
recTimer.stop();
|
recTimer.stop();
|
||||||
|
const path = root.recordingPath;
|
||||||
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
|
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
|
||||||
// user pressed stop and the file was finalised normally.
|
// user pressed stop and the file was finalised normally. Any other
|
||||||
if (root.recordingPath !== "") {
|
// code means wf-recorder died or errored before finalising, so the
|
||||||
|
// file may be missing or truncated -- never report success then.
|
||||||
|
const clean = code === 2 || code === 130;
|
||||||
|
if (path !== "") {
|
||||||
|
if (clean) {
|
||||||
StatusEvents.publish({
|
StatusEvents.publish({
|
||||||
key: "capture-recording",
|
key: "capture-recording",
|
||||||
glyph: "\u{F044A}",
|
glyph: "\u{F044A}",
|
||||||
title: "Screen recording saved",
|
title: "Screen recording saved",
|
||||||
detail: root.recordingPath.split("/").pop(),
|
detail: path.split("/").pop(),
|
||||||
tone: "ok",
|
tone: "ok",
|
||||||
priority: StatusEvents.importantPriority,
|
priority: StatusEvents.importantPriority,
|
||||||
actionId: "open-path",
|
actionId: "open-path",
|
||||||
actionData: root.recordingPath
|
actionData: path
|
||||||
});
|
});
|
||||||
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
|
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", path]);
|
||||||
|
} else {
|
||||||
|
StatusEvents.publish({
|
||||||
|
key: "capture-recording",
|
||||||
|
glyph: "\u{F044A}",
|
||||||
|
title: "Screen recording failed",
|
||||||
|
detail: "wf-recorder exited unexpectedly (code " + code + ")",
|
||||||
|
tone: "warn",
|
||||||
|
priority: StatusEvents.importantPriority
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
root.recordingPath = "";
|
root.recordingPath = "";
|
||||||
root.recordingSeconds = 0;
|
root.recordingSeconds = 0;
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ Singleton {
|
|||||||
// installed. Distinct from an empty history.
|
// installed. Distinct from an empty history.
|
||||||
property bool available: true
|
property bool available: true
|
||||||
|
|
||||||
|
// query's `exited` and its stdout `streamFinished` are not guaranteed to
|
||||||
|
// fire in a particular order (the same signal-ordering hazard
|
||||||
|
// HomeAssistantConfig.qml's settle-both pattern guards against). These
|
||||||
|
// track which of the two have arrived for the query currently in flight
|
||||||
|
// so the result is only finalized once both are known -- otherwise a
|
||||||
|
// failed query's empty stdout could be parsed as "empty history" before
|
||||||
|
// the non-zero exit code is seen, or vice versa.
|
||||||
|
property bool _queryExited: false
|
||||||
|
property int _queryExitCode: 0
|
||||||
|
property bool _queryStdoutDone: false
|
||||||
|
property string _queryStdoutText: ""
|
||||||
|
|
||||||
// Wall-clock seconds at the moment `entries` was filled. Relative times are
|
// Wall-clock seconds at the moment `entries` was filled. Relative times are
|
||||||
// rendered against this rather than against a live clock, so a row's label
|
// rendered against this rather than against a live clock, so a row's label
|
||||||
// cannot change while the user is reading it and nothing has to tick.
|
// cannot change while the user is reading it and nothing has to tick.
|
||||||
@@ -69,6 +81,8 @@ Singleton {
|
|||||||
if (query.running)
|
if (query.running)
|
||||||
return;
|
return;
|
||||||
root.loading = true;
|
root.loading = true;
|
||||||
|
root._queryExited = false;
|
||||||
|
root._queryStdoutDone = false;
|
||||||
query.running = true;
|
query.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,16 +160,18 @@ Singleton {
|
|||||||
command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql]
|
command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql]
|
||||||
|
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: root._parse(this.text)
|
onStreamFinished: {
|
||||||
|
root._queryStdoutDone = true;
|
||||||
|
root._queryStdoutText = this.text;
|
||||||
|
root._settleQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onExited: exitCode => {
|
onExited: exitCode => {
|
||||||
root.loading = false;
|
root.loading = false;
|
||||||
if (exitCode !== 0) {
|
root._queryExited = true;
|
||||||
root.available = false;
|
root._queryExitCode = exitCode;
|
||||||
root.entries = [];
|
root._settleQuery();
|
||||||
root.refreshed();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +185,29 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called from both query.onExited and its stdout streamFinished. Only
|
||||||
|
// finalizes once both signals have arrived, since their firing order is
|
||||||
|
// not guaranteed -- see the _queryExited / _queryStdoutDone comment
|
||||||
|
// above. A non-zero exit always means the history is unavailable,
|
||||||
|
// regardless of what (if anything) stdout produced; only a clean exit
|
||||||
|
// reaches _parse, where empty stdout is legitimately "no history yet".
|
||||||
|
function _settleQuery(): void {
|
||||||
|
if (!root._queryExited || !root._queryStdoutDone)
|
||||||
|
return;
|
||||||
|
const exitCode = root._queryExitCode;
|
||||||
|
const text = root._queryStdoutText;
|
||||||
|
root._queryExited = false;
|
||||||
|
root._queryStdoutDone = false;
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.available = false;
|
||||||
|
root.entries = [];
|
||||||
|
root.refreshed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root._parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
function _parse(text: string): void {
|
function _parse(text: string): void {
|
||||||
root.available = true;
|
root.available = true;
|
||||||
root.queriedAt = Date.now() / 1000;
|
root.queriedAt = Date.now() / 1000;
|
||||||
|
|||||||
@@ -24,8 +24,33 @@ Singleton {
|
|||||||
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||||
|
|
||||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||||
|
// A neutral contrast role, not an accent. The focused Prism border belongs
|
||||||
|
// to the visual theme and must remain untouched when this role changes.
|
||||||
|
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
||||||
|
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
||||||
|
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
|
||||||
|
|
||||||
|
// The focused border follows the chosen accent. `ee` is the shipped alpha
|
||||||
|
// for the Prism gradient; Theme owns which colours, this owns the form the
|
||||||
|
// compositor wants them in.
|
||||||
|
function hyprColor(value: var): string {
|
||||||
|
// Qt gives "#rrggbb" -- or "#aarrggbb" if the colour ever carries an
|
||||||
|
// alpha channel. slice(-6) keeps the trailing rrggbb either way;
|
||||||
|
// slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
|
||||||
|
// call it RGB. Hyprland wants rgba(rrggbbaa).
|
||||||
|
return "rgba(" + String(value).replace("#", "").slice(-6) + "ee)";
|
||||||
|
}
|
||||||
|
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
|
||||||
|
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
|
// What the last push actually sent, so apply() can tell an accent-only
|
||||||
|
// change apart from a scheme change and skip the steps that do not depend
|
||||||
|
// on whichever did not move. Their starting values do not matter: the
|
||||||
|
// first apply() always runs with force set, which ignores both.
|
||||||
|
property bool appliedDark: false
|
||||||
|
property string appliedAccentName: ""
|
||||||
|
|
||||||
// Applied one command at a time: Process runs a single command, and several
|
// Applied one command at a time: Process runs a single command, and several
|
||||||
// of these are separate programs.
|
// of these are separate programs.
|
||||||
property var pending: []
|
property var pending: []
|
||||||
@@ -60,7 +85,7 @@ Singleton {
|
|||||||
Timer {
|
Timer {
|
||||||
id: settle
|
id: settle
|
||||||
interval: 1200
|
interval: 1200
|
||||||
onTriggered: root.apply()
|
onTriggered: root.apply(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
@@ -71,12 +96,30 @@ Singleton {
|
|||||||
Timer {
|
Timer {
|
||||||
id: coalesce
|
id: coalesce
|
||||||
interval: 250
|
interval: 250
|
||||||
onTriggered: root.apply()
|
onTriggered: root.apply(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(): void {
|
// `force` pushes every step regardless of what moved -- startup needs
|
||||||
|
// that, because gsettings and the compositor keep their own state and
|
||||||
|
// have no way to know a previous Quickshell session already told them the
|
||||||
|
// answer. Everywhere else this reacts to ANY preference changing (see
|
||||||
|
// onRevisionChanged above), so most calls have nothing to do with either
|
||||||
|
// scheme or accent; only the steps whose inputs actually moved since the
|
||||||
|
// last push run, which keeps sampling accent swatches from also rewriting
|
||||||
|
// gsettings and re-running the whole app-theming script on every sample.
|
||||||
|
function apply(force: bool): void {
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
|
|
||||||
|
const accentName = DesktopPreferences.get("accentName") || "blue";
|
||||||
|
const schemeChanged = force || root.dark !== root.appliedDark;
|
||||||
|
const accentChanged = force || accentName !== root.appliedAccentName;
|
||||||
|
|
||||||
|
if (!schemeChanged && !accentChanged)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const commands = [];
|
||||||
|
|
||||||
|
if (schemeChanged) {
|
||||||
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
||||||
|
|
||||||
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||||
@@ -94,23 +137,51 @@ Singleton {
|
|||||||
// because the failure mode is silent in exactly this way.
|
// because the failure mode is silent in exactly this way.
|
||||||
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
|
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
|
||||||
|
|
||||||
const commands = [
|
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
|
||||||
];
|
|
||||||
|
|
||||||
// Unfocused window borders. The focused border is the prism gradient and
|
// Unfocused window borders need scheme-relative contrast, and stay
|
||||||
// is already scheme-independent; the inactive one is a flat neutral that
|
// this service's to own. The FOCUSED border below is the accent
|
||||||
// would be invisible against the opposite background.
|
// role and belongs to the theme -- see modules/settings/README.md.
|
||||||
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
|
// Unlike that role, this one does not depend on the accent, so an
|
||||||
|
// accent-only change never needs to restate it.
|
||||||
commands.push(["hyprctl", "eval",
|
commands.push(["hyprctl", "eval",
|
||||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||||
|
}
|
||||||
|
|
||||||
// Applications that predate org.freedesktop.appearance and carry their
|
if (schemeChanged || accentChanged) {
|
||||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
// Each accent carries a separate pair for light and dark, so this
|
||||||
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
|
// runs on either kind of change: a scheme flip restates the same
|
||||||
// write above and needs nothing here.
|
// accent's other pair, and an accent change restates the same
|
||||||
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
|
// scheme's other colours.
|
||||||
|
//
|
||||||
|
// A two-stop gradient at the shipped angle. Written as a Lua TABLE:
|
||||||
|
// the string form of a gradient carries only one stop, and passing
|
||||||
|
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently
|
||||||
|
// keeps the previous value. Multi-stop must be the table form --
|
||||||
|
// built by the same serialiseValue() SystemSettings uses for every
|
||||||
|
// other gradient, rather than a second hand-rolled copy of that
|
||||||
|
// escaping here.
|
||||||
|
const activeBorder = SystemSettings.serialiseValue({
|
||||||
|
colors: [root.accentBorderStart, root.accentBorderEnd],
|
||||||
|
angle: 115
|
||||||
|
});
|
||||||
|
commands.push(["hyprctl", "eval",
|
||||||
|
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
|
||||||
|
|
||||||
|
// Applications that predate org.freedesktop.appearance and carry
|
||||||
|
// their own palettes -- terminals, chiefly. Everything that reads
|
||||||
|
// the portal (GTK4, Qt6, Chromium, Electron) is already handled by
|
||||||
|
// the gsettings write above and needs nothing here. The accent is
|
||||||
|
// passed through too: kitty's border and the hyprlock fallback
|
||||||
|
// template are also the accent role, not just the scheme -- so
|
||||||
|
// this still has to run on an accent-only change, even though the
|
||||||
|
// scheme-relative work it also does is then redundant.
|
||||||
|
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.appliedDark = root.dark;
|
||||||
|
root.appliedAccentName = accentName;
|
||||||
|
|
||||||
root.enqueue(commands);
|
root.enqueue(commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,4 +146,11 @@ Singleton {
|
|||||||
onWifiDeviceChanged: root.syncScanners()
|
onWifiDeviceChanged: root.syncScanners()
|
||||||
onWifiEnabledChanged: root.syncScanners()
|
onWifiEnabledChanged: root.syncScanners()
|
||||||
onAdapterChanged: root.syncScanners()
|
onAdapterChanged: root.syncScanners()
|
||||||
|
|
||||||
|
// adapter.enabled has no property on root to bind onXChanged to, so it
|
||||||
|
// needs its own Connections -- the Bluetooth equivalent of onWifiEnabledChanged.
|
||||||
|
Connections {
|
||||||
|
target: root.adapter
|
||||||
|
function onEnabledChanged(): void { root.syncScanners(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,5 +98,16 @@ Singleton {
|
|||||||
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addAutostart(desktopId: string): void {
|
||||||
|
if (root.busy)
|
||||||
|
return;
|
||||||
|
if (!root.knownDesktopId(desktopId)) {
|
||||||
|
root.lastError = "Choose an installed application.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
mutationProcess.exec([root.helper, "add-autostart", desktopId]);
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: root.refresh()
|
Component.onCompleted: root.refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Application-facing desktop style.
|
||||||
|
//
|
||||||
|
// Panama owns the durable choices; gsettings is an output boundary for GTK
|
||||||
|
// and applications that follow GNOME's desktop schemas. Commands are arrays,
|
||||||
|
// values are validated before storage, and no user text is ever sent through a
|
||||||
|
// shell. Hyprland's pointer setting stays live through Accessibility.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style"
|
||||||
|
|
||||||
|
// SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
|
||||||
|
// a separate allow-list so a caller cannot smuggle a display label into a
|
||||||
|
// stored theme name.
|
||||||
|
property var cursorThemes: []
|
||||||
|
property var iconThemes: []
|
||||||
|
property var cursorThemeNames: []
|
||||||
|
property var iconThemeNames: []
|
||||||
|
property bool catalogLoaded: false
|
||||||
|
property bool scanning: false
|
||||||
|
property bool startupApplied: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
property var pending: []
|
||||||
|
readonly property bool busy: root.scanning || catalogProcess.running
|
||||||
|
|| runner.running || root.pending.length > 0
|
||||||
|
readonly property int preferenceRevision: DesktopPreferences.revision
|
||||||
|
|
||||||
|
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||||
|
readonly property string iconTheme: DesktopPreferences.get("iconTheme")
|
||||||
|
readonly property string applicationFont: DesktopPreferences.get("applicationFont")
|
||||||
|
readonly property string documentFont: DesktopPreferences.get("documentFont")
|
||||||
|
readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: catalogProcess
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.acceptCatalog(this.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.scanning = false;
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.catalogLoaded = false;
|
||||||
|
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: runner
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "One desktop style setting could not be applied.";
|
||||||
|
root.drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.ensureStarted();
|
||||||
|
// Accessing the singleton here keeps its existing hyprctl setcursor
|
||||||
|
// path alive for cursor-theme changes as well as cursor-size changes.
|
||||||
|
Accessibility.applyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: startupApply
|
||||||
|
interval: 1200
|
||||||
|
onTriggered: {
|
||||||
|
root.startupApplied = true;
|
||||||
|
root.applyAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: DesktopPreferences
|
||||||
|
function onRevisionChanged(): void { applyCoalesce.restart(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: applyCoalesce
|
||||||
|
interval: 180
|
||||||
|
onTriggered: root.applyAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStarted(): void {
|
||||||
|
if (!root.catalogLoaded && !root.scanning)
|
||||||
|
root.refreshCatalog();
|
||||||
|
if (!root.startupApplied && !startupApply.running)
|
||||||
|
startupApply.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCatalog(): void {
|
||||||
|
if (catalogProcess.running)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
catalogProcess.exec([root.helperPath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptCatalog(text: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
|
||||||
|
throw new Error("invalid catalog shape");
|
||||||
|
|
||||||
|
const cursors = parsed.cursorThemes.filter(name =>
|
||||||
|
typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
|
||||||
|
const icons = parsed.iconThemes.filter(name =>
|
||||||
|
typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
|
||||||
|
root.cursorThemeNames = cursors;
|
||||||
|
root.iconThemeNames = icons;
|
||||||
|
root.cursorThemes = cursors.map(name => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
detail: "Pointer theme"
|
||||||
|
}));
|
||||||
|
root.iconThemes = icons.map(name => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
detail: "Application icon theme"
|
||||||
|
}));
|
||||||
|
root.catalogLoaded = true;
|
||||||
|
root.lastError = "";
|
||||||
|
} catch (error) {
|
||||||
|
root.cursorThemes = [];
|
||||||
|
root.iconThemes = [];
|
||||||
|
root.cursorThemeNames = [];
|
||||||
|
root.iconThemeNames = [];
|
||||||
|
root.catalogLoaded = false;
|
||||||
|
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||||
|
}
|
||||||
|
root.scanning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain(): void {
|
||||||
|
if (runner.running || root.pending.length === 0)
|
||||||
|
return;
|
||||||
|
const next = root.pending[0];
|
||||||
|
root.pending = root.pending.slice(1);
|
||||||
|
runner.exec(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(commands: var): void {
|
||||||
|
// A fresh revision supersedes commands that have not started yet. The
|
||||||
|
// currently running command is allowed to finish, then the newest full
|
||||||
|
// state is replayed in a deterministic order.
|
||||||
|
root.pending = commands;
|
||||||
|
root.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
|
||||||
|
// quote, backslash, and control character, and the schema patterns further
|
||||||
|
// constrain stored font/theme names. Arguments still travel directly to
|
||||||
|
// gsettings rather than through a shell.
|
||||||
|
function gvariant(value: var): string {
|
||||||
|
if (typeof value === "boolean")
|
||||||
|
return value ? "true" : "false";
|
||||||
|
if (typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
return JSON.stringify(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontName(familyKey: string, sizeKey: string): string {
|
||||||
|
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonLayout(): string {
|
||||||
|
const side = DesktopPreferences.get("titlebarButtonSide");
|
||||||
|
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
|
||||||
|
|
||||||
|
// Tokens are fixed. Only their side and whether maximize is present
|
||||||
|
// vary, so preference data can never become command syntax.
|
||||||
|
if (side === "left")
|
||||||
|
return (maximize ? "close,maximize" : "close") + ":appmenu";
|
||||||
|
return "appmenu:" + (maximize ? "maximize,close" : "close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setting(schema: string, key: string, value: var): var {
|
||||||
|
return ["gsettings", "set", schema, key, root.gvariant(value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAll(): void {
|
||||||
|
root.lastError = "";
|
||||||
|
root.enqueue([
|
||||||
|
root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
|
||||||
|
root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-name",
|
||||||
|
root.fontName("applicationFont", "applicationFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "document-font-name",
|
||||||
|
root.fontName("documentFont", "documentFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "monospace-font-name",
|
||||||
|
root.fontName("monospaceFont", "monospaceFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-hinting",
|
||||||
|
DesktopPreferences.get("fontHinting")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-antialiasing",
|
||||||
|
DesktopPreferences.get("fontAntialiasing")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
|
||||||
|
DesktopPreferences.get("middleClickPaste")),
|
||||||
|
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||||
|
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
||||||
|
DesktopPreferences.get("titlebarDoubleClick"))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
|
||||||
|
if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
|
||||||
|
root.lastError = `That ${kind} theme is not installed.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!DesktopPreferences.set(key, value)) {
|
||||||
|
root.lastError = `That ${kind} theme name could not be saved.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCursorTheme(value: string): bool {
|
||||||
|
return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIconTheme(value: string): bool {
|
||||||
|
return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeFont(key: string, family: string, allowed: var, kind: string): bool {
|
||||||
|
if (allowed.indexOf(family) < 0) {
|
||||||
|
root.lastError = `That ${kind} font is not installed.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!DesktopPreferences.set(key, family)) {
|
||||||
|
root.lastError = `That ${kind} font name could not be saved.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApplicationFont(family: string): bool {
|
||||||
|
return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDocumentFont(family: string): bool {
|
||||||
|
return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMonospaceFont(family: string): bool {
|
||||||
|
return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
function logicalSize(record) {
|
||||||
|
if (!record)
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
|
||||||
|
const width = Number(record.width);
|
||||||
|
const height = Number(record.height);
|
||||||
|
const scale = Number(record.scale);
|
||||||
|
const transform = Number(record.transform);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height)
|
||||||
|
|| !Number.isFinite(scale) || scale <= 0)
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
|
||||||
|
const rotated = transform === 1 || transform === 3;
|
||||||
|
return {
|
||||||
|
width: (rotated ? height : width) / scale,
|
||||||
|
height: (rotated ? width : height) / scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCoordinate(value) {
|
||||||
|
return Number.isFinite(value) && Number.isInteger(value)
|
||||||
|
&& value >= -100000 && value <= 100000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(layout) {
|
||||||
|
if (!Array.isArray(layout) || layout.length === 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const names = {};
|
||||||
|
let primaryCount = 0;
|
||||||
|
for (const record of layout) {
|
||||||
|
if (!record || typeof record.name !== "string"
|
||||||
|
|| !/^[A-Za-z0-9_.-]+$/.test(record.name)
|
||||||
|
|| names[record.name])
|
||||||
|
return false;
|
||||||
|
names[record.name] = true;
|
||||||
|
|
||||||
|
if (!Number.isFinite(record.width) || record.width <= 0
|
||||||
|
|| !Number.isFinite(record.height) || record.height <= 0
|
||||||
|
|| !Number.isFinite(record.scale) || record.scale <= 0
|
||||||
|
|| !Number.isInteger(record.transform)
|
||||||
|
|| record.transform < 0 || record.transform > 3
|
||||||
|
|| !validCoordinate(record.x) || !validCoordinate(record.y)
|
||||||
|
|| typeof record.primary !== "boolean")
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const size = logicalSize(record);
|
||||||
|
if (!Number.isFinite(size.width) || size.width <= 0
|
||||||
|
|| !Number.isFinite(size.height) || size.height <= 0)
|
||||||
|
return false;
|
||||||
|
if (record.primary)
|
||||||
|
primaryCount += 1;
|
||||||
|
}
|
||||||
|
return primaryCount === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneLayout(layout) {
|
||||||
|
return (layout || []).map(record => Object.assign({}, record));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(layout) {
|
||||||
|
const result = cloneLayout(layout);
|
||||||
|
const primary = result.find(record => record.primary === true);
|
||||||
|
if (!primary)
|
||||||
|
return result;
|
||||||
|
const anchorX = primary.x;
|
||||||
|
const anchorY = primary.y;
|
||||||
|
for (const record of result) {
|
||||||
|
record.x -= anchorX;
|
||||||
|
record.y -= anchorY;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bounds(layout) {
|
||||||
|
if (!Array.isArray(layout) || layout.length === 0)
|
||||||
|
return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
|
let left = Infinity;
|
||||||
|
let top = Infinity;
|
||||||
|
let right = -Infinity;
|
||||||
|
let bottom = -Infinity;
|
||||||
|
for (const record of layout) {
|
||||||
|
const size = logicalSize(record);
|
||||||
|
left = Math.min(left, record.x);
|
||||||
|
top = Math.min(top, record.y);
|
||||||
|
right = Math.max(right, record.x + size.width);
|
||||||
|
bottom = Math.max(bottom, record.y + size.height);
|
||||||
|
}
|
||||||
|
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap(layout, movingName, threshold) {
|
||||||
|
const result = cloneLayout(layout);
|
||||||
|
const moving = result.find(record => record.name === movingName);
|
||||||
|
if (!moving)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16;
|
||||||
|
const movingSize = logicalSize(moving);
|
||||||
|
const movingXEdges = [moving.x, moving.x + movingSize.width];
|
||||||
|
const movingYEdges = [moving.y, moving.y + movingSize.height];
|
||||||
|
const stationary = result
|
||||||
|
.filter(record => record.name !== movingName)
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
|
let bestX = null;
|
||||||
|
let bestY = null;
|
||||||
|
for (const record of stationary) {
|
||||||
|
const size = logicalSize(record);
|
||||||
|
const xEdges = [record.x, record.x + size.width];
|
||||||
|
const yEdges = [record.y, record.y + size.height];
|
||||||
|
for (const source of movingXEdges) {
|
||||||
|
for (const target of xEdges) {
|
||||||
|
const delta = target - source;
|
||||||
|
const distance = Math.abs(delta);
|
||||||
|
if (distance <= limit && (bestX === null || distance < bestX.distance))
|
||||||
|
bestX = { delta, distance };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const source of movingYEdges) {
|
||||||
|
for (const target of yEdges) {
|
||||||
|
const delta = target - source;
|
||||||
|
const distance = Math.abs(delta);
|
||||||
|
if (distance <= limit && (bestY === null || distance < bestY.distance))
|
||||||
|
bestY = { delta, distance };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestX !== null)
|
||||||
|
moving.x += bestX.delta;
|
||||||
|
if (bestY !== null)
|
||||||
|
moving.y += bestY.delta;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasRects(layout, canvasWidth, canvasHeight, padding) {
|
||||||
|
const desktopBounds = bounds(layout);
|
||||||
|
const inset = Math.max(0, Number(padding) || 0);
|
||||||
|
const availableWidth = Math.max(0, Number(canvasWidth) - inset * 2);
|
||||||
|
const availableHeight = Math.max(0, Number(canvasHeight) - inset * 2);
|
||||||
|
const scale = desktopBounds.width > 0 && desktopBounds.height > 0
|
||||||
|
? Math.min(availableWidth / desktopBounds.width,
|
||||||
|
availableHeight / desktopBounds.height)
|
||||||
|
: 0;
|
||||||
|
const contentWidth = desktopBounds.width * scale;
|
||||||
|
const contentHeight = desktopBounds.height * scale;
|
||||||
|
const originX = inset + (availableWidth - contentWidth) / 2;
|
||||||
|
const originY = inset + (availableHeight - contentHeight) / 2;
|
||||||
|
return {
|
||||||
|
bounds: desktopBounds,
|
||||||
|
scale,
|
||||||
|
rects: (layout || []).map(record => {
|
||||||
|
const size = logicalSize(record);
|
||||||
|
return {
|
||||||
|
name: record.name,
|
||||||
|
x: originX + (record.x - desktopBounds.x) * scale,
|
||||||
|
y: originY + (record.y - desktopBounds.y) * scale,
|
||||||
|
width: size.width * scale,
|
||||||
|
height: size.height * scale,
|
||||||
|
primary: record.primary === true
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import Quickshell
|
|||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import "DisplayLayout.js" as DisplayLayout
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
@@ -31,25 +32,25 @@ Singleton {
|
|||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
// Set while a change is applied but not yet confirmed.
|
// Set while a change is applied but not yet confirmed.
|
||||||
property string pendingOutput: ""
|
property var pendingPreviousLayout: null
|
||||||
property var pendingPrevious: null
|
property var pendingRequestedLayout: null
|
||||||
property var pendingRequested: null
|
|
||||||
property bool pendingVerified: false
|
property bool pendingVerified: false
|
||||||
property bool revertQueued: false
|
property bool revertQueued: false
|
||||||
property var revertExpected: null
|
property var revertExpectedLayout: null
|
||||||
property string revertReason: ""
|
property string revertReason: ""
|
||||||
property bool revertVerificationActive: false
|
property bool revertVerificationActive: false
|
||||||
property int operationGeneration: 0
|
property int operationGeneration: 0
|
||||||
property int revertGeneration: -1
|
property int revertGeneration: -1
|
||||||
property bool externalChangeBlocked: false
|
property bool externalChangeBlocked: false
|
||||||
property int secondsLeft: 0
|
property int secondsLeft: 0
|
||||||
|
property bool identifying: false
|
||||||
|
|
||||||
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
|
||||||
readonly property bool canConfirm: root.awaitingConfirmation
|
readonly property bool canConfirm: root.awaitingConfirmation
|
||||||
&& root.pendingVerified
|
&& root.pendingVerified
|
||||||
&& !root.busy
|
&& !root.busy
|
||||||
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
||||||
|| root.revertExpected !== null
|
|| root.revertExpectedLayout !== null
|
||||||
|
|
||||||
readonly property int confirmSeconds: 15
|
readonly property int confirmSeconds: 15
|
||||||
|
|
||||||
@@ -94,7 +95,6 @@ Singleton {
|
|||||||
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
|
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
verifyTimer.attempts = 0;
|
|
||||||
verifyTimer.ticks = 0;
|
verifyTimer.ticks = 0;
|
||||||
verifyTimer.restart();
|
verifyTimer.restart();
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,6 @@ Singleton {
|
|||||||
// Exit status is advisory only. Hyprland's Lua bridge can report
|
// Exit status is advisory only. Hyprland's Lua bridge can report
|
||||||
// success without applying a value, so exact readback decides.
|
// success without applying a value, so exact readback decides.
|
||||||
root.revertVerificationActive = true;
|
root.revertVerificationActive = true;
|
||||||
revertVerifyTimer.attempts = 0;
|
|
||||||
revertVerifyTimer.ticks = 0;
|
revertVerifyTimer.ticks = 0;
|
||||||
revertVerifyTimer.restart();
|
revertVerifyTimer.restart();
|
||||||
}
|
}
|
||||||
@@ -123,9 +122,24 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function identify(): void {
|
||||||
|
root.identifying = true;
|
||||||
|
identifyTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
function parse(text: string, generation: int): void {
|
function parse(text: string, generation: int): void {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(text);
|
const raw = JSON.parse(text);
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
const persisted = stored && typeof stored === "object" ? stored : {};
|
||||||
|
const persistedPrimaries = raw.filter(monitor => {
|
||||||
|
const entry = persisted[monitor.name ?? ""];
|
||||||
|
return root.isPersistedLayoutEntry(entry) && entry.primary === true;
|
||||||
|
});
|
||||||
|
const origin = raw.find(monitor => monitor.x === 0 && monitor.y === 0);
|
||||||
|
const primaryName = persistedPrimaries.length === 1
|
||||||
|
? persistedPrimaries[0].name
|
||||||
|
: (origin?.name ?? raw[0]?.name ?? "");
|
||||||
root.monitors = raw.map(monitor => {
|
root.monitors = raw.map(monitor => {
|
||||||
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
||||||
const width = monitor.width ?? 0;
|
const width = monitor.width ?? 0;
|
||||||
@@ -144,31 +158,35 @@ Singleton {
|
|||||||
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
||||||
scale: monitor.scale ?? 1,
|
scale: monitor.scale ?? 1,
|
||||||
transform: monitor.transform ?? 0,
|
transform: monitor.transform ?? 0,
|
||||||
|
x: Number.isInteger(monitor.x) ? monitor.x : 0,
|
||||||
|
y: Number.isInteger(monitor.y) ? monitor.y : 0,
|
||||||
|
primary: monitor.name === primaryName,
|
||||||
currentFormat: monitor.currentFormat ?? "",
|
currentFormat: monitor.currentFormat ?? "",
|
||||||
colorPreset: monitor.colorManagementPreset ?? "",
|
colorPreset: monitor.colorManagementPreset ?? "",
|
||||||
vrr: monitor.vrr === true,
|
vrr: monitor.vrr === true,
|
||||||
modes: modes
|
modes: modes
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (root.awaitingConfirmation && root.pendingRequested
|
if (root.awaitingConfirmation && root.pendingRequestedLayout
|
||||||
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
&& generation === root.operationGeneration
|
||||||
|
&& root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
|
||||||
root.pendingVerified = true;
|
root.pendingVerified = true;
|
||||||
verifyTimer.stop();
|
verifyTimer.stop();
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
} else if (root.revertVerificationActive
|
} else if (root.revertVerificationActive
|
||||||
&& generation === root.revertGeneration
|
&& generation === root.revertGeneration
|
||||||
&& root.revertExpected
|
&& root.revertExpectedLayout
|
||||||
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
&& root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
|
||||||
revertVerifyTimer.stop();
|
revertVerifyTimer.stop();
|
||||||
root.revertVerificationActive = false;
|
root.revertVerificationActive = false;
|
||||||
root.revertGeneration = -1;
|
root.revertGeneration = -1;
|
||||||
root.revertExpected = null;
|
root.revertExpectedLayout = null;
|
||||||
if (root.revertReason === "")
|
if (root.revertReason === "")
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
else
|
else
|
||||||
root.lastError = root.revertReason;
|
root.lastError = root.revertReason;
|
||||||
root.revertReason = "";
|
root.revertReason = "";
|
||||||
} else if (!root.awaitingConfirmation && !root.revertExpected && (
|
} else if (!root.awaitingConfirmation && !root.revertExpectedLayout && (
|
||||||
root.lastError === "Could not read the connected displays."
|
root.lastError === "Could not read the connected displays."
|
||||||
|| root.lastError === "The display list could not be read.")) {
|
|| root.lastError === "The display list could not be read.")) {
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
@@ -216,6 +234,17 @@ Singleton {
|
|||||||
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPersistedLayoutEntry(entry: var): bool {
|
||||||
|
return !!entry && typeof entry === "object"
|
||||||
|
&& root.modeParts(entry.mode) !== null
|
||||||
|
&& Number.isFinite(entry.scale) && entry.scale > 0
|
||||||
|
&& Number.isInteger(entry.transform)
|
||||||
|
&& entry.transform >= 0 && entry.transform <= 3
|
||||||
|
&& Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000
|
||||||
|
&& Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 100000
|
||||||
|
&& typeof entry.primary === "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
function modeParts(mode: string): var {
|
function modeParts(mode: string): var {
|
||||||
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||||
if (!match)
|
if (!match)
|
||||||
@@ -250,16 +279,41 @@ Singleton {
|
|||||||
choices[0]);
|
choices[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesRequest(monitor: var, requested: var): bool {
|
function currentLayout(): var {
|
||||||
if (!monitor || !requested || monitor.name !== requested.output)
|
return root.monitors.map(monitor => ({
|
||||||
|
name: monitor.name,
|
||||||
|
width: monitor.width,
|
||||||
|
height: monitor.height,
|
||||||
|
refreshRate: monitor.refreshRate,
|
||||||
|
mode: monitor.mode,
|
||||||
|
scale: monitor.scale,
|
||||||
|
transform: monitor.transform,
|
||||||
|
x: monitor.x,
|
||||||
|
y: monitor.y,
|
||||||
|
primary: monitor.primary === true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesLayout(monitors: var, layout: var): bool {
|
||||||
|
if (!Array.isArray(monitors) || !Array.isArray(layout)
|
||||||
|
|| monitors.length !== layout.length)
|
||||||
return false;
|
return false;
|
||||||
|
const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (let index = 0; index < expected.length; index++) {
|
||||||
|
const requested = expected[index];
|
||||||
|
const monitor = actual[index];
|
||||||
const parts = root.modeParts(requested.mode);
|
const parts = root.modeParts(requested.mode);
|
||||||
return !!parts
|
if (!parts || monitor.name !== requested.name
|
||||||
&& monitor.width === parts.width
|
|| monitor.width !== parts.width
|
||||||
&& monitor.height === parts.height
|
|| monitor.height !== parts.height
|
||||||
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
|| Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
|
||||||
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
|| Math.abs(monitor.scale - requested.scale) >= 0.001
|
||||||
&& monitor.transform === requested.transform;
|
|| monitor.transform !== requested.transform
|
||||||
|
|| monitor.x !== requested.x || monitor.y !== requested.y)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeIsCurrent(monitor: var, candidate: var): bool {
|
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||||
@@ -269,10 +323,48 @@ Singleton {
|
|||||||
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
function validRequestedLayout(layout: var): bool {
|
||||||
// settings file is only written by confirm().
|
if (!DisplayLayout.validate(layout) || layout.length !== root.monitors.length)
|
||||||
|
return false;
|
||||||
|
const currentNames = root.monitors.map(monitor => monitor.name).sort();
|
||||||
|
const requestedNames = layout.map(record => record.name).sort();
|
||||||
|
if (JSON.stringify(currentNames) !== JSON.stringify(requestedNames))
|
||||||
|
return false;
|
||||||
|
return layout.every(record => {
|
||||||
|
const monitor = root.monitorNamed(record.name);
|
||||||
|
const parts = root.modeParts(record.mode);
|
||||||
|
return !!monitor && !!parts
|
||||||
|
&& record.width === parts.width && record.height === parts.height
|
||||||
|
&& monitor.modes.some(candidate => candidate.mode === record.mode)
|
||||||
|
&& root.isScaleClean(record.mode, record.scale)
|
||||||
|
&& root.transforms.some(candidate => candidate.value === record.transform);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-field controls remain callers of the complete-layout transaction.
|
||||||
|
// Their edit is cloned into the current layout so every output's position
|
||||||
|
// participates in apply, verification, and rollback.
|
||||||
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||||
if (root.externalChangeBlocked) {
|
const layout = root.currentLayout();
|
||||||
|
const record = layout.find(candidate => candidate.name === output);
|
||||||
|
const parts = root.modeParts(mode);
|
||||||
|
if (!record || !parts) {
|
||||||
|
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
record.mode = mode;
|
||||||
|
record.width = parts.width;
|
||||||
|
record.height = parts.height;
|
||||||
|
record.refreshRate = parts.refresh;
|
||||||
|
record.scale = scale;
|
||||||
|
record.transform = transform;
|
||||||
|
return root.applyLayout(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||||
|
// complete connected layout is only written by confirm().
|
||||||
|
function applyLayout(layout: var, protectedOperation: bool): bool {
|
||||||
|
if (root.externalChangeBlocked && protectedOperation !== true) {
|
||||||
root.lastError = "Wait for Settings to finish restoring before changing a display.";
|
root.lastError = "Wait for Settings to finish restoring before changing a display.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -284,58 +376,53 @@ Singleton {
|
|||||||
root.lastError = "Finish the current display change first.";
|
root.lastError = "Finish the current display change first.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const monitor = root.monitorNamed(output);
|
const normalized = DisplayLayout.normalize(layout);
|
||||||
if (!monitor) {
|
if (!root.validRequestedLayout(normalized)) {
|
||||||
root.lastError = "That display is not connected.";
|
root.lastError = "That complete display layout is not valid for the connected displays.";
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
|
||||||
root.lastError = "That display does not offer that mode.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!root.isScaleClean(mode, scale)) {
|
|
||||||
root.lastError = "That scale does not divide this resolution cleanly.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
|
||||||
root.lastError = "That rotation is not one Panama offers.";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
root.pendingPrevious = {
|
root.pendingPreviousLayout = root.currentLayout();
|
||||||
output: output,
|
|
||||||
mode: monitor.mode,
|
|
||||||
scale: monitor.scale,
|
|
||||||
transform: monitor.transform
|
|
||||||
};
|
|
||||||
root.operationGeneration++;
|
root.operationGeneration++;
|
||||||
root.pendingRequested = {
|
root.pendingRequestedLayout = normalized;
|
||||||
output: output,
|
|
||||||
mode: mode,
|
|
||||||
scale: scale,
|
|
||||||
transform: transform
|
|
||||||
};
|
|
||||||
root.pendingOutput = output;
|
|
||||||
root.pendingVerified = false;
|
root.pendingVerified = false;
|
||||||
root.revertQueued = false;
|
root.revertQueued = false;
|
||||||
root.secondsLeft = root.confirmSeconds;
|
root.secondsLeft = root.confirmSeconds;
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
countdown.restart();
|
countdown.restart();
|
||||||
|
|
||||||
root.push(output, mode, scale, transform);
|
root.pushLayout(normalized, applyRun);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function push(output: string, mode: string, scale: real, transform: int): void {
|
// Settings restore holds the external-change lock while it proves a
|
||||||
// Values are validated above and the output name comes from the
|
// snapshot. This narrow entry point authorizes that one transaction while
|
||||||
// compositor's own list, so nothing user-authored reaches the payload.
|
// keeping every user-facing control blocked until restore settles.
|
||||||
applyRun.exec(["hyprctl", "eval",
|
function applyProtectedLayout(layout: var): bool {
|
||||||
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
return root.applyLayout(layout, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePrimary(output: string): bool {
|
||||||
|
const layout = root.currentLayout();
|
||||||
|
if (!layout.some(record => record.name === output)) {
|
||||||
|
root.lastError = "That display is not connected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const record of layout)
|
||||||
|
record.primary = record.name === output;
|
||||||
|
return root.applyLayout(DisplayLayout.normalize(layout));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLayout(layout: var, runner: var): void {
|
||||||
|
const payload = layout.map(record =>
|
||||||
|
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
|
||||||
|
).join("; ");
|
||||||
|
runner.exec(["hyprctl", "eval", payload]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirm(): bool {
|
function confirm(): bool {
|
||||||
if (!root.canConfirm || !root.matchesRequest(
|
if (!root.canConfirm
|
||||||
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
|| !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
|
||||||
if (root.awaitingConfirmation)
|
if (root.awaitingConfirmation)
|
||||||
root.lastError = "Wait for the display to finish applying before keeping it.";
|
root.lastError = "Wait for the display to finish applying before keeping it.";
|
||||||
return false;
|
return false;
|
||||||
@@ -343,11 +430,16 @@ Singleton {
|
|||||||
|
|
||||||
const stored = DesktopPreferences.get("displays");
|
const stored = DesktopPreferences.get("displays");
|
||||||
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||||
next[root.pendingOutput] = {
|
for (const record of root.pendingRequestedLayout) {
|
||||||
mode: root.pendingRequested.mode,
|
next[record.name] = {
|
||||||
scale: root.pendingRequested.scale,
|
mode: record.mode,
|
||||||
transform: root.pendingRequested.transform
|
scale: record.scale,
|
||||||
|
transform: record.transform,
|
||||||
|
x: record.x,
|
||||||
|
y: record.y,
|
||||||
|
primary: record.primary
|
||||||
};
|
};
|
||||||
|
}
|
||||||
if (!DesktopPreferences.set("displays", next)) {
|
if (!DesktopPreferences.set("displays", next)) {
|
||||||
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||||
return false;
|
return false;
|
||||||
@@ -361,9 +453,8 @@ Singleton {
|
|||||||
function clearPending(): void {
|
function clearPending(): void {
|
||||||
countdown.stop();
|
countdown.stop();
|
||||||
verifyTimer.stop();
|
verifyTimer.stop();
|
||||||
root.pendingOutput = "";
|
root.pendingPreviousLayout = null;
|
||||||
root.pendingPrevious = null;
|
root.pendingRequestedLayout = null;
|
||||||
root.pendingRequested = null;
|
|
||||||
root.pendingVerified = false;
|
root.pendingVerified = false;
|
||||||
root.revertQueued = false;
|
root.revertQueued = false;
|
||||||
root.secondsLeft = 0;
|
root.secondsLeft = 0;
|
||||||
@@ -390,16 +481,25 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function performRevert(): void {
|
function performRevert(): void {
|
||||||
const previous = root.pendingPrevious;
|
const connected = {};
|
||||||
|
for (const monitor of root.monitors)
|
||||||
|
connected[monitor.name] = true;
|
||||||
|
const previous = (root.pendingPreviousLayout || [])
|
||||||
|
.filter(record => connected[record.name])
|
||||||
|
.map(record => Object.assign({}, record));
|
||||||
|
if (previous.length > 0 && !previous.some(record => record.primary)) {
|
||||||
|
const origin = previous.find(record => record.x === 0 && record.y === 0);
|
||||||
|
(origin || previous[0]).primary = true;
|
||||||
|
}
|
||||||
root.operationGeneration++;
|
root.operationGeneration++;
|
||||||
root.revertGeneration = root.operationGeneration;
|
root.revertGeneration = root.operationGeneration;
|
||||||
root.revertExpected = previous;
|
root.revertExpectedLayout = previous.length > 0 ? previous : null;
|
||||||
root.revertVerificationActive = false;
|
root.revertVerificationActive = false;
|
||||||
root.clearPending();
|
root.clearPending();
|
||||||
if (previous) {
|
if (previous.length > 0)
|
||||||
revertRun.exec(["hyprctl", "eval",
|
root.pushLayout(previous, revertRun);
|
||||||
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
else
|
||||||
}
|
root.lastError = root.revertReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clears any stored override for an output so it returns to the value
|
// Clears any stored override for an output so it returns to the value
|
||||||
@@ -418,42 +518,53 @@ Singleton {
|
|||||||
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verificationTimedOut(): void {
|
||||||
|
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertVerificationTimedOut(): void {
|
||||||
|
revertVerifyTimer.stop();
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.revertGeneration = -1;
|
||||||
|
root.revertExpectedLayout = null;
|
||||||
|
root.revertReason = "";
|
||||||
|
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: identifyTimer
|
||||||
|
interval: 3000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.identifying = false
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: verifyTimer
|
id: verifyTimer
|
||||||
property int attempts: 0
|
|
||||||
property int ticks: 0
|
property int ticks: 0
|
||||||
interval: 120
|
interval: 120
|
||||||
repeat: true
|
repeat: true
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
ticks++;
|
ticks++;
|
||||||
if (ticks > 50) {
|
if (ticks > 50) {
|
||||||
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
root.verificationTimedOut();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (root.refresh())
|
root.refresh();
|
||||||
attempts++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: revertVerifyTimer
|
id: revertVerifyTimer
|
||||||
property int attempts: 0
|
|
||||||
property int ticks: 0
|
property int ticks: 0
|
||||||
interval: 120
|
interval: 120
|
||||||
repeat: true
|
repeat: true
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
ticks++;
|
ticks++;
|
||||||
if (ticks > 50) {
|
if (ticks > 50) {
|
||||||
stop();
|
root.revertVerificationTimedOut();
|
||||||
root.revertVerificationActive = false;
|
|
||||||
root.revertGeneration = -1;
|
|
||||||
root.revertExpected = null;
|
|
||||||
root.revertReason = "";
|
|
||||||
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (root.refresh())
|
root.refresh();
|
||||||
attempts++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ Singleton {
|
|||||||
root.searching = false;
|
root.searching = false;
|
||||||
if (exitCode !== 0)
|
if (exitCode !== 0)
|
||||||
root.lastError = "Could not reach the location service.";
|
root.lastError = "Could not reach the location service.";
|
||||||
|
|
||||||
|
// Typing kept going while this fetch was in flight -- rather than
|
||||||
|
// leaving the newer query stranded until another keystroke, go
|
||||||
|
// fetch it now. run() no-ops if pending is now too short.
|
||||||
|
if (root.pending !== root.lastQuery)
|
||||||
|
root.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,16 @@ Singleton {
|
|||||||
|
|
||||||
property string payload: ""
|
property string payload: ""
|
||||||
|
|
||||||
onStarted: copyProcess.write(copyProcess.payload)
|
stdinEnabled: true
|
||||||
|
onStarted: {
|
||||||
|
copyProcess.write(copyProcess.payload);
|
||||||
|
// wl-copy reads stdin until EOF before it exits; leaving the
|
||||||
|
// channel open (Process.write alone never closes it) would hang
|
||||||
|
// it forever waiting for more input. Disabling stdin closes the
|
||||||
|
// write side -- see HomeAssistantConfig.qml's writeProc for the
|
||||||
|
// same stdinEnabled pattern.
|
||||||
|
copyProcess.stdinEnabled = false;
|
||||||
|
}
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
root.lastCopyResult = exitCode === 0
|
root.lastCopyResult = exitCode === 0
|
||||||
? "Report copied."
|
? "Report copied."
|
||||||
@@ -339,6 +348,10 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
|
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
|
||||||
root.lastCopyResult = "";
|
root.lastCopyResult = "";
|
||||||
|
// Re-arm stdin: the previous run closed it (see copyProcess.onStarted)
|
||||||
|
// and a disabled channel stays closed even after being set back to
|
||||||
|
// true mid-run, so each new run needs it explicitly re-enabled.
|
||||||
|
copyProcess.stdinEnabled = true;
|
||||||
copyProcess.exec(["wl-copy"]);
|
copyProcess.exec(["wl-copy"]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ Singleton {
|
|||||||
property string actionKind: ""
|
property string actionKind: ""
|
||||||
property string actionPath: ""
|
property string actionPath: ""
|
||||||
|
|
||||||
|
// actionProc's `exited` and its stdout `streamFinished` are not guaranteed
|
||||||
|
// to fire in a particular order (same hazard HomeAssistantConfig.qml's
|
||||||
|
// settle-both pattern guards against). These track which of the two have
|
||||||
|
// been observed for the action currently in flight so finishAction() is
|
||||||
|
// only ever called once both have arrived, with the real stdout JSON as
|
||||||
|
// the authoritative result.
|
||||||
|
property bool actionExited: false
|
||||||
|
property bool actionStdoutDone: false
|
||||||
|
property string actionStdoutText: ""
|
||||||
|
|
||||||
readonly property var preferredPhone: {
|
readonly property var preferredPhone: {
|
||||||
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
|
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
|
||||||
return phones.find(device => device.reachable) ?? phones[0] ?? null;
|
return phones.find(device => device.reachable) ?? phones[0] ?? null;
|
||||||
@@ -66,6 +76,8 @@ Singleton {
|
|||||||
root.transferActive = true;
|
root.transferActive = true;
|
||||||
root.transferFileName = path.split("/").pop();
|
root.transferFileName = path.split("/").pop();
|
||||||
root.transferDeviceName = root.preferredPhone.name;
|
root.transferDeviceName = root.preferredPhone.name;
|
||||||
|
root.actionExited = false;
|
||||||
|
root.actionStdoutDone = false;
|
||||||
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
|
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
|
||||||
actionProc.running = true;
|
actionProc.running = true;
|
||||||
}
|
}
|
||||||
@@ -83,6 +95,8 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
root.actionKind = kind;
|
root.actionKind = kind;
|
||||||
root.actionPath = "";
|
root.actionPath = "";
|
||||||
|
root.actionExited = false;
|
||||||
|
root.actionStdoutDone = false;
|
||||||
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
|
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
|
||||||
actionProc.running = true;
|
actionProc.running = true;
|
||||||
}
|
}
|
||||||
@@ -95,6 +109,22 @@ Singleton {
|
|||||||
root.transferActive = false;
|
root.transferActive = false;
|
||||||
root.transferFileName = "";
|
root.transferFileName = "";
|
||||||
root.transferDeviceName = "";
|
root.transferDeviceName = "";
|
||||||
|
root.actionExited = false;
|
||||||
|
root.actionStdoutDone = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from both actionProc.onExited and its stdout streamFinished.
|
||||||
|
// Only finalizes once both signals have arrived for the in-flight action,
|
||||||
|
// since their firing order is not guaranteed -- see the actionExited /
|
||||||
|
// actionStdoutDone comment above.
|
||||||
|
function settleAction(): void {
|
||||||
|
if (root.actionKind === "")
|
||||||
|
return;
|
||||||
|
if (!root.actionExited || !root.actionStdoutDone)
|
||||||
|
return;
|
||||||
|
root.actionExited = false;
|
||||||
|
root.actionStdoutDone = false;
|
||||||
|
root.finishAction(root.actionStdoutText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishAction(text: string): void {
|
function finishAction(text: string): void {
|
||||||
@@ -198,11 +228,15 @@ Singleton {
|
|||||||
Process {
|
Process {
|
||||||
id: actionProc
|
id: actionProc
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: root.finishAction(this.text)
|
onStreamFinished: {
|
||||||
|
root.actionStdoutDone = true;
|
||||||
|
root.actionStdoutText = this.text;
|
||||||
|
root.settleAction();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (root.actionKind !== "")
|
root.actionExited = true;
|
||||||
root.finishAction("");
|
root.settleAction();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Generated lock-screen state. Visual preferences are coalesced into one
|
||||||
|
// helper invocation, while the last valid status remains visible if a helper
|
||||||
|
// response is malformed.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-lock"
|
||||||
|
|
||||||
|
property bool generated: false
|
||||||
|
property string path: ""
|
||||||
|
property bool fallback: true
|
||||||
|
property string lastError: ""
|
||||||
|
property string watchedSignature: ""
|
||||||
|
property bool regenerateAfterCurrent: false
|
||||||
|
|
||||||
|
readonly property bool busy: generateProcess.running || statusProcess.running
|
||||||
|
|
||||||
|
function preferenceSignature(): string {
|
||||||
|
DesktopPreferences.revision;
|
||||||
|
return JSON.stringify([
|
||||||
|
DesktopPreferences.get("lockBackgroundMode"),
|
||||||
|
DesktopPreferences.get("lockBlurLevel"),
|
||||||
|
DesktopPreferences.get("lockShowClock"),
|
||||||
|
DesktopPreferences.get("lockShowDate"),
|
||||||
|
DesktopPreferences.get("lockShowUser"),
|
||||||
|
DesktopPreferences.get("lockFadeOnEmpty"),
|
||||||
|
DesktopPreferences.get("use24Hour"),
|
||||||
|
DesktopPreferences.get("colorScheme"),
|
||||||
|
DesktopPreferences.get("wallpaperPath"),
|
||||||
|
DesktopPreferences.get("wallpaperMode"),
|
||||||
|
DesktopPreferences.get("wallpaperPerMonitor")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatus(text: string): void {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(text);
|
||||||
|
if (!state || typeof state.generated !== "boolean"
|
||||||
|
|| typeof state.path !== "string" || state.path.length === 0)
|
||||||
|
throw new Error("invalid lock status");
|
||||||
|
root.generated = state.generated;
|
||||||
|
root.path = state.path;
|
||||||
|
root.fallback = state.fallback === true;
|
||||||
|
root.lastError = typeof state.error === "string" ? state.error : "";
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "The lock-screen configuration could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!statusProcess.running)
|
||||||
|
statusProcess.exec([root.helperPath, "status"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerate(): void {
|
||||||
|
if (generateProcess.running) {
|
||||||
|
root.regenerateAfterCurrent = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
generateProcess.exec([root.helperPath, "generate"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusProcess
|
||||||
|
|
||||||
|
property bool parsed: false
|
||||||
|
|
||||||
|
onRunningChanged: {
|
||||||
|
if (running)
|
||||||
|
parsed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
statusProcess.parsed = true;
|
||||||
|
root.applyStatus(this.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0 && !statusProcess.parsed)
|
||||||
|
root.lastError = "The lock-screen configuration could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: generateProcess
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "The lock-screen configuration could not be generated.";
|
||||||
|
root.refresh();
|
||||||
|
if (root.regenerateAfterCurrent) {
|
||||||
|
root.regenerateAfterCurrent = false;
|
||||||
|
regenerateTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: DesktopPreferences
|
||||||
|
|
||||||
|
function onRevisionChanged(): void {
|
||||||
|
const signature = root.preferenceSignature();
|
||||||
|
if (signature === root.watchedSignature)
|
||||||
|
return;
|
||||||
|
root.watchedSignature = signature;
|
||||||
|
regenerateTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: regenerateTimer
|
||||||
|
interval: 250
|
||||||
|
onTriggered: root.regenerate()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.watchedSignature = root.preferenceSignature();
|
||||||
|
root.refresh();
|
||||||
|
regenerateTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ pragma Singleton
|
|||||||
// The freedesktop notification server, plus the two lists the UI renders:
|
// The freedesktop notification server, plus the two lists the UI renders:
|
||||||
//
|
//
|
||||||
// popups — what Toasts.qml is currently showing (transient, timed)
|
// popups — what Toasts.qml is currently showing (transient, timed)
|
||||||
// history — GNOME's message tray, what NotificationCenter.qml shows
|
// history — GNOME's message tray, what NotificationList.qml shows
|
||||||
//
|
//
|
||||||
// A notification lives exactly as long as `tracked` is true, so history holds
|
// A notification lives exactly as long as `tracked` is true, so history holds
|
||||||
// the *live* objects rather than copies: that keeps actions and inline replies
|
// the *live* objects rather than copies: that keeps actions and inline replies
|
||||||
@@ -80,6 +80,21 @@ Singleton {
|
|||||||
return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : "";
|
return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Freedesktop timeout resolution, shared by the toast countdown (Toast.qml)
|
||||||
|
// and the no-display expiry a transient notification gets while Do Not
|
||||||
|
// Disturb is on (below). Critical urgency and an explicit expireTimeout
|
||||||
|
// override policy; -1 ("server decides") falls back to it. 0 means "never
|
||||||
|
// auto-expire" per spec.
|
||||||
|
function notificationTimeoutMs(notification: var): int {
|
||||||
|
if (notification.urgency === NotificationUrgency.Critical)
|
||||||
|
return Settings.notificationTimeoutCriticalMs;
|
||||||
|
if (notification.expireTimeout === 0)
|
||||||
|
return 0;
|
||||||
|
if (notification.expireTimeout > 0)
|
||||||
|
return Math.round(notification.expireTimeout * 1000);
|
||||||
|
return Settings.notificationTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
readonly property bool hasNotifications: root.history.length > 0
|
readonly property bool hasNotifications: root.history.length > 0
|
||||||
|
|
||||||
function notificationAppId(notification: var): string {
|
function notificationAppId(notification: var): string {
|
||||||
@@ -156,7 +171,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// history grouped by app, in most-recent-app-first order — the shape
|
// history grouped by app, in most-recent-app-first order — the shape
|
||||||
// NotificationCenter.qml renders directly.
|
// NotificationList.qml renders directly.
|
||||||
readonly property var groups: {
|
readonly property var groups: {
|
||||||
const out = [];
|
const out = [];
|
||||||
const byApp = {};
|
const byApp = {};
|
||||||
@@ -219,8 +234,44 @@ Singleton {
|
|||||||
root.unreadCount += 1;
|
root.unreadCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!root.doNotDisturb)
|
if (!root.doNotDisturb) {
|
||||||
root.popups = [notification].concat(root.popups);
|
root.popups = [notification].concat(root.popups);
|
||||||
|
} else if (notification.transient) {
|
||||||
|
// Never shown, and (being transient) never filed in history
|
||||||
|
// either — nothing will otherwise dismiss() it, so schedule
|
||||||
|
// the same release its popup timeout would have given it.
|
||||||
|
root.scheduleTransientExpiry(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs a DND-hidden transient notification through the same lifetime it
|
||||||
|
// would have gotten as a visible popup (Toast.qml's countdown), just
|
||||||
|
// without ever showing it, so it still gets released instead of staying
|
||||||
|
// tracked forever.
|
||||||
|
function scheduleTransientExpiry(notification: var): void {
|
||||||
|
const ms = root.notificationTimeoutMs(notification);
|
||||||
|
if (ms <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const timer = Qt.createQmlObject("import QtQuick; Timer { repeat: false }", root);
|
||||||
|
timer.interval = ms;
|
||||||
|
timer.triggered.connect(() => {
|
||||||
|
timer.destroy();
|
||||||
|
root.releaseTransient(notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closed some other way first (app-side close, dismissAll()) — cancel
|
||||||
|
// the pending timer instead of firing a stale dismiss() later.
|
||||||
|
notification.closed.connect(() => timer.destroy());
|
||||||
|
timer.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient notifications are never filed in history, so nothing else
|
||||||
|
// holds a reference once their lifetime ends — release tracked state
|
||||||
|
// directly. Shared by the DND-hidden expiry above and dismissAll() below.
|
||||||
|
function releaseTransient(n: var): void {
|
||||||
|
if (n.transient)
|
||||||
|
n.dismiss();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mutation ────────────────────────────────────────────────────────────
|
// ── Mutation ────────────────────────────────────────────────────────────
|
||||||
@@ -243,10 +294,7 @@ Singleton {
|
|||||||
if (next.length === root.popups.length)
|
if (next.length === root.popups.length)
|
||||||
return;
|
return;
|
||||||
root.popups = next;
|
root.popups = next;
|
||||||
|
root.releaseTransient(n);
|
||||||
// Transients were never in history, so nothing else holds them.
|
|
||||||
if (n.transient)
|
|
||||||
n.dismiss();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismiss(n: Notification): void {
|
function dismiss(n: Notification): void {
|
||||||
@@ -257,10 +305,19 @@ Singleton {
|
|||||||
// Copy first: dismiss() re-enters through forget() and rewrites both
|
// Copy first: dismiss() re-enters through forget() and rewrites both
|
||||||
// lists while we iterate.
|
// lists while we iterate.
|
||||||
const all = root.history.slice();
|
const all = root.history.slice();
|
||||||
|
|
||||||
|
// Transient notifications are never filed in history — whether
|
||||||
|
// currently shown as a popup or hidden by Do Not Disturb with a
|
||||||
|
// pending scheduleTransientExpiry() timer — so releasing them needs
|
||||||
|
// its own pass over the server's full tracked set.
|
||||||
|
const transients = root.active.values.filter(n => n.transient);
|
||||||
|
|
||||||
root.history = [];
|
root.history = [];
|
||||||
root.popups = [];
|
root.popups = [];
|
||||||
for (const n of all)
|
for (const n of all)
|
||||||
n.dismiss();
|
n.dismiss();
|
||||||
|
for (const n of transients)
|
||||||
|
root.releaseTransient(n);
|
||||||
root.unreadCount = 0;
|
root.unreadCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,20 +41,28 @@ Singleton {
|
|||||||
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
||||||
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
||||||
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
||||||
|
property var readLiveDisplayLayout: function() { return Displays.currentLayout(); }
|
||||||
|
property var applyDisplayLayout: function(layout) { return Displays.applyProtectedLayout(layout); }
|
||||||
|
property var displayCanConfirm: function() { return Displays.canConfirm; }
|
||||||
|
property var confirmDisplayLayout: function() { return Displays.confirm(); }
|
||||||
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
||||||
|
property var applyIdle: function() { IdleLock.apply(); }
|
||||||
|
property var idleBusy: function() { return IdleLock.busy; }
|
||||||
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
||||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||||
property var systemBusy: function() { return SystemSettings.busy; }
|
property var systemBusy: function() { return SystemSettings.busy; }
|
||||||
property var currentWallpaper: function() {
|
property var applyWallpaperPolicy: function() { Wallpaper.applyCurrentPolicy(false); }
|
||||||
return String(DesktopPreferences.get("wallpaperPath") ?? "");
|
property var wallpaperBusy: function() { return Wallpaper.busy; }
|
||||||
}
|
property var regenerateLock: function() { LockScreen.regenerate(); }
|
||||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
property var lockBusy: function() { return LockScreen.busy; }
|
||||||
property var reloadShell: function() { Quickshell.reload(false); }
|
property var reloadShell: function() { Quickshell.reload(false); }
|
||||||
property var protectedDisplays: ({})
|
property var protectedDisplays: ({})
|
||||||
|
property var protectedDisplayLayout: []
|
||||||
|
property var pendingRestoredLayout: null
|
||||||
|
|
||||||
readonly property bool busy: listQuery.running || actionRun.running
|
readonly property bool busy: listQuery.running || actionRun.running
|
||||||
|| applyRestoredState.running || settleReload.running
|
|| settleDisplayRestore.running || applyRestoredState.running || settleReload.running
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: listQuery
|
id: listQuery
|
||||||
@@ -89,18 +97,21 @@ Singleton {
|
|||||||
if (actionRun.restoring) {
|
if (actionRun.restoring) {
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
root.protectedDisplays = ({});
|
root.protectedDisplays = ({});
|
||||||
|
root.protectedDisplayLayout = [];
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||||
if (actionRun.restoring) {
|
if (actionRun.restoring) {
|
||||||
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
|
const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
|
||||||
root.lastError = homeReloaded
|
if (restoreAccepted)
|
||||||
? ""
|
root.lastError = "";
|
||||||
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
else if (root.lastError === "")
|
||||||
if (!homeReloaded) {
|
root.lastError = "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||||
|
if (!restoreAccepted) {
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
root.protectedDisplays = ({});
|
root.protectedDisplays = ({});
|
||||||
|
root.protectedDisplayLayout = [];
|
||||||
}
|
}
|
||||||
} else
|
} else
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
@@ -108,6 +119,28 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: settleDisplayRestore
|
||||||
|
property int attempts: 0
|
||||||
|
interval: 100
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
attempts++;
|
||||||
|
if (root.displayCanConfirm()) {
|
||||||
|
stop();
|
||||||
|
if (!root.confirmDisplayLayout()) {
|
||||||
|
root.failDisplayRestore("The restored display layout could not be confirmed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.pendingRestoredLayout = null;
|
||||||
|
root.beginRestoredStateReplay();
|
||||||
|
} else if (!root.displayBusy() || attempts >= 180) {
|
||||||
|
stop();
|
||||||
|
root.failDisplayRestore("The restored display layout could not be verified.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: applyRestoredState
|
id: applyRestoredState
|
||||||
interval: 80
|
interval: 80
|
||||||
@@ -116,9 +149,11 @@ Singleton {
|
|||||||
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
||||||
// These services also own state outside QML and need an explicit
|
// These services also own state outside QML and need an explicit
|
||||||
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
||||||
root.applyCompositor();
|
root.applyIdle();
|
||||||
|
root.regenerateLock();
|
||||||
|
root.applyWallpaperPolicy();
|
||||||
root.reloadKeybinds();
|
root.reloadKeybinds();
|
||||||
root.applyWallpaper(root.currentWallpaper());
|
root.applyCompositor();
|
||||||
|
|
||||||
settleReload.attempts = 0;
|
settleReload.attempts = 0;
|
||||||
settleReload.restart();
|
settleReload.restart();
|
||||||
@@ -135,10 +170,12 @@ Singleton {
|
|||||||
// Let the current instances finish their external writes before a
|
// Let the current instances finish their external writes before a
|
||||||
// soft reload replaces them. The cap keeps a failed external tool
|
// soft reload replaces them. The cap keeps a failed external tool
|
||||||
// from leaving restored Home state stale indefinitely.
|
// from leaving restored Home state stale indefinitely.
|
||||||
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
if ((!root.idleBusy() && !root.keybindsReloading() && !root.systemBusy()
|
||||||
|
&& !root.wallpaperBusy() && !root.lockBusy()) || attempts >= 30) {
|
||||||
stop();
|
stop();
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
root.protectedDisplays = ({});
|
root.protectedDisplays = ({});
|
||||||
|
root.protectedDisplayLayout = [];
|
||||||
root.reloadShell();
|
root.reloadShell();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,11 +214,76 @@ Singleton {
|
|||||||
if (!root.reloadHomeState(text))
|
if (!root.reloadHomeState(text))
|
||||||
return false;
|
return false;
|
||||||
root.reloadDesktop();
|
root.reloadDesktop();
|
||||||
if (!root.protectDisplays(root.protectedDisplays))
|
const restoredLayout = root.layoutFromStoredDisplays(root.readDisplays());
|
||||||
return false;
|
if (restoredLayout === null || root.layoutsEqual(
|
||||||
applyRestoredState.restart();
|
restoredLayout, root.protectedDisplayLayout)) {
|
||||||
|
root.beginRestoredStateReplay();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
root.pendingRestoredLayout = restoredLayout;
|
||||||
|
if (!root.applyDisplayLayout(restoredLayout))
|
||||||
|
return root.failDisplayRestore("The restored display layout was rejected.");
|
||||||
|
settleDisplayRestore.attempts = 0;
|
||||||
|
settleDisplayRestore.restart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginRestoredStateReplay(): void {
|
||||||
|
applyRestoredState.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutFromStoredDisplays(stored: var): var {
|
||||||
|
if (!stored || typeof stored !== "object")
|
||||||
|
return null;
|
||||||
|
const current = root.readLiveDisplayLayout();
|
||||||
|
if (!Array.isArray(current) || current.length === 0)
|
||||||
|
return null;
|
||||||
|
const layout = [];
|
||||||
|
for (const live of current) {
|
||||||
|
const entry = stored[live.name];
|
||||||
|
const match = String(entry?.mode ?? "").match(
|
||||||
|
/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!entry || !match || !Number.isFinite(entry.scale) || entry.scale <= 0
|
||||||
|
|| !Number.isInteger(entry.transform)
|
||||||
|
|| entry.transform < 0 || entry.transform > 3
|
||||||
|
|| !Number.isInteger(entry.x) || !Number.isInteger(entry.y)
|
||||||
|
|| typeof entry.primary !== "boolean")
|
||||||
|
return null;
|
||||||
|
layout.push(Object.assign({}, live, {
|
||||||
|
width: Number(match[1]),
|
||||||
|
height: Number(match[2]),
|
||||||
|
refreshRate: Number(match[3]),
|
||||||
|
mode: entry.mode,
|
||||||
|
scale: entry.scale,
|
||||||
|
transform: entry.transform,
|
||||||
|
x: entry.x,
|
||||||
|
y: entry.y,
|
||||||
|
primary: entry.primary
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return layout.filter(record => record.primary).length === 1 ? layout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutsEqual(left: var, right: var): bool {
|
||||||
|
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
|
||||||
|
return false;
|
||||||
|
const fields = ["name", "mode", "scale", "transform", "x", "y", "primary"];
|
||||||
|
const a = Array.from(left).sort((x, y) => x.name.localeCompare(y.name));
|
||||||
|
const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name));
|
||||||
|
return a.every((record, index) => fields.every(
|
||||||
|
field => record[field] === b[index][field]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function failDisplayRestore(message: string): bool {
|
||||||
|
root.pendingRestoredLayout = null;
|
||||||
|
if (!root.protectDisplays(root.protectedDisplays))
|
||||||
|
message += " The original display preference also could not be restored.";
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
root.protectedDisplays = ({});
|
||||||
|
root.protectedDisplayLayout = [];
|
||||||
|
root.lastError = message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Restore output carries the canonical Home state. Reconstructing through
|
// Restore output carries the canonical Home state. Reconstructing through
|
||||||
// these methods keeps validation and persistence inside HomePreferences;
|
// these methods keeps validation and persistence inside HomePreferences;
|
||||||
@@ -250,6 +352,8 @@ Singleton {
|
|||||||
const currentDisplays = root.readDisplays();
|
const currentDisplays = root.readDisplays();
|
||||||
root.protectedDisplays = JSON.parse(JSON.stringify(
|
root.protectedDisplays = JSON.parse(JSON.stringify(
|
||||||
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||||
|
root.protectedDisplayLayout = JSON.parse(JSON.stringify(
|
||||||
|
root.readLiveDisplayLayout() ?? []));
|
||||||
root.setDisplayBlocked(true);
|
root.setDisplayBlocked(true);
|
||||||
actionRun.restoring = true;
|
actionRun.restoring = true;
|
||||||
actionRun.exec([root.helperPath, "restore", name]);
|
actionRun.exec([root.helperPath, "restore", name]);
|
||||||
|
|||||||
@@ -17,15 +17,25 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// SettingsSearch is part of the always-constructed sidebar. Touching the
|
||||||
|
// desktop-style service here gives application preferences their startup
|
||||||
|
// replay even when Appearance is not the page that opens first.
|
||||||
|
Component.onCompleted: DesktopStyle.ensureStarted()
|
||||||
|
|
||||||
// Which page shows the settings in a given schema group. A group with no
|
// Which page shows the settings in a given schema group. A group with no
|
||||||
// entry here still appears in results and routes to Home rather than being
|
// entry here still appears in results and routes to Home rather than being
|
||||||
// dropped, so adding a group can never make a setting unreachable.
|
// dropped, so adding a group can never make a setting unreachable.
|
||||||
readonly property var groupPages: ({
|
readonly property var groupPages: ({
|
||||||
|
"appearance": "appearance",
|
||||||
"clock": "appearance",
|
"clock": "appearance",
|
||||||
"vitals": "appearance",
|
"vitals": "appearance",
|
||||||
|
"typography": "appearance",
|
||||||
|
"themes": "appearance",
|
||||||
|
"titlebar": "appearance",
|
||||||
"windows": "appearance",
|
"windows": "appearance",
|
||||||
"effects": "appearance",
|
"effects": "appearance",
|
||||||
"wallpaper": "appearance",
|
"wallpaper": "appearance",
|
||||||
|
"lockAppearance": "appearance",
|
||||||
"dock": "desktop",
|
"dock": "desktop",
|
||||||
"focus": "desktop",
|
"focus": "desktop",
|
||||||
"display": "displays",
|
"display": "displays",
|
||||||
@@ -36,7 +46,10 @@ Singleton {
|
|||||||
"pointer": "mouse",
|
"pointer": "mouse",
|
||||||
"touchpad": "mouse",
|
"touchpad": "mouse",
|
||||||
"multitasking": "desktop",
|
"multitasking": "desktop",
|
||||||
"weather": "appearance",
|
"edges": "desktop",
|
||||||
|
"master": "desktop",
|
||||||
|
"notices": "desktop",
|
||||||
|
"weather": "home",
|
||||||
"notifications": "notifications",
|
"notifications": "notifications",
|
||||||
"capture": "screen-intelligence"
|
"capture": "screen-intelligence"
|
||||||
})
|
})
|
||||||
@@ -54,7 +67,13 @@ Singleton {
|
|||||||
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
||||||
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
|
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
|
||||||
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
|
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
|
||||||
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" }
|
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
|
||||||
|
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid colour", page: "appearance" },
|
||||||
|
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" },
|
||||||
|
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
|
||||||
|
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
|
||||||
|
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
|
||||||
|
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }
|
||||||
]
|
]
|
||||||
|
|
||||||
function pageFor(group: string): string {
|
function pageFor(group: string): string {
|
||||||
@@ -82,7 +101,8 @@ Singleton {
|
|||||||
for (const entry of PreferenceSchema.entries) {
|
for (const entry of PreferenceSchema.entries) {
|
||||||
if (entry.internal)
|
if (entry.internal)
|
||||||
continue;
|
continue;
|
||||||
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group}`.toLowerCase();
|
const optionLabels = (entry.options ?? []).map(option => option.label).join(" ");
|
||||||
|
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`.toLowerCase();
|
||||||
if (haystack.indexOf(needle) >= 0)
|
if (haystack.indexOf(needle) >= 0)
|
||||||
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
|
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
|
||||||
}
|
}
|
||||||
@@ -99,8 +119,16 @@ Singleton {
|
|||||||
|
|
||||||
// Exact prefix matches first: typing "blur" should put "Blur" above
|
// Exact prefix matches first: typing "blur" should put "Blur" above
|
||||||
// "Blur radius", and both above a setting that merely mentions blur in
|
// "Blur radius", and both above a setting that merely mentions blur in
|
||||||
// its explanation.
|
// its explanation. An exact enum option also leads: "slideshow" is a
|
||||||
|
// mode choice, so Wallpaper mode belongs above the interval row that
|
||||||
|
// merely explains it.
|
||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
|
const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label);
|
||||||
|
const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label);
|
||||||
|
const ao = (aSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
|
||||||
|
const bo = (bSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
|
||||||
|
if (ao !== bo)
|
||||||
|
return ao ? -1 : 1;
|
||||||
const al = a.label.toLowerCase();
|
const al = a.label.toLowerCase();
|
||||||
const bl = b.label.toLowerCase();
|
const bl = b.label.toLowerCase();
|
||||||
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
|
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
|
||||||
|
|||||||
@@ -35,13 +35,32 @@ Singleton {
|
|||||||
|
|
||||||
function setEventSounds(enabled: bool): void {
|
function setEventSounds(enabled: bool): void {
|
||||||
root.eventSounds = enabled;
|
root.eventSounds = enabled;
|
||||||
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)];
|
root._writeEventSounds();
|
||||||
eventWrite.running = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInputFeedback(enabled: bool): void {
|
function setInputFeedback(enabled: bool): void {
|
||||||
root.inputFeedback = enabled;
|
root.inputFeedback = enabled;
|
||||||
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)];
|
root._writeInputFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigning `running = true` to a Process that is already running is a
|
||||||
|
// no-op, not a queue -- so a toggle that lands mid-write would otherwise
|
||||||
|
// be dropped silently. eventWrite.onExited re-checks root.eventSounds
|
||||||
|
// against what was actually written and calls this again if they still
|
||||||
|
// disagree.
|
||||||
|
function _writeEventSounds(): void {
|
||||||
|
if (eventWrite.running)
|
||||||
|
return;
|
||||||
|
eventWrite.writtenValue = root.eventSounds;
|
||||||
|
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(root.eventSounds)];
|
||||||
|
eventWrite.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _writeInputFeedback(): void {
|
||||||
|
if (inputWrite.running)
|
||||||
|
return;
|
||||||
|
inputWrite.writtenValue = root.inputFeedback;
|
||||||
|
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(root.inputFeedback)];
|
||||||
inputWrite.running = true;
|
inputWrite.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +90,7 @@ Singleton {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: eventWrite
|
id: eventWrite
|
||||||
|
property bool writtenValue: true
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
root.lastError = "Event sound preferences could not be changed.";
|
root.lastError = "Event sound preferences could not be changed.";
|
||||||
@@ -78,11 +98,14 @@ Singleton {
|
|||||||
} else {
|
} else {
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
}
|
}
|
||||||
|
if (root.eventSounds !== eventWrite.writtenValue)
|
||||||
|
root._writeEventSounds();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: inputWrite
|
id: inputWrite
|
||||||
|
property bool writtenValue: false
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
root.lastError = "Input feedback preferences could not be changed.";
|
root.lastError = "Input feedback preferences could not be changed.";
|
||||||
@@ -90,6 +113,8 @@ Singleton {
|
|||||||
} else {
|
} else {
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
}
|
}
|
||||||
|
if (root.inputFeedback !== inputWrite.writtenValue)
|
||||||
|
root._writeInputFeedback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,25 @@ Singleton {
|
|||||||
list.running = true;
|
list.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A picker click while a change is still applying is queued rather than
|
||||||
|
// fired directly: assigning `running = true` to an already-running
|
||||||
|
// Process is a no-op, so a second set() here would otherwise be silently
|
||||||
|
// dropped and apply.onExited would then adopt the second value as if it
|
||||||
|
// had actually been applied. requestedValue always holds the latest
|
||||||
|
// request; apply.onExited re-fires with it once the in-flight apply
|
||||||
|
// settles, mirroring Brightness.qml's pending-write queue.
|
||||||
|
property string requestedValue: ""
|
||||||
|
|
||||||
function set(value: string): void {
|
function set(value: string): void {
|
||||||
if (value === root.current)
|
if (value === root.current)
|
||||||
return;
|
return;
|
||||||
|
root.requestedValue = value;
|
||||||
|
if (!apply.running)
|
||||||
|
root._applyValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyValue(value: string): void {
|
||||||
|
root.requestedValue = "";
|
||||||
apply.command = [root.helperPath, "set", value];
|
apply.command = [root.helperPath, "set", value];
|
||||||
apply.pendingValue = value;
|
apply.pendingValue = value;
|
||||||
apply.running = true;
|
apply.running = true;
|
||||||
@@ -94,6 +110,16 @@ Singleton {
|
|||||||
} else {
|
} else {
|
||||||
root.lastError = "The system did not accept that language. It may have needed a password.";
|
root.lastError = "The system did not accept that language. It may have needed a password.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A set() call that arrived while this apply was running only
|
||||||
|
// queued itself in requestedValue (see the comment above). Fire
|
||||||
|
// it now if it still names something other than what was just
|
||||||
|
// applied, so the UI never settles on a locale the system was
|
||||||
|
// never actually asked for.
|
||||||
|
if (root.requestedValue !== "" && root.requestedValue !== apply.pendingValue)
|
||||||
|
root._applyValue(root.requestedValue);
|
||||||
|
else
|
||||||
|
root.requestedValue = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ Singleton {
|
|||||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||||
|
property var regenerateLock: function() { LockScreen.regenerate(); }
|
||||||
|
property var lockBusy: function() { return LockScreen.busy; }
|
||||||
|
|
||||||
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||||
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
||||||
@@ -273,6 +275,15 @@ Singleton {
|
|||||||
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
||||||
// prefs.getInt so both sides agree.
|
// prefs.getInt so both sides agree.
|
||||||
function hyprValue(entry: var, value: var): var {
|
function hyprValue(entry: var, value: var): var {
|
||||||
|
// Some options are phrased as a negative by the compositor -- the four
|
||||||
|
// Hyprland notices are all `disable_x` -- while the setting reads as
|
||||||
|
// "show x", because a switch labelled "Disable splash text" that must
|
||||||
|
// be ON to hide something is a small cruelty. `invert` bridges the two,
|
||||||
|
// in exactly one place, so nothing downstream has to remember which
|
||||||
|
// options are backwards.
|
||||||
|
if (entry.hypr.invert === true && typeof value === "boolean")
|
||||||
|
value = !value;
|
||||||
|
|
||||||
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
||||||
return value ? 1 : 0;
|
return value ? 1 : 0;
|
||||||
return value;
|
return value;
|
||||||
@@ -300,6 +311,24 @@ Singleton {
|
|||||||
return value ? "true" : "false";
|
return value ? "true" : "false";
|
||||||
if (typeof value === "number")
|
if (typeof value === "number")
|
||||||
return String(value);
|
return String(value);
|
||||||
|
|
||||||
|
// A gradient is the one setting whose Lua form is not a scalar. The
|
||||||
|
// stubs declare it as `string|{colors:string[], angle?:number}`, and
|
||||||
|
// the string form only ever carries ONE stop -- writing
|
||||||
|
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
|
||||||
|
// the previous value, which is how a two-stop write looks like it
|
||||||
|
// worked and did nothing. Multi-stop must be the table form.
|
||||||
|
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
||||||
|
const stops = value.colors
|
||||||
|
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
|
||||||
|
.join(", ");
|
||||||
|
const angle = Number(value.angle);
|
||||||
|
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A vec2 reaches Lua as a two-element table.
|
||||||
|
if (Array.isArray(value) && value.length === 2)
|
||||||
|
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
|
||||||
// Strings only reach here after the schema's pattern check; quoting is
|
// Strings only reach here after the schema's pattern check; quoting is
|
||||||
// belt-and-braces rather than the primary defence.
|
// belt-and-braces rather than the primary defence.
|
||||||
return `"${String(value).replace(/["\\]/g, "")}"`;
|
return `"${String(value).replace(/["\\]/g, "")}"`;
|
||||||
@@ -309,7 +338,15 @@ Singleton {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
for (const name in node) {
|
for (const name in node) {
|
||||||
const child = node[name];
|
const child = node[name];
|
||||||
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`);
|
// A leaf arrives pre-serialised as a string; anything else is
|
||||||
|
// either a nested section or a structured value (gradient, vec2)
|
||||||
|
// that serialiseValue knows how to render.
|
||||||
|
const rendered = typeof child === "string"
|
||||||
|
? child
|
||||||
|
: (Array.isArray(child) || (child && child.colors !== undefined)
|
||||||
|
? root.serialiseValue(child)
|
||||||
|
: root.serialiseTable(child));
|
||||||
|
parts.push(`${name} = ${rendered}`);
|
||||||
}
|
}
|
||||||
return `{ ${parts.join(", ")} }`;
|
return `{ ${parts.join(", ")} }`;
|
||||||
}
|
}
|
||||||
@@ -353,6 +390,59 @@ Singleton {
|
|||||||
root.drainQueue();
|
root.drainQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gradients are written in one notation and read back in another, so they
|
||||||
|
// cannot be compared directly the way every other type can.
|
||||||
|
//
|
||||||
|
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
|
||||||
|
// read: "993b4261 45deg"
|
||||||
|
//
|
||||||
|
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
|
||||||
|
// always appended even when it was never given. Comparing the raw strings
|
||||||
|
// reports every gradient write as rejected, which is what would have
|
||||||
|
// happened had this been added with readAs: "str".
|
||||||
|
function gradientMatches(expected: var, observed: string): bool {
|
||||||
|
if (typeof observed !== "string")
|
||||||
|
return false;
|
||||||
|
return root.normaliseGradient(expected) === root.normaliseGradient(observed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
|
||||||
|
function normaliseGradient(value: var): string {
|
||||||
|
const stops = [];
|
||||||
|
let angle = 0;
|
||||||
|
|
||||||
|
const readStop = function (text: string): void {
|
||||||
|
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
|
||||||
|
if (rgba) {
|
||||||
|
let hex = rgba[1].toLowerCase();
|
||||||
|
// rgb() has no alpha; the compositor reports it as fully opaque.
|
||||||
|
if (hex.length === 6)
|
||||||
|
hex = hex + "ff";
|
||||||
|
// RRGGBBAA in, AARRGGBB out.
|
||||||
|
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
|
||||||
|
if (bare) {
|
||||||
|
stops.push(bare[1].toLowerCase());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
|
||||||
|
if (deg)
|
||||||
|
angle = Number(deg[1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
||||||
|
value.colors.forEach(readStop);
|
||||||
|
if (value.angle !== undefined && isFinite(Number(value.angle)))
|
||||||
|
angle = Number(value.angle);
|
||||||
|
} else {
|
||||||
|
String(value).trim().split(/\s+/).forEach(readStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops.join(" ") + " " + angle + "deg";
|
||||||
|
}
|
||||||
|
|
||||||
function matchesObserved(entry: var, value: var, answer: var): bool {
|
function matchesObserved(entry: var, value: var, answer: var): bool {
|
||||||
if (!answer)
|
if (!answer)
|
||||||
return false;
|
return false;
|
||||||
@@ -370,6 +460,12 @@ Singleton {
|
|||||||
case "css":
|
case "css":
|
||||||
// Gaps read back as a box, e.g. "10 10 10 10".
|
// Gaps read back as a box, e.g. "10 10 10 10".
|
||||||
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
|
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
|
||||||
|
case "gradient":
|
||||||
|
return root.gradientMatches(expected, answer.gradient);
|
||||||
|
case "vec2":
|
||||||
|
return Array.isArray(answer.vec2) && Array.isArray(expected)
|
||||||
|
&& Number(answer.vec2[0]) === Number(expected[0])
|
||||||
|
&& Number(answer.vec2[1]) === Number(expected[1]);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -413,16 +509,13 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentDisplays = root.readDisplays();
|
|
||||||
const protectedDisplays = JSON.parse(JSON.stringify(
|
|
||||||
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
|
||||||
root.setDisplayBlocked(true);
|
root.setDisplayBlocked(true);
|
||||||
DesktopPreferences.resetDesktopDefaults();
|
DesktopPreferences.resetDesktopDefaults();
|
||||||
if (!root.protectDisplays(protectedDisplays)) {
|
|
||||||
root.setDisplayBlocked(false);
|
// Do not apply geometry during a reset: doing so would need the same
|
||||||
root.lastError = "The current display setting could not be protected during reset.";
|
// visible confirmation transaction as the Displays page. Clearing the
|
||||||
return false;
|
// stored records is still important, though, so the next session uses
|
||||||
}
|
// Panama's shipped DP-2 placement and automatic placement elsewhere.
|
||||||
|
|
||||||
// Home accessories keep their own store (panama-home.json), so a reset
|
// Home accessories keep their own store (panama-home.json), so a reset
|
||||||
// that only cleared the schema store would silently leave a customised
|
// that only cleared the schema store would silently leave a customised
|
||||||
@@ -443,6 +536,7 @@ Singleton {
|
|||||||
root.applyPersistedDisplayPolicy();
|
root.applyPersistedDisplayPolicy();
|
||||||
root.reloadKeybinds();
|
root.reloadKeybinds();
|
||||||
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
||||||
|
root.regenerateLock();
|
||||||
resetRelease.attempts = 0;
|
resetRelease.attempts = 0;
|
||||||
resetRelease.restart();
|
resetRelease.restart();
|
||||||
}
|
}
|
||||||
@@ -455,7 +549,7 @@ Singleton {
|
|||||||
repeat: true
|
repeat: true
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
attempts++;
|
attempts++;
|
||||||
if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) {
|
if ((!root.keybindsReloading() && !root.busy && !root.lockBusy()) || attempts >= 50) {
|
||||||
stop();
|
stop();
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,55 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
// The desktop background.
|
// Verified wallpaper policy application. hyprpaper 0.8 applies one output per
|
||||||
//
|
// IPC call, so Panama queues every connected output and persists a policy only
|
||||||
// hyprpaper owns the actual painting; this owns choosing. Two things are worth
|
// after listactive confirms the complete map.
|
||||||
// knowing about hyprpaper 0.8:
|
|
||||||
//
|
|
||||||
// * Its IPC is much smaller than the documentation for older versions
|
|
||||||
// suggests. `wallpaper <output>,<path>` and `listactive` work; `preload`,
|
|
||||||
// `listloaded`, `unload`, and `reload` all answer "invalid hyprpaper
|
|
||||||
// request". So there is no preload step -- setting is a single call.
|
|
||||||
// * hyprpaper.conf lives in the Panama repo via the ~/.config/hypr symlink,
|
|
||||||
// so it cannot be rewritten at runtime without dirtying a tracked file.
|
|
||||||
// The chosen wallpaper therefore lives in the shared settings store like
|
|
||||||
// every other preference, and is re-applied when the shell starts.
|
|
||||||
//
|
|
||||||
// The argument is "<output>,<path>", so a path containing a comma would be
|
|
||||||
// parsed as a different request. The schema's pattern rejects those, and the
|
|
||||||
// value is passed as a single argv element rather than through a shell.
|
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
|
import "WallpaperPolicy.js" as WallpaperPolicy
|
||||||
import qs.config
|
import qs.config
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// Absolute paths of candidate images, newest first.
|
|
||||||
property var available: []
|
property var available: []
|
||||||
property string active: ""
|
property var activeByOutput: ({})
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
property bool scanning: false
|
property bool scanning: false
|
||||||
|
property var transaction: null
|
||||||
|
property bool startupRestoreEnabled: true
|
||||||
|
property var outputOverride: null
|
||||||
|
property var candidateOverride: null
|
||||||
|
property string slideshowPath: ""
|
||||||
|
property int slideshowIndex: -1
|
||||||
|
property var shuffleBag: []
|
||||||
|
property int slideshowIntervalOverrideMs: 0
|
||||||
|
property bool pendingHotplugReapply: false
|
||||||
|
|
||||||
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
||||||
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
||||||
|
readonly property string active: {
|
||||||
|
const outputs = root.outputNames();
|
||||||
|
if (outputs.length > 0 && root.activeByOutput[outputs[0]])
|
||||||
|
return root.activeByOutput[outputs[0]];
|
||||||
|
const paths = Object.values(root.activeByOutput);
|
||||||
|
return paths.length > 0 ? paths[0] : "";
|
||||||
|
}
|
||||||
|
readonly property bool busy: root.transaction !== null
|
||||||
|
|| applyProcess.running || verifyProcess.running
|
||||||
|
readonly property var slideshowCollection: WallpaperPolicy.validCollection(
|
||||||
|
DesktopPreferences.get("wallpaperSlideshowPaths") ?? [], root.candidates([]))
|
||||||
|
readonly property bool slideshowTimerRunning: slideshowTimer.running
|
||||||
|
readonly property string outputSignature: root.outputNames().slice().sort().join("|")
|
||||||
|
|
||||||
|
property var outputNames: function() {
|
||||||
|
if (Array.isArray(root.outputOverride))
|
||||||
|
return root.outputOverride.slice();
|
||||||
|
return Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||||
|
}
|
||||||
|
|
||||||
// Directories searched for wallpapers, in order. Screenshots are
|
|
||||||
// deliberately excluded: a folder of 300 screenshots is not a wallpaper
|
|
||||||
// picker, and including it made the grid useless on this machine.
|
|
||||||
readonly property var searchRoots: [
|
readonly property var searchRoots: [
|
||||||
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
||||||
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
||||||
@@ -47,20 +59,14 @@ Singleton {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: scan
|
id: scan
|
||||||
|
|
||||||
// -print0 would be safer against odd filenames, but the schema already
|
|
||||||
// rejects paths containing commas or newlines, and this list is only
|
|
||||||
// ever offered as candidates -- the value that gets stored is validated
|
|
||||||
// again on the way in.
|
|
||||||
command: ["bash", "-lc",
|
command: ["bash", "-lc",
|
||||||
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
||||||
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
||||||
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
||||||
|
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
const paths = this.text.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
root.available = this.text.split("\n")
|
||||||
root.available = paths;
|
.map(line => line.trim()).filter(line => line.length > 0);
|
||||||
root.scanning = false;
|
root.scanning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,40 +77,36 @@ Singleton {
|
|||||||
command: ["hyprctl", "hyprpaper", "listactive"]
|
command: ["hyprctl", "hyprpaper", "listactive"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
// "DP-2: /path/to/image.jpg", one line per output.
|
const parsed = root.parseActive(this.text);
|
||||||
const first = this.text.split("\n").find(line => line.indexOf(":") > 0);
|
if (parsed !== null)
|
||||||
root.active = first ? first.slice(first.indexOf(":") + 1).trim() : "";
|
root.activeByOutput = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyprpaper requires an explicit output name: the "<empty>,<path>" form that
|
|
||||||
// older versions accepted as "all outputs" is silently ignored by 0.8, so a
|
|
||||||
// wallpaper set that way appears to succeed and never changes. Outputs are
|
|
||||||
// therefore walked one at a time.
|
|
||||||
Process {
|
Process {
|
||||||
id: apply
|
id: applyProcess
|
||||||
|
|
||||||
property string requested: ""
|
|
||||||
property string storedValue: ""
|
|
||||||
property var remaining: []
|
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (root.transaction === null)
|
||||||
|
return;
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.lastError = "hyprpaper could not load that image.";
|
root.lastError = "Hyprpaper did not apply that background.";
|
||||||
apply.remaining = [];
|
root.transaction = null;
|
||||||
|
root.schedulePendingHotplug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (apply.remaining.length > 0) {
|
root.drainTransaction();
|
||||||
const next = apply.remaining[0];
|
|
||||||
apply.remaining = apply.remaining.slice(1);
|
|
||||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${next},${apply.requested}`]);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
root.lastError = "";
|
|
||||||
DesktopPreferences.set("wallpaperPath", apply.storedValue);
|
|
||||||
root.refreshActive();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: verifyProcess
|
||||||
|
property string outputText: ""
|
||||||
|
onStarted: outputText = ""
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: verifyProcess.outputText = this.text
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
@@ -113,20 +115,43 @@ Singleton {
|
|||||||
restore.restart();
|
restore.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyprpaper is started by the compositor's autostart, so it may not be
|
|
||||||
// listening yet when the shell comes up. Re-applying the stored choice
|
|
||||||
// after a short delay makes the wallpaper survive a reboot without needing
|
|
||||||
// hyprpaper.conf to know about it.
|
|
||||||
Timer {
|
Timer {
|
||||||
id: restore
|
id: restore
|
||||||
interval: 1500
|
interval: 1500
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
const stored = root.configured;
|
if (root.startupRestoreEnabled)
|
||||||
if (stored !== "" && stored !== root.active)
|
root.applyCurrentPolicy(false);
|
||||||
root.set(stored);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: slideshowTimer
|
||||||
|
interval: root.slideshowIntervalOverrideMs > 0
|
||||||
|
? root.slideshowIntervalOverrideMs
|
||||||
|
: DesktopPreferences.get("wallpaperIntervalMinutes") * 60000
|
||||||
|
repeat: true
|
||||||
|
running: DesktopPreferences.get("wallpaperMode") === "slideshow"
|
||||||
|
&& root.slideshowCollection.length >= 2 && !root.busy
|
||||||
|
onTriggered: root.advanceSlideshow()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: outputSettle
|
||||||
|
interval: 350
|
||||||
|
onTriggered: {
|
||||||
|
if (root.busy) {
|
||||||
|
root.pendingHotplugReapply = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.applyCurrentPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOutputSignatureChanged: {
|
||||||
|
if (Object.keys(root.activeByOutput).length > 0)
|
||||||
|
outputSettle.restart();
|
||||||
|
}
|
||||||
|
|
||||||
function rescan(): void {
|
function rescan(): void {
|
||||||
if (scan.running)
|
if (scan.running)
|
||||||
return;
|
return;
|
||||||
@@ -135,37 +160,268 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshActive(): void {
|
function refreshActive(): void {
|
||||||
if (!activeQuery.running)
|
if (!activeQuery.running && !root.busy)
|
||||||
activeQuery.running = true;
|
activeQuery.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies to every connected output. Returns false when the path is not one
|
function candidates(extra: var): var {
|
||||||
// the schema will accept, so a caller can report the refusal.
|
const result = [];
|
||||||
function set(path: string): bool {
|
const discovered = Array.isArray(root.candidateOverride)
|
||||||
const effectivePath = path === "" ? root.shippedPath : path;
|
? root.candidateOverride : root.available;
|
||||||
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
|
for (const path of discovered.concat([root.shippedPath, root.configured]).concat(extra || [])) {
|
||||||
root.lastError = "That file path cannot be used as a wallpaper.";
|
if (typeof path === "string" && /^\/[^,\n]+$/.test(path) && !result.includes(path))
|
||||||
|
result.push(path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPolicy(): var {
|
||||||
|
return {
|
||||||
|
mode: DesktopPreferences.get("wallpaperMode") ?? "single",
|
||||||
|
globalPath: root.configured === "" ? root.shippedPath : root.configured,
|
||||||
|
collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [],
|
||||||
|
intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30,
|
||||||
|
shuffle: DesktopPreferences.get("wallpaperShuffle") !== false,
|
||||||
|
assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({}),
|
||||||
|
slideshowPath: root.slideshowPath !== "" ? root.slideshowPath : root.active
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalisePolicy(policy: var): var {
|
||||||
|
if (!policy || typeof policy !== "object")
|
||||||
|
return null;
|
||||||
|
const mode = ["single", "slideshow", "per-monitor"].includes(policy.mode)
|
||||||
|
? policy.mode : "single";
|
||||||
|
const rawGlobal = policy.globalPath === "" ? root.shippedPath : policy.globalPath;
|
||||||
|
const candidatePaths = root.candidates([rawGlobal]
|
||||||
|
.concat(policy.collection || [])
|
||||||
|
.concat(Object.values(policy.assignments || {}))
|
||||||
|
.concat([policy.slideshowPath || ""]));
|
||||||
|
if (!WallpaperPolicy.validPath(rawGlobal, candidatePaths))
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
globalPath: rawGlobal,
|
||||||
|
storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal,
|
||||||
|
collection: WallpaperPolicy.validCollection(policy.collection || [], candidatePaths),
|
||||||
|
intervalMinutes: Math.max(5, Math.min(1440, Number(policy.intervalMinutes) || 30)),
|
||||||
|
shuffle: policy.shuffle !== false,
|
||||||
|
assignments: WallpaperPolicy.validAssignments(policy.assignments || {}, candidatePaths),
|
||||||
|
slideshowPath: WallpaperPolicy.validPath(policy.slideshowPath, candidatePaths)
|
||||||
|
? policy.slideshowPath : rawGlobal,
|
||||||
|
nextShuffleBag: Array.isArray(policy.nextShuffleBag)
|
||||||
|
? policy.nextShuffleBag.slice() : root.shuffleBag.slice(),
|
||||||
|
nextSlideshowIndex: Number.isInteger(policy.nextSlideshowIndex)
|
||||||
|
? policy.nextSlideshowIndex : root.slideshowIndex,
|
||||||
|
candidates: candidatePaths
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPolicy(policy: var, persist: bool, automatic: bool): bool {
|
||||||
|
if (root.busy)
|
||||||
|
return false;
|
||||||
|
const normalised = root.normalisePolicy(policy);
|
||||||
|
if (normalised === null) {
|
||||||
|
root.lastError = "That wallpaper policy is not valid.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (apply.running)
|
const outputs = root.outputNames();
|
||||||
return false;
|
|
||||||
|
|
||||||
apply.requested = effectivePath;
|
|
||||||
apply.storedValue = path;
|
|
||||||
|
|
||||||
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
|
||||||
if (outputs.length === 0) {
|
if (outputs.length === 0) {
|
||||||
root.lastError = "No display to set a wallpaper on.";
|
root.lastError = "No display to set a wallpaper on.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const expected = WallpaperPolicy.effectiveMap(
|
||||||
apply.remaining = outputs.slice(1);
|
normalised.mode, normalised.globalPath, normalised.slideshowPath,
|
||||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
|
normalised.assignments, outputs, normalised.candidates);
|
||||||
|
if (Object.keys(expected).length !== outputs.length
|
||||||
|
|| Object.values(expected).some(path => path === "")) {
|
||||||
|
root.lastError = "That wallpaper policy is not valid.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
root.transaction = {
|
||||||
|
expected,
|
||||||
|
remaining: outputs.slice(),
|
||||||
|
policy: normalised,
|
||||||
|
persist: persist === true,
|
||||||
|
automatic: automatic === true
|
||||||
|
};
|
||||||
|
root.drainTransaction();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The display name for a path: the file's own name, without extension,
|
function drainTransaction(): void {
|
||||||
// with separators turned into spaces.
|
if (root.transaction === null || applyProcess.running || verifyProcess.running)
|
||||||
|
return;
|
||||||
|
if (root.transaction.remaining.length === 0) {
|
||||||
|
verifyProcess.exec(["hyprctl", "hyprpaper", "listactive"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const output = root.transaction.remaining[0];
|
||||||
|
root.transaction.remaining = root.transaction.remaining.slice(1);
|
||||||
|
applyProcess.exec([
|
||||||
|
"hyprctl", "hyprpaper", "wallpaper",
|
||||||
|
`${output},${root.transaction.expected[output]}`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActive(text: string): var {
|
||||||
|
const result = {};
|
||||||
|
const lines = String(text).split("\n").map(line => line.trim()).filter(line => line !== "");
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^([A-Za-z0-9_.-]+): (\/[^,\n]+)$/);
|
||||||
|
if (!match || result[match[1]] !== undefined)
|
||||||
|
return null;
|
||||||
|
result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishVerification(exitCode: int): void {
|
||||||
|
if (root.transaction === null)
|
||||||
|
return;
|
||||||
|
const observed = exitCode === 0 ? root.parseActive(verifyProcess.outputText) : null;
|
||||||
|
const expected = root.transaction.expected;
|
||||||
|
const matches = observed !== null
|
||||||
|
&& Object.keys(observed).length === Object.keys(expected).length
|
||||||
|
&& Object.keys(expected).every(output => observed[output] === expected[output]);
|
||||||
|
if (!matches) {
|
||||||
|
root.lastError = "Hyprpaper did not confirm that background.";
|
||||||
|
root.transaction = null;
|
||||||
|
root.schedulePendingHotplug();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = root.transaction;
|
||||||
|
root.activeByOutput = observed;
|
||||||
|
root.transaction = null;
|
||||||
|
root.lastError = "";
|
||||||
|
if (completed.automatic) {
|
||||||
|
root.slideshowPath = completed.policy.slideshowPath;
|
||||||
|
root.shuffleBag = completed.policy.nextShuffleBag;
|
||||||
|
root.slideshowIndex = completed.policy.nextSlideshowIndex;
|
||||||
|
}
|
||||||
|
if (completed.persist)
|
||||||
|
root.persistPolicy(completed.policy);
|
||||||
|
root.schedulePendingHotplug();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPolicy(policy: var): void {
|
||||||
|
DesktopPreferences.set("wallpaperMode", policy.mode);
|
||||||
|
DesktopPreferences.set("wallpaperPath", policy.storedPath);
|
||||||
|
DesktopPreferences.set("wallpaperSlideshowPaths", policy.collection);
|
||||||
|
DesktopPreferences.set("wallpaperIntervalMinutes", policy.intervalMinutes);
|
||||||
|
DesktopPreferences.set("wallpaperShuffle", policy.shuffle);
|
||||||
|
DesktopPreferences.set("wallpaperPerMonitor", policy.assignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCurrentPolicy(persist: bool): bool {
|
||||||
|
return root.applyPolicy(root.currentPolicy(), persist === true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingHotplug(): void {
|
||||||
|
if (!root.pendingHotplugReapply)
|
||||||
|
return;
|
||||||
|
root.pendingHotplugReapply = false;
|
||||||
|
outputSettle.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceSlideshow(): bool {
|
||||||
|
if (root.busy)
|
||||||
|
return false;
|
||||||
|
const collection = root.slideshowCollection;
|
||||||
|
if (collection.length < 2)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const current = root.slideshowPath !== ""
|
||||||
|
? root.slideshowPath
|
||||||
|
: (root.active !== "" ? root.active : collection[0]);
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
policy.mode = "slideshow";
|
||||||
|
policy.collection = collection;
|
||||||
|
|
||||||
|
if (DesktopPreferences.get("wallpaperShuffle") !== false) {
|
||||||
|
const next = WallpaperPolicy.shuffledNext(
|
||||||
|
collection, root.shuffleBag, current, Math.random);
|
||||||
|
policy.slideshowPath = next.path;
|
||||||
|
policy.nextShuffleBag = next.bag;
|
||||||
|
policy.nextSlideshowIndex = collection.indexOf(next.path);
|
||||||
|
} else {
|
||||||
|
policy.slideshowPath = WallpaperPolicy.orderedNext(collection, current);
|
||||||
|
policy.nextShuffleBag = [];
|
||||||
|
policy.nextSlideshowIndex = collection.indexOf(policy.slideshowPath);
|
||||||
|
}
|
||||||
|
return root.applyPolicy(policy, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSingle(path: string): bool {
|
||||||
|
const effectivePath = path === "" ? root.shippedPath : path;
|
||||||
|
const allowed = root.candidates([]);
|
||||||
|
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
|
||||||
|
root.lastError = "That file path cannot be used as a wallpaper.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
policy.mode = "single";
|
||||||
|
policy.globalPath = effectivePath;
|
||||||
|
policy.slideshowPath = effectivePath;
|
||||||
|
return root.applyPolicy(policy, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(mode: string): bool {
|
||||||
|
if (!["single", "slideshow", "per-monitor"].includes(mode))
|
||||||
|
return false;
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
policy.mode = mode;
|
||||||
|
if (mode === "slideshow") {
|
||||||
|
const collection = WallpaperPolicy.validCollection(
|
||||||
|
policy.collection, root.candidates([]));
|
||||||
|
if (!collection.includes(policy.slideshowPath))
|
||||||
|
policy.slideshowPath = collection[0] || policy.globalPath;
|
||||||
|
}
|
||||||
|
return root.applyPolicy(policy, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAssignment(output: string, path: string): bool {
|
||||||
|
if (!root.outputNames().includes(output)
|
||||||
|
|| !WallpaperPolicy.validPath(path, root.candidates([])))
|
||||||
|
return false;
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
policy.mode = "per-monitor";
|
||||||
|
policy.assignments = Object.assign({}, policy.assignments);
|
||||||
|
policy.assignments[output] = path;
|
||||||
|
return root.applyPolicy(policy, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSlideshowPath(path: string): bool {
|
||||||
|
if (!WallpaperPolicy.validPath(path, root.candidates([])))
|
||||||
|
return false;
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
const collection = WallpaperPolicy.validCollection(policy.collection, root.candidates([]));
|
||||||
|
policy.collection = collection.includes(path)
|
||||||
|
? collection.filter(candidate => candidate !== path)
|
||||||
|
: collection.concat([path]);
|
||||||
|
policy.mode = "slideshow";
|
||||||
|
if (!policy.collection.includes(policy.slideshowPath))
|
||||||
|
policy.slideshowPath = policy.collection[0] || policy.globalPath;
|
||||||
|
return root.applyPolicy(policy, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIntervalMinutes(minutes: int): bool {
|
||||||
|
const value = PreferenceSchema.coerce("wallpaperIntervalMinutes", minutes);
|
||||||
|
return value !== undefined
|
||||||
|
&& DesktopPreferences.set("wallpaperIntervalMinutes", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShuffle(enabled: bool): bool {
|
||||||
|
return DesktopPreferences.set("wallpaperShuffle", enabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility boundary used by backup/reset and the existing picker.
|
||||||
|
function set(path: string): bool {
|
||||||
|
return root.setSingle(path);
|
||||||
|
}
|
||||||
|
|
||||||
function titleFor(path: string): string {
|
function titleFor(path: string): string {
|
||||||
const file = String(path).split("/").pop();
|
const file = String(path).split("/").pop();
|
||||||
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
function validPath(path, candidates) {
|
||||||
|
if (typeof path !== "string" || !/^\/[^,\n]+$/.test(path))
|
||||||
|
return false;
|
||||||
|
return (candidates || []).includes(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCollection(paths, candidates) {
|
||||||
|
const result = [];
|
||||||
|
const seen = {};
|
||||||
|
for (const path of paths || []) {
|
||||||
|
if (!validPath(path, candidates) || seen[path])
|
||||||
|
continue;
|
||||||
|
seen[path] = true;
|
||||||
|
result.push(path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validAssignments(assignments, candidates) {
|
||||||
|
const result = {};
|
||||||
|
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments))
|
||||||
|
return result;
|
||||||
|
for (const output of Object.keys(assignments)) {
|
||||||
|
if (!/^[A-Za-z0-9_.-]+$/.test(output))
|
||||||
|
continue;
|
||||||
|
const path = assignments[output];
|
||||||
|
if (validPath(path, candidates))
|
||||||
|
result[output] = path;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveMap(mode, globalPath, slideshowPath, assignments, outputs, candidates) {
|
||||||
|
const result = {};
|
||||||
|
const fallback = validPath(globalPath, candidates)
|
||||||
|
? globalPath
|
||||||
|
: ((candidates || [])[0] || "");
|
||||||
|
const selectedAssignments = validAssignments(assignments, candidates);
|
||||||
|
const slideshow = validPath(slideshowPath, candidates) ? slideshowPath : fallback;
|
||||||
|
for (const output of outputs || []) {
|
||||||
|
if (typeof output !== "string" || !/^[A-Za-z0-9_.-]+$/.test(output))
|
||||||
|
continue;
|
||||||
|
if (mode === "per-monitor")
|
||||||
|
result[output] = selectedAssignments[output] || fallback;
|
||||||
|
else if (mode === "slideshow")
|
||||||
|
result[output] = slideshow;
|
||||||
|
else
|
||||||
|
result[output] = fallback;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedNext(collection, current) {
|
||||||
|
const paths = collection || [];
|
||||||
|
if (paths.length === 0)
|
||||||
|
return "";
|
||||||
|
const index = paths.indexOf(current);
|
||||||
|
return paths[(index + 1 + paths.length) % paths.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffledBag(collection, random) {
|
||||||
|
const result = Array.from(collection || []);
|
||||||
|
const nextRandom = typeof random === "function" ? random : Math.random;
|
||||||
|
for (let index = result.length - 1; index > 0; index--) {
|
||||||
|
const swap = Math.floor(Math.max(0, Math.min(0.999999999, nextRandom())) * (index + 1));
|
||||||
|
const value = result[index];
|
||||||
|
result[index] = result[swap];
|
||||||
|
result[swap] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffledNext(collection, bag, current, random) {
|
||||||
|
const paths = Array.from(collection || []);
|
||||||
|
if (paths.length === 0)
|
||||||
|
return { path: "", bag: [] };
|
||||||
|
|
||||||
|
const remaining = [];
|
||||||
|
const seen = {};
|
||||||
|
for (const path of bag || []) {
|
||||||
|
if (!paths.includes(path) || seen[path])
|
||||||
|
continue;
|
||||||
|
seen[path] = true;
|
||||||
|
remaining.push(path);
|
||||||
|
}
|
||||||
|
if (remaining.length === 0)
|
||||||
|
remaining.push(...shuffledBag(paths, random));
|
||||||
|
|
||||||
|
if (remaining.length > 1 && remaining[0] === current) {
|
||||||
|
const replacement = remaining.findIndex(path => path !== current);
|
||||||
|
const value = remaining[0];
|
||||||
|
remaining[0] = remaining[replacement];
|
||||||
|
remaining[replacement] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: remaining[0], bag: remaining.slice(1) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Alt-Tab, with something on screen while you do it.
|
||||||
|
//
|
||||||
|
// Named WindowSwitcherState rather than WindowSwitcher: the overlay component
|
||||||
|
// in modules/switcher already owns that name, and a singleton sharing it is
|
||||||
|
// silently shadowed wherever both are imported -- the same failure that made an
|
||||||
|
// earlier Locale singleton resolve to QML's built-in type instead.
|
||||||
|
//
|
||||||
|
// Super+Tab already cycled windows; nothing was drawn, so you were choosing
|
||||||
|
// blind and could only confirm by arriving. This holds the selection while a
|
||||||
|
// switch is in progress and lets the overlay render it.
|
||||||
|
//
|
||||||
|
// MOST-RECENTLY-USED ORDER
|
||||||
|
//
|
||||||
|
// The list is ordered by when each window last had focus, not by when it was
|
||||||
|
// opened, because that is what makes the gesture useful: one Tab returns to the
|
||||||
|
// window you just came from, which is the overwhelmingly common case. Creation
|
||||||
|
// order would send you to whichever window happens to be first in Hyprland's
|
||||||
|
// list, which is arbitrary from the user's point of view.
|
||||||
|
//
|
||||||
|
// Hyprland does not report an MRU order, so it is tracked here: every time a
|
||||||
|
// toplevel becomes active it moves to the front. Addresses are used as the key
|
||||||
|
// because they are stable for a window's lifetime, where titles and app ids are
|
||||||
|
// not.
|
||||||
|
//
|
||||||
|
// HOW A SWITCH ENDS
|
||||||
|
//
|
||||||
|
// The compositor fires a bind on Super RELEASE, which commits. That is the only
|
||||||
|
// way to know the gesture is over -- there is no "modifier released" signal
|
||||||
|
// otherwise. It means close() runs on every Super release in the session, so it
|
||||||
|
// must be cheap and a no-op when nothing is open.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool open: false
|
||||||
|
property int index: 0
|
||||||
|
|
||||||
|
// Window addresses, most recently focused first.
|
||||||
|
property var recent: []
|
||||||
|
|
||||||
|
// The switch candidates, resolved fresh each time the gesture starts.
|
||||||
|
property var windows: []
|
||||||
|
|
||||||
|
readonly property var selected: (root.index >= 0 && root.index < root.windows.length)
|
||||||
|
? root.windows[root.index] : null
|
||||||
|
|
||||||
|
// Ordered by the MRU list, with anything unseen appended in Hyprland's own
|
||||||
|
// order so a brand new window is still reachable.
|
||||||
|
function orderedWindows(): var {
|
||||||
|
const all = (Hyprland.toplevels?.values ?? []).filter(t => t && t.wayland && t.wayland.appId);
|
||||||
|
const byAddress = {};
|
||||||
|
for (const toplevel of all)
|
||||||
|
byAddress[String(toplevel.address)] = toplevel;
|
||||||
|
|
||||||
|
const ordered = [];
|
||||||
|
for (const address of root.recent) {
|
||||||
|
const match = byAddress[address];
|
||||||
|
if (match) {
|
||||||
|
ordered.push(match);
|
||||||
|
delete byAddress[address];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const toplevel of all)
|
||||||
|
if (byAddress[String(toplevel.address)])
|
||||||
|
ordered.push(toplevel);
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts the gesture if it is not already running, then steps. The first
|
||||||
|
// Tab lands on the PREVIOUS window rather than the current one, which is
|
||||||
|
// what every other implementation of this gesture does.
|
||||||
|
function step(forward: bool): void {
|
||||||
|
if (!root.open) {
|
||||||
|
root.windows = root.orderedWindows();
|
||||||
|
if (root.windows.length < 2)
|
||||||
|
return;
|
||||||
|
root.open = true;
|
||||||
|
root.index = forward ? 1 : root.windows.length - 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.windows.length === 0)
|
||||||
|
return;
|
||||||
|
const count = root.windows.length;
|
||||||
|
root.index = forward
|
||||||
|
? (root.index + 1) % count
|
||||||
|
: (root.index - 1 + count) % count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs on every Super release in the session, so it does as little as
|
||||||
|
// possible when no switch is in progress.
|
||||||
|
function commit(): void {
|
||||||
|
if (!root.open)
|
||||||
|
return;
|
||||||
|
const target = root.selected;
|
||||||
|
root.open = false;
|
||||||
|
root.windows = [];
|
||||||
|
root.index = 0;
|
||||||
|
if (target && target.wayland)
|
||||||
|
target.wayland.activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
root.open = false;
|
||||||
|
root.windows = [];
|
||||||
|
root.index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus changes maintain the MRU order. This runs whether or not a switch
|
||||||
|
// is in progress, because ordinary clicking between windows is most of how
|
||||||
|
// the order is established.
|
||||||
|
Connections {
|
||||||
|
target: Hyprland
|
||||||
|
function onActiveToplevelChanged(): void {
|
||||||
|
const active = Hyprland.activeToplevel;
|
||||||
|
if (!active || !active.address)
|
||||||
|
return;
|
||||||
|
const address = String(active.address);
|
||||||
|
const next = [address];
|
||||||
|
for (const existing of root.recent)
|
||||||
|
if (existing !== address)
|
||||||
|
next.push(existing);
|
||||||
|
// Bounded: a session can accumulate a lot of closed addresses, and
|
||||||
|
// this list is only ever used to order what is currently open.
|
||||||
|
root.recent = next.slice(0, 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,21 @@ ShellRoot {
|
|||||||
property var homeFavorites: []
|
property var homeFavorites: []
|
||||||
property bool displayOperationBusy: false
|
property bool displayOperationBusy: false
|
||||||
property bool displayBlocked: false
|
property bool displayBlocked: false
|
||||||
property var displayGeneration: ({ "DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0 } })
|
property bool displayApplyAccepted: true
|
||||||
|
property bool displayConfirmationReady: false
|
||||||
|
property var originalDisplays: ({
|
||||||
|
"DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
|
||||||
|
"HDMI-A-1": { mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
|
||||||
|
})
|
||||||
|
property var restoredDisplays: ({
|
||||||
|
"DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0, x: -2560, y: 0, primary: false },
|
||||||
|
"HDMI-A-1": { mode: "2560x1440@60", scale: 1, transform: 0, x: 0, y: 0, primary: true }
|
||||||
|
})
|
||||||
|
property var displayGeneration: originalDisplays
|
||||||
|
property var liveLayout: [
|
||||||
|
{ name: "DP-2", width: 4500, height: 3000, refreshRate: 60, mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
|
||||||
|
{ name: "HDMI-A-1", width: 2560, height: 1440, refreshRate: 60, mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
|
||||||
|
]
|
||||||
|
|
||||||
function record(name: string): void {
|
function record(name: string): void {
|
||||||
const next = root.calls.slice();
|
const next = root.calls.slice();
|
||||||
@@ -45,7 +59,10 @@ ShellRoot {
|
|||||||
root.homeFavorites = root.homeFavorites.map(favorite =>
|
root.homeFavorites = root.homeFavorites.map(favorite =>
|
||||||
favorite.id === id ? { id: id, alias: alias } : favorite);
|
favorite.id === id ? { id: id, alias: alias } : favorite);
|
||||||
};
|
};
|
||||||
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
|
SettingsBackup.reloadDesktop = function() {
|
||||||
|
root.record("desktop.reload");
|
||||||
|
root.displayGeneration = JSON.parse(JSON.stringify(root.restoredDisplays));
|
||||||
|
};
|
||||||
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
|
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
|
||||||
SettingsBackup.protectDisplays = function(value) {
|
SettingsBackup.protectDisplays = function(value) {
|
||||||
root.record("display.protect:" + JSON.stringify(value));
|
root.record("display.protect:" + JSON.stringify(value));
|
||||||
@@ -53,16 +70,39 @@ ShellRoot {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
|
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
|
||||||
|
SettingsBackup.readLiveDisplayLayout = function() {
|
||||||
|
return root.liveLayout.map(record => Object.assign({}, record));
|
||||||
|
};
|
||||||
|
SettingsBackup.applyDisplayLayout = function(layout) {
|
||||||
|
root.record("display.apply:" + JSON.stringify(layout));
|
||||||
|
if (!root.displayApplyAccepted)
|
||||||
|
return false;
|
||||||
|
root.liveLayout = layout.map(record => Object.assign({}, record));
|
||||||
|
root.displayOperationBusy = true;
|
||||||
|
root.displayConfirmationReady = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
SettingsBackup.displayCanConfirm = function() { return root.displayConfirmationReady; };
|
||||||
|
SettingsBackup.confirmDisplayLayout = function() {
|
||||||
|
root.record("display.confirm");
|
||||||
|
root.displayOperationBusy = false;
|
||||||
|
root.displayConfirmationReady = false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
SettingsBackup.setDisplayBlocked = function(blocked) {
|
SettingsBackup.setDisplayBlocked = function(blocked) {
|
||||||
root.record("display.block:" + blocked);
|
root.record("display.block:" + blocked);
|
||||||
root.displayBlocked = blocked;
|
root.displayBlocked = blocked;
|
||||||
};
|
};
|
||||||
|
SettingsBackup.applyIdle = function() { root.record("idle.apply"); };
|
||||||
|
SettingsBackup.idleBusy = function() { return false; };
|
||||||
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
|
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
|
||||||
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
||||||
SettingsBackup.keybindsReloading = function() { return false; };
|
SettingsBackup.keybindsReloading = function() { return false; };
|
||||||
SettingsBackup.systemBusy = function() { return false; };
|
SettingsBackup.systemBusy = function() { return false; };
|
||||||
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
|
SettingsBackup.applyWallpaperPolicy = function() { root.record("wallpaper.apply-policy"); };
|
||||||
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
|
SettingsBackup.wallpaperBusy = function() { return false; };
|
||||||
|
SettingsBackup.regenerateLock = function() { root.record("lock.regenerate"); };
|
||||||
|
SettingsBackup.lockBusy = function() { return false; };
|
||||||
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
|
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +115,15 @@ ShellRoot {
|
|||||||
root.homeFavorites = [];
|
root.homeFavorites = [];
|
||||||
root.displayOperationBusy = false;
|
root.displayOperationBusy = false;
|
||||||
root.displayBlocked = false;
|
root.displayBlocked = false;
|
||||||
|
root.displayApplyAccepted = true;
|
||||||
|
root.displayConfirmationReady = false;
|
||||||
|
root.displayGeneration = JSON.parse(JSON.stringify(root.originalDisplays));
|
||||||
|
root.liveLayout = [
|
||||||
|
{ name: "DP-2", width: 4500, height: 3000, refreshRate: 60, mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
|
||||||
|
{ name: "HDMI-A-1", width: 2560, height: 1440, refreshRate: 60, mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
|
||||||
|
];
|
||||||
SettingsBackup.protectedDisplays = root.displayGeneration;
|
SettingsBackup.protectedDisplays = root.displayGeneration;
|
||||||
|
SettingsBackup.protectedDisplayLayout = root.liveLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(output: string): bool {
|
function apply(output: string): bool {
|
||||||
@@ -88,6 +136,11 @@ ShellRoot {
|
|||||||
return SettingsBackup.restore("settings-20260818-010203004.json");
|
return SettingsBackup.restore("settings-20260818-010203004.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDisplayFailure(output: string): bool {
|
||||||
|
root.displayApplyAccepted = false;
|
||||||
|
return SettingsBackup.handleRestoreOutput(output);
|
||||||
|
}
|
||||||
|
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
calls: root.calls,
|
calls: root.calls,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user