Compare commits
41
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 |
@@ -21,3 +21,4 @@ __pycache__/
|
||||
/config/dot/gtk-3.0/settings.ini
|
||||
/config/dot/gtk-4.0/settings.ini
|
||||
/config/dot/tmux/current-theme.conf
|
||||
/config/dot/hypr/hyprlock.conf
|
||||
|
||||
@@ -152,11 +152,14 @@ case "$cmd" in
|
||||
|
||||
# 2.4) Run flatpak updates (user then system)
|
||||
flatpak update -y
|
||||
sudo flatpak update
|
||||
sudo flatpak update -y
|
||||
|
||||
# 2.5) Optional firmware via fwupd
|
||||
if $firmware; then
|
||||
sudo fwupdmgr refresh
|
||||
# fwupdmgr exits non-zero when metadata is already current -- that is
|
||||
# not an error, but under 'set -e' it would abort the script before
|
||||
# 'fwupdmgr update' ever runs.
|
||||
sudo fwupdmgr refresh || true
|
||||
sudo fwupdmgr update
|
||||
fi
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ than presenting inert Hyprland controls.
|
||||
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
||||
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
||||
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
|
||||
| Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
|
||||
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
|
||||
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||
| 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 + CTRL + H/J/K/L` | Swap window |
|
||||
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
|
||||
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter |
|
||||
| `SUPER + SHIFT + I/U` · `,/N` | Taller · shorter |
|
||||
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
|
||||
| `SUPER + Q` | Close |
|
||||
| `SUPER + U` | Fullscreen |
|
||||
|
||||
@@ -23,8 +23,16 @@ hl.on("hyprland.start", function()
|
||||
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
|
||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
|
||||
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
|
||||
-- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
|
||||
-- commands without waiting for them to finish, so the dbus-update call
|
||||
-- above racing this one is not safe to assume complete -- a lost race
|
||||
-- leaves the Condition unmet and the unit silently never starts (exit 0,
|
||||
-- no error). hypridle is the only listener for the logind Lock signal,
|
||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||
-- synchronously in the same shell invocation first so the Condition
|
||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
|
||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
general {
|
||||
# `pidof` guard prevents stacking lockers if this fires twice.
|
||||
lock_cmd = pidof hyprlock || hyprlock
|
||||
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
|
||||
before_sleep_cmd = loginctl lock-session
|
||||
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hyprlock — lock screen
|
||||
# hyprlock — lock screen.
|
||||
#
|
||||
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
|
||||
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
|
||||
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||
# next 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.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -45,7 +54,7 @@ background {
|
||||
vibrancy_darkness = 0.05
|
||||
|
||||
# Shown if the screenshot is unavailable.
|
||||
color = rgba(34, 36, 54, 1.0)
|
||||
color = rgba(@BG@, 1.0)
|
||||
|
||||
zindex = -1
|
||||
}
|
||||
@@ -54,7 +63,7 @@ background {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:1000] date +"%-I:%M"
|
||||
color = rgba(200, 211, 245, 1.0)
|
||||
color = rgba(@FG@, 1.0)
|
||||
font_size = 120
|
||||
font_family = Adwaita Sans Light
|
||||
position = 0, 260
|
||||
@@ -65,7 +74,7 @@ label {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:60000] date +"%A, %B %-d"
|
||||
color = rgba(130, 139, 184, 1.0)
|
||||
color = rgba(@MUTED@, 1.0)
|
||||
font_size = 24
|
||||
font_family = Adwaita Sans
|
||||
position = 0, 160
|
||||
@@ -84,18 +93,18 @@ input-field {
|
||||
outline_thickness = 2
|
||||
rounding = 26
|
||||
|
||||
outer_color = rgba(130, 170, 255, 0.9)
|
||||
inner_color = rgba(46, 47, 61, 0.85)
|
||||
font_color = rgba(200, 211, 245, 1.0)
|
||||
check_color = rgba(130, 170, 255, 1.0)
|
||||
fail_color = rgba(255, 117, 127, 1.0)
|
||||
outer_color = rgba(@ACCENT@, 0.9)
|
||||
inner_color = rgba(@FIELD@, 0.85)
|
||||
font_color = rgba(@FG@, 1.0)
|
||||
check_color = rgba(@ACCENT@, 1.0)
|
||||
fail_color = rgba(@ERROR@, 1.0)
|
||||
|
||||
dots_size = 0.25
|
||||
dots_spacing = 0.3
|
||||
dots_center = true
|
||||
|
||||
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
|
||||
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
|
||||
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
|
||||
fade_on_empty = false
|
||||
hide_input = false
|
||||
@@ -105,7 +114,7 @@ input-field {
|
||||
label {
|
||||
monitor =
|
||||
text = $USER
|
||||
color = rgba(200, 211, 245, 0.9)
|
||||
color = rgba(@FG@, 0.9)
|
||||
font_size = 16
|
||||
font_family = Adwaita Sans
|
||||
position = 0, -110
|
||||
@@ -192,12 +192,27 @@ bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative =
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
-- SUPER+SHIFT+P was double-bound with the 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" })
|
||||
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
|
||||
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
-- showing what you are choosing between.
|
||||
--
|
||||
-- The gesture needs three binds, not two. Tab steps the selection, and the
|
||||
-- switch is only COMMITTED when the modifier is released -- which is the sole
|
||||
-- way the compositor can tell the gesture is finished. That release bind is on
|
||||
-- the bare modifier, so it fires on EVERY Super release in the session; the
|
||||
-- handler returns immediately when no switch is open, which is why this is
|
||||
-- affordable.
|
||||
--
|
||||
-- The release bind carries no description on purpose: it is not a shortcut
|
||||
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
|
||||
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
|
||||
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
|
||||
-- Jump back to the previously focused window.
|
||||
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
||||
|
||||
@@ -211,6 +226,17 @@ bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description
|
||||
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
||||
-- moving right past the last workspace creates a new one, and moving left from
|
||||
-- the first clamps instead of wrapping.
|
||||
|
||||
-- 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 + 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" })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- Look and feel -- Tokyo Night Moon
|
||||
--
|
||||
-- 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
|
||||
--
|
||||
-- Performance note: every animation below is event-driven. Nothing uses the
|
||||
@@ -14,6 +14,30 @@
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- Mirrors config/Theme.qml's `accents` map. Lua has no import path into a QML
|
||||
-- singleton, so the hex pairs are restated here -- just the four hex strings
|
||||
-- each named accent needs, not the labels, which stay UI-only.
|
||||
local accents = {
|
||||
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
|
||||
orchid = { dark = "c099ff", darkSecondary = "fca7ea", light = "7847bd", lightSecondary = "9854f1" },
|
||||
teal = { dark = "86e1fc", darkSecondary = "82aaff", light = "007197", lightSecondary = "2e7de9" },
|
||||
green = { dark = "c3e88d", darkSecondary = "86e1fc", light = "587539", lightSecondary = "007197" },
|
||||
amber = { dark = "ffc777", darkSecondary = "ff966c", light = "8c6c3e", lightSecondary = "b15c00" },
|
||||
orange = { dark = "ff966c", darkSecondary = "ff757f", light = "b15c00", lightSecondary = "c64343" },
|
||||
rose = { dark = "ff757f", darkSecondary = "c099ff", light = "f52a65", lightSecondary = "9854f1" },
|
||||
slate = { dark = "828bb8", darkSecondary = "82aaff", light = "6172b0", lightSecondary = "2e7de9" },
|
||||
}
|
||||
|
||||
-- The accent pair a fresh session or `hyprctl reload` starts from.
|
||||
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
|
||||
-- data, once the shell settles (~1.2s after startup -- see its `settle`
|
||||
-- Timer). This table exists only so the compositor is never observably blue
|
||||
-- for a non-blue accent during the gap before that first live apply.
|
||||
local accentScheme = prefs.get("colorScheme", "dark")
|
||||
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
|
||||
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
|
||||
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
gaps_in = prefs.get("gapsIn", 5),
|
||||
@@ -22,11 +46,13 @@ hl.config({
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The focused accent role: blue leads, orchid follows, on a
|
||||
-- diagonal so the pair is visible on both a tall and a wide
|
||||
-- window. ColorScheme never writes this role; a future accent
|
||||
-- picker can own it without fighting light/dark mode.
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- The focused accent role, on a diagonal so the pair is visible
|
||||
-- on both a tall and a wide window. Driven by the chosen
|
||||
-- accentName (see the `accents` table above); services/
|
||||
-- ColorScheme.qml applies the same values live, and restates them
|
||||
-- from Theme.accent/Theme.accentSecondary on every scheme change
|
||||
-- too, since each accent carries a separate pair per scheme.
|
||||
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
|
||||
-- The neutral inactive role follows the colour scheme because a
|
||||
-- dark neutral disappears against a light desktop.
|
||||
-- services/ColorScheme.qml applies the same values live; this is
|
||||
@@ -104,11 +130,13 @@ hl.config({
|
||||
-- New in 0.56. Kept deliberately faint: in this direction the gradient
|
||||
-- border is the signature, and a strong halo would compete with it.
|
||||
-- This is just enough to lift the focused window off the wallpaper.
|
||||
-- Derived from the same accent as active_border above, not hardcoded,
|
||||
-- so the halo never disagrees with the border it surrounds.
|
||||
glow = {
|
||||
enabled = prefs.get("glowEnabled", true),
|
||||
range = prefs.get("glowRange", 8),
|
||||
render_power = 2,
|
||||
color = "rgba(82aaff33)",
|
||||
color = "rgba(" .. accentStart .. "33)",
|
||||
color_inactive = "rgba(00000000)",
|
||||
},
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||
-- { ["DP-2"] = {
|
||||
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
||||
-- x = 0, y = 0, primary = true,
|
||||
-- } }
|
||||
--
|
||||
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||
-- stay here, because those are the settings with a documented reason attached
|
||||
@@ -71,6 +74,26 @@ local function valid_transform(transform)
|
||||
and transform <= 3
|
||||
end
|
||||
|
||||
local function valid_coordinate(value)
|
||||
return type(value) == "number"
|
||||
and value == value
|
||||
and value == math.floor(value)
|
||||
and value >= -100000
|
||||
and value <= 100000
|
||||
end
|
||||
|
||||
local function valid_position(entry)
|
||||
return valid_coordinate(entry.x) and valid_coordinate(entry.y)
|
||||
end
|
||||
|
||||
local function valid_primary(entry)
|
||||
return type(entry.primary) == "boolean"
|
||||
end
|
||||
|
||||
local function has_layout_fields(entry)
|
||||
return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil
|
||||
end
|
||||
|
||||
local function display_entry(output)
|
||||
if type(output) ~= "string" or output == ""
|
||||
or output:match("^[%w_.-]+$") == nil then
|
||||
@@ -85,9 +108,23 @@ local function display_entry(output)
|
||||
or not valid_transform(entry.transform) then
|
||||
return nil
|
||||
end
|
||||
-- Legacy records have none of the layout fields and keep automatic
|
||||
-- placement. A partially written extended record is unsafe: accepting its
|
||||
-- mode but guessing its position could overlap or strand another output.
|
||||
if has_layout_fields(entry)
|
||||
and (not valid_position(entry) or not valid_primary(entry)) then
|
||||
return nil
|
||||
end
|
||||
return entry
|
||||
end
|
||||
|
||||
local function display_position(entry, fallback)
|
||||
if entry ~= nil and has_layout_fields(entry) then
|
||||
return string.format("%dx%d", entry.x, entry.y)
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
local shipped_mode = "4500x3000@60"
|
||||
local shipped_scale = 1.5
|
||||
local shipped_transform = 0
|
||||
@@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2")
|
||||
hl.monitor({
|
||||
output = "DP-2",
|
||||
mode = dp2 and dp2.mode or shipped_mode,
|
||||
position = "0x0",
|
||||
position = display_position(dp2, "0x0"),
|
||||
scale = dp2 and dp2.scale or shipped_scale,
|
||||
transform = dp2 and dp2.transform or shipped_transform,
|
||||
|
||||
@@ -119,7 +156,7 @@ for output, _ in pairs(displays) do
|
||||
hl.monitor({
|
||||
output = output,
|
||||
mode = entry.mode,
|
||||
position = "auto",
|
||||
position = display_position(entry, "auto"),
|
||||
scale = entry.scale,
|
||||
transform = entry.transform,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,7 +467,7 @@ Singleton {
|
||||
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "followMouse", type: "enum", def: 1, group: "input",
|
||||
key: "followMouse", type: "enum", def: 1, group: "pointer",
|
||||
label: "Pointer focus",
|
||||
detail: "What moving the pointer does to which window is focused",
|
||||
// These labels were wrong, and wrong in the worst way: value 1 was
|
||||
@@ -495,7 +495,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
|
||||
group: "input",
|
||||
group: "pointer",
|
||||
label: "Pointer speed",
|
||||
detail: "Zero is flat, unaccelerated response",
|
||||
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
|
||||
@@ -747,6 +747,66 @@ Singleton {
|
||||
label: "Wallpaper",
|
||||
detail: "Shown on every output"
|
||||
},
|
||||
{
|
||||
key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
|
||||
label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
|
||||
options: [
|
||||
{ value: "single", label: "Single" },
|
||||
{ value: "slideshow", label: "Slideshow" },
|
||||
{ value: "per-monitor", label: "Per display" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
|
||||
label: "Slideshow collection", detail: "Backgrounds selected for rotation"
|
||||
},
|
||||
{
|
||||
key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
|
||||
unit: "min", group: "wallpaper", label: "Change background every",
|
||||
detail: "Time between slideshow images"
|
||||
},
|
||||
{
|
||||
key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
|
||||
label: "Shuffle", detail: "Show every selected image before repeating"
|
||||
},
|
||||
{
|
||||
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
|
||||
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
|
||||
},
|
||||
|
||||
// ── Lock-screen appearance ─────────────────────────────────────────
|
||||
// scripts/panama-lock validates these again before generating a state
|
||||
// config. The tracked hyprlock.conf remains the safe fallback.
|
||||
{
|
||||
key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
|
||||
label: "Background", detail: "What appears behind the lock screen",
|
||||
options: [
|
||||
{ value: "screenshot", label: "Blurred desktop" },
|
||||
{ value: "wallpaper", label: "Current wallpaper" },
|
||||
{ value: "solid", label: "Solid color" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
|
||||
group: "lockAppearance", label: "Background blur",
|
||||
detail: "Softens what is behind the password field"
|
||||
},
|
||||
{
|
||||
key: "lockShowClock", type: "bool", def: true, group: "lockAppearance",
|
||||
label: "Show clock", detail: "Use the desktop's 12 or 24-hour format"
|
||||
},
|
||||
{
|
||||
key: "lockShowDate", type: "bool", def: true, group: "lockAppearance",
|
||||
label: "Show date", detail: "Show the weekday and full date"
|
||||
},
|
||||
{
|
||||
key: "lockShowUser", type: "bool", def: true, group: "lockAppearance",
|
||||
label: "Show user name", detail: "Identify the signed-in account"
|
||||
},
|
||||
{
|
||||
key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance",
|
||||
label: "Hide password field until typing", detail: "Keep the empty field out of the way"
|
||||
},
|
||||
|
||||
// ── Idle, lock, and sleep ───────────────────────────────────────────
|
||||
// Written into a generated hypridle config; see scripts/panama-idle.
|
||||
@@ -811,6 +871,26 @@ Singleton {
|
||||
{ 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
|
||||
@@ -1131,7 +1211,7 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── 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
|
||||
// bit depth are deliberately not here: those carry a documented
|
||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||
@@ -1140,7 +1220,7 @@ Singleton {
|
||||
key: "displays", type: "json", def: ({}), group: "display",
|
||||
internal: true,
|
||||
label: "Display configuration",
|
||||
detail: "Resolution, scale, and rotation per connected display"
|
||||
detail: "Resolution, scale, rotation, position, and primary display"
|
||||
},
|
||||
|
||||
// ── Per-application notification rules ──────────────────────────────
|
||||
|
||||
@@ -42,13 +42,41 @@ Singleton {
|
||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||
|
||||
// The pair. `accent` is the primary and carries every state meaning
|
||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
||||
// theme — it never appears alone, only as the far end of a gradient. That
|
||||
// restraint is the whole point: the two colours meeting is the signature,
|
||||
// so the pink stops being special the moment it's used as a flat fill.
|
||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||
// ── The accent ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// `accent` is the primary and carries every state meaning (focused, active,
|
||||
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||
// gradient. That restraint is the whole point: the two colours meeting is
|
||||
// the signature, so the second colour stops being special the moment it is
|
||||
// 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 cyan: root.dark ? "#86e1fc" : "#007197"
|
||||
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 : "",
|
||||
scale: monitor ? monitor.scale : 0,
|
||||
transform: monitor ? monitor.transform : -1,
|
||||
x: monitor ? monitor.x : 0,
|
||||
y: monitor ? monitor.y : 0,
|
||||
primary: monitor ? monitor.primary : false,
|
||||
modes: monitor ? monitor.modes.length : 0,
|
||||
awaiting: Displays.awaitingConfirmation,
|
||||
canConfirm: Displays.canConfirm,
|
||||
@@ -36,6 +39,49 @@ ShellRoot {
|
||||
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||
}
|
||||
|
||||
function transactionStatus(): string {
|
||||
return JSON.stringify({
|
||||
layout: Displays.currentLayout(),
|
||||
pending: Displays.pendingRequestedLayout,
|
||||
previous: Displays.pendingPreviousLayout,
|
||||
reverting: Displays.revertExpectedLayout,
|
||||
awaiting: Displays.awaitingConfirmation,
|
||||
canConfirm: Displays.canConfirm,
|
||||
busy: Displays.busy,
|
||||
generation: Displays.operationGeneration,
|
||||
revertGeneration: Displays.revertGeneration,
|
||||
lastError: Displays.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayoutFixture(secondX: int, secondY: int): bool {
|
||||
const layout = Displays.currentLayout();
|
||||
if (layout.length !== 2) return false;
|
||||
layout[0].x = 0;
|
||||
layout[0].y = 0;
|
||||
layout[0].primary = true;
|
||||
layout[1].x = secondX;
|
||||
layout[1].y = secondY;
|
||||
layout[1].primary = false;
|
||||
return Displays.applyLayout(layout);
|
||||
}
|
||||
|
||||
function makePrimaryFixture(output: string): bool {
|
||||
return Displays.makePrimary(output);
|
||||
}
|
||||
|
||||
function injectReadback(text: string, generation: int): void {
|
||||
Displays.parse(text, generation);
|
||||
}
|
||||
|
||||
function expireApplyVerification(): void {
|
||||
Displays.verificationTimedOut();
|
||||
}
|
||||
|
||||
function expireRevertVerification(): void {
|
||||
Displays.revertVerificationTimedOut();
|
||||
}
|
||||
|
||||
function refreshIdentityFixture(): string {
|
||||
const modes = Displays.normaliseModes([
|
||||
"[email protected]",
|
||||
@@ -49,6 +95,30 @@ ShellRoot {
|
||||
});
|
||||
}
|
||||
|
||||
function positionFixture(): string {
|
||||
const previous = Displays.monitors;
|
||||
Displays.parse(JSON.stringify([
|
||||
{
|
||||
name: "DP-2", description: "Primary", width: 4500, height: 3000,
|
||||
refreshRate: 60, scale: 1.5, transform: 0, x: 140, y: 80,
|
||||
availableModes: ["[email protected]"]
|
||||
},
|
||||
{
|
||||
name: "HDMI-A-1", description: "Second", width: 2560, height: 1440,
|
||||
refreshRate: 60, scale: 1, transform: 0, x: 3140, y: 80,
|
||||
availableModes: ["[email protected]"]
|
||||
}
|
||||
]), Displays.operationGeneration);
|
||||
const result = JSON.stringify(Displays.monitors.map(monitor => ({
|
||||
name: monitor.name,
|
||||
x: monitor.x,
|
||||
y: monitor.y,
|
||||
primary: monitor.primary
|
||||
})));
|
||||
Displays.monitors = previous;
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyBad(kind: string): bool {
|
||||
const monitor = Displays.monitors[0];
|
||||
if (!monitor) return false;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "lock-screen-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
generated: LockScreen.generated,
|
||||
path: LockScreen.path,
|
||||
fallback: LockScreen.fallback,
|
||||
lastError: LockScreen.lastError,
|
||||
busy: LockScreen.busy
|
||||
});
|
||||
}
|
||||
|
||||
function burst(): void {
|
||||
DesktopPreferences.set("lockBlurLevel", 1);
|
||||
DesktopPreferences.set("lockBlurLevel", 2);
|
||||
DesktopPreferences.set("lockBlurLevel", 4);
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
LockScreen.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
readonly property string fixtureWallpaper: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
||||
|
||||
LockScreenPreview {
|
||||
id: screenshotPreview
|
||||
width: 620
|
||||
backgroundMode: "screenshot"
|
||||
blurLevel: 4
|
||||
showClock: true
|
||||
showDate: true
|
||||
showUser: true
|
||||
fadeOnEmpty: false
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
LockScreenPreview {
|
||||
id: wallpaperPreview
|
||||
width: 620
|
||||
backgroundMode: "wallpaper"
|
||||
blurLevel: 2
|
||||
showClock: false
|
||||
showDate: true
|
||||
showUser: false
|
||||
fadeOnEmpty: true
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
LockScreenPreview {
|
||||
id: solidPreview
|
||||
width: 620
|
||||
backgroundMode: "solid"
|
||||
blurLevel: 0
|
||||
showClock: true
|
||||
showDate: false
|
||||
showUser: true
|
||||
fadeOnEmpty: false
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "lock-screen-settings-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
screenshot: {
|
||||
mode: screenshotPreview.previewMode,
|
||||
wallpaperVisible: screenshotPreview.wallpaperVisible,
|
||||
blurStrength: screenshotPreview.blurStrength,
|
||||
clockVisible: screenshotPreview.clockVisible,
|
||||
dateVisible: screenshotPreview.dateVisible,
|
||||
userVisible: screenshotPreview.userVisible,
|
||||
passwordVisible: screenshotPreview.passwordVisible
|
||||
},
|
||||
wallpaper: {
|
||||
mode: wallpaperPreview.previewMode,
|
||||
wallpaperVisible: wallpaperPreview.wallpaperVisible,
|
||||
blurStrength: wallpaperPreview.blurStrength,
|
||||
clockVisible: wallpaperPreview.clockVisible,
|
||||
dateVisible: wallpaperPreview.dateVisible,
|
||||
userVisible: wallpaperPreview.userVisible,
|
||||
passwordVisible: wallpaperPreview.passwordVisible
|
||||
},
|
||||
solid: {
|
||||
mode: solidPreview.previewMode,
|
||||
wallpaperVisible: solidPreview.wallpaperVisible,
|
||||
blurStrength: solidPreview.blurStrength,
|
||||
clockVisible: solidPreview.clockVisible,
|
||||
dateVisible: solidPreview.dateVisible,
|
||||
userVisible: solidPreview.userVisible,
|
||||
passwordVisible: solidPreview.passwordVisible
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.toggle("activity")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
|
||||
onSecondaryActivated: ShellState.openSettings("privacy")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
|
||||
@@ -25,10 +25,18 @@ PanelWindow {
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
// Deliberately does not read Capture.recordingSeconds (or anything else
|
||||
// that ticks once a second): this array is a plain JS array, not an
|
||||
// identity-preserving model, so any dependency that changes every second
|
||||
// would make the whole thing re-derive every second, and the Repeater
|
||||
// below would destroy and recreate every row -- including one the user
|
||||
// might be hovering or about to click. The set of activities should only
|
||||
// change when an activity actually starts or stops. Elapsed time is
|
||||
// rendered by each row's own Text binding instead, further down.
|
||||
readonly property var activities: {
|
||||
const result = [];
|
||||
if (PrivacyState.recordingActive)
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true });
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
|
||||
if (PrivacyState.screenSharingActive)
|
||||
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
||||
if (PrivacyState.cameraActive)
|
||||
@@ -38,11 +46,12 @@ PanelWindow {
|
||||
return result;
|
||||
}
|
||||
|
||||
function elapsed(): string {
|
||||
const total = Capture.recordingSeconds;
|
||||
const seconds = String(total % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(total / 60) % 60;
|
||||
const hours = Math.floor(total / 3600);
|
||||
// Pure formatter, no ticking property read here -- callers decide what
|
||||
// seconds value to pass, and only they take on the per-second dependency.
|
||||
function formatElapsed(totalSeconds: int): string {
|
||||
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
@@ -164,7 +173,11 @@ PanelWindow {
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: activityRow.modelData.detail
|
||||
// Only this Text re-evaluates every second while
|
||||
// recording -- Capture.recordingSeconds is read
|
||||
// here, not in the parent `activities` array, so
|
||||
// the row itself is never torn down for a tick.
|
||||
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -15,6 +15,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.openDateMenu("agenda")
|
||||
|
||||
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
|
||||
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||
|
||||
ToolTip.visible: root.hovered && root.visible
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
// The calendar that drops out of the clock — GNOME's date menu, minus the
|
||||
// notification list (that lives in its own panel). Month grid, today marked
|
||||
// with the accent, arrows to page through months, current weather at the foot.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Popover {
|
||||
id: root
|
||||
|
||||
// Popover's container is a plain Item, which does not derive an implicit
|
||||
// size from its children, so the window has to be sized from the body.
|
||||
implicitWidth: body.implicitWidth + contentPadding * 2
|
||||
implicitHeight: body.implicitHeight + contentPadding * 2
|
||||
|
||||
readonly property int cellSize: 34
|
||||
readonly property int cellHeight: 30
|
||||
|
||||
// Hours precision is enough: the only thing that has to change on its own
|
||||
// is which cell counts as "today", and that only moves at midnight.
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Hours
|
||||
}
|
||||
|
||||
readonly property int todayYear: clock.date.getFullYear()
|
||||
readonly property int todayMonth: clock.date.getMonth()
|
||||
readonly property int todayDay: clock.date.getDate()
|
||||
|
||||
// The month currently on screen. Reset to today every time the popover
|
||||
// opens, so it never comes back showing wherever you paged off to.
|
||||
property int viewYear: root.todayYear
|
||||
property int viewMonth: root.todayMonth
|
||||
|
||||
onVisibleChanged: if (visible)
|
||||
root.showToday()
|
||||
|
||||
function showToday(): void {
|
||||
root.viewYear = root.todayYear;
|
||||
root.viewMonth = root.todayMonth;
|
||||
}
|
||||
|
||||
function stepMonth(delta: int): void {
|
||||
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
|
||||
root.viewYear = d.getFullYear();
|
||||
root.viewMonth = d.getMonth();
|
||||
}
|
||||
|
||||
// Six weeks of cells, so the grid height never changes as you page through
|
||||
// months. Days from the neighbouring months fill the edges, dimmed.
|
||||
readonly property var cells: {
|
||||
const first = new Date(root.viewYear, root.viewMonth, 1);
|
||||
const offset = first.getDay(); // 0 = Sunday, matching the header row
|
||||
const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate();
|
||||
const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate();
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const n = i - offset + 1;
|
||||
if (n < 1)
|
||||
out.push({
|
||||
day: inPrevMonth + n,
|
||||
current: false
|
||||
});
|
||||
else if (n > inThisMonth)
|
||||
out.push({
|
||||
day: n - inThisMonth,
|
||||
current: false
|
||||
});
|
||||
else
|
||||
out.push({
|
||||
day: n,
|
||||
current: true
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Month header ────────────────────────────────────────────────────
|
||||
Item {
|
||||
width: root.cellSize * 7
|
||||
height: 28
|
||||
|
||||
CalendarArrow {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "\u{F0141}" // md-chevron_left
|
||||
onActivated: root.stepMonth(-1)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.fg
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.showToday()
|
||||
}
|
||||
}
|
||||
|
||||
CalendarArrow {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "\u{F0142}" // md-chevron_right
|
||||
onActivated: root.stepMonth(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekday header ──────────────────────────────────────────────────
|
||||
Row {
|
||||
Repeater {
|
||||
model: ["S", "M", "T", "W", "T", "F", "S"]
|
||||
|
||||
delegate: Text {
|
||||
required property string modelData
|
||||
|
||||
width: root.cellSize
|
||||
height: 20
|
||||
text: modelData
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Day grid ────────────────────────────────────────────────────────
|
||||
Grid {
|
||||
columns: 7
|
||||
|
||||
Repeater {
|
||||
model: root.cells
|
||||
|
||||
delegate: Item {
|
||||
id: cell
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
|
||||
|
||||
width: root.cellSize
|
||||
height: root.cellHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: root.cellHeight - 2
|
||||
height: root.cellHeight - 2
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
visible: cell.isToday
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: cell.modelData.day
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
|
||||
|
||||
color: {
|
||||
if (cell.isToday)
|
||||
return Theme.bgDark;
|
||||
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
width: root.cellSize * 7
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
visible: Weather.available
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Weather.available
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Weather.icon
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
color: Theme.accentAlt
|
||||
}
|
||||
|
||||
Column {
|
||||
Text {
|
||||
text: Math.round(Weather.temperature) + Weather.unitSuffix
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Weather.description
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.fgDim
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ Pill {
|
||||
|
||||
onActivated: ShellState.toggleDateMenu("agenda")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
|
||||
// format toggles are mirrored on Appearance, but someone right-clicking a
|
||||
// clock is far more often after the time itself than its typography.
|
||||
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Qt.formatDateTime(clock.date, root.format)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
@@ -34,6 +35,9 @@ Pill {
|
||||
onActivated: if (root.player?.canTogglePlaying)
|
||||
root.player.togglePlaying()
|
||||
|
||||
// Right-click opens the settings that govern this widget. Output device and per-application volume.
|
||||
onSecondaryActivated: ShellState.openSettings("sound")
|
||||
|
||||
// Scroll up = previous, down = next — the same direction as the workspace
|
||||
// switcher, so the whole bar scrolls consistently.
|
||||
onScrolled: delta => {
|
||||
|
||||
@@ -24,6 +24,9 @@ Pill {
|
||||
|
||||
onActivated: root.requestQuickSettings()
|
||||
|
||||
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
|
||||
onSecondaryActivated: ShellState.openSettings("connectivity")
|
||||
|
||||
// ── Audio ───────────────────────────────────────────────────────────────
|
||||
// Without a tracker, volume and muted silently read as zero/false.
|
||||
PwObjectTracker {
|
||||
|
||||
@@ -13,6 +13,10 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Right-click opens the settings that govern this widget. Which readouts
|
||||
// appear in the bar, and how often they update.
|
||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||
|
||||
interactive: false
|
||||
|
||||
Row {
|
||||
|
||||
@@ -10,6 +10,9 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
|
||||
onSecondaryActivated: ShellState.openSettings("home")
|
||||
|
||||
interactive: false
|
||||
visible: Weather.available
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
// ── Intellihide ─────────────────────────────────────────────────────────
|
||||
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
||||
// scope a per-screen bar to its own screen. Falls back to null when this
|
||||
// Dock is created standalone (no `screen` set) rather than via Variants.
|
||||
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
|
||||
|
||||
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
|
||||
// be computed. "Is anything on this workspace at all" is the robust proxy,
|
||||
// and it is what Dash-to-Dock's all-windows intellihide felt like in
|
||||
// practice: an empty workspace keeps the dock out.
|
||||
//
|
||||
// Deliberately this instance's own monitor's active workspace, not the
|
||||
// globally-focused one -- with one Dock per screen, keying off the global
|
||||
// focus would make focusing an empty workspace on monitor A hide the dock
|
||||
// on monitor B even though B's own workspace is still busy.
|
||||
readonly property bool workspaceOccupied: {
|
||||
const ws = Hyprland.focusedWorkspace;
|
||||
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
|
||||
return !!ws && ws.toplevels.values.length > 0;
|
||||
}
|
||||
|
||||
@@ -83,15 +93,31 @@ PanelWindow {
|
||||
onTriggered: root.revealed = false
|
||||
}
|
||||
|
||||
// Other modules (the bar, the capture overlay) read this.
|
||||
onRevealedChanged: ShellState.dockRevealed = revealed
|
||||
// Other modules (the bar, the capture overlay) read this. It is one
|
||||
// shared flag but there is one Dock per monitor, so only the instance on
|
||||
// the currently-focused monitor is allowed to write it -- otherwise
|
||||
// whichever instance last changed reveal state would stomp the others,
|
||||
// and a reader would see an arbitrary monitor's value. This scopes the
|
||||
// flag to mean "is the dock revealed on the monitor the user is on",
|
||||
// which is what a capture overlay or the bar actually care about.
|
||||
// (A true per-monitor flag would need ShellState.dockRevealed itself to
|
||||
// become keyed by screen, which is out of scope here -- see the report.)
|
||||
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
|
||||
|
||||
onRevealedChanged: root._syncShellState()
|
||||
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
||||
|
||||
function _syncShellState(): void {
|
||||
if (root.isFocusedMonitorInstance)
|
||||
ShellState.dockRevealed = root.revealed;
|
||||
}
|
||||
|
||||
// wantRevealed's first evaluation emits no change signal when it lands on
|
||||
// false (the default), so the initial state has to be taken explicitly —
|
||||
// otherwise a shell started on a busy workspace would leave the dock up.
|
||||
Component.onCompleted: {
|
||||
revealed = wantRevealed;
|
||||
ShellState.dockRevealed = revealed;
|
||||
root._syncShellState();
|
||||
}
|
||||
|
||||
// ── Input region ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// GNOME's message tray: everything that has arrived, grouped by app.
|
||||
//
|
||||
// Drops from the top centre because that is where GNOME's tray lives and the
|
||||
// muscle memory is the whole point. Layer-shell centres a surface on whichever
|
||||
// axis it is not anchored to, so anchoring only `top` does it.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: ShellState.notificationsOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 440
|
||||
implicitHeight: surface.implicitHeight
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-notifications"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
Notifs.markAllRead();
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
windows: [root]
|
||||
active: root.visible
|
||||
onCleared: ShellState.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
|
||||
// border so the two don't fight for the same row of pixels.
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 32
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Notifications"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
IconButton {
|
||||
size: 30
|
||||
iconSize: 16
|
||||
icon: "notifications-disabled-symbolic"
|
||||
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
|
||||
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: clearLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
visible: Notifs.hasNotifications
|
||||
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: clearLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Clear all"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: clearMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Notifs.dismissAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ─────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 120
|
||||
visible: !Notifs.hasNotifications
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grouped history ─────────────────────────────────────────
|
||||
ScrollColumn {
|
||||
width: parent.width
|
||||
maxHeight: 620
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Notifs.hasNotifications
|
||||
|
||||
Repeater {
|
||||
model: Notifs.groups
|
||||
|
||||
Column {
|
||||
id: group
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 26
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: group.modelData.app
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
IconButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
iconSize: 13
|
||||
tint: Theme.fgDim
|
||||
icon: "edit-clear-all-symbolic"
|
||||
iconFallback: "window-close-symbolic"
|
||||
onClicked: Notifs.dismissApp(group.modelData.app)
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: group.modelData.items
|
||||
|
||||
NotificationCard {
|
||||
required property var modelData
|
||||
|
||||
width: group.width
|
||||
compact: true
|
||||
notification: modelData
|
||||
onDismissed: Notifs.dismiss(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// A single banner: a notification card that slides in and times itself out.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
@@ -14,28 +13,25 @@ Item {
|
||||
// into a keyboard focus request on the layer surface.
|
||||
signal replyFocusChanged(bool focused)
|
||||
|
||||
// Mirrors the signal above so the dismiss timer below can read it.
|
||||
property bool replyFocused: false
|
||||
|
||||
implicitHeight: card.implicitHeight
|
||||
|
||||
// Critical notifications stay until dismissed (the setting is 0). An app
|
||||
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
||||
// "server decides", which is our default.
|
||||
readonly property int timeoutMs: {
|
||||
if (root.notification.urgency === NotificationUrgency.Critical)
|
||||
return Settings.notificationTimeoutCriticalMs;
|
||||
if (root.notification.expireTimeout === 0)
|
||||
return 0;
|
||||
if (root.notification.expireTimeout > 0)
|
||||
return Math.round(root.notification.expireTimeout * 1000);
|
||||
return Settings.notificationTimeoutMs;
|
||||
}
|
||||
// "server decides", which is our default. Shared with Notifs.qml, which
|
||||
// gives a DND-hidden transient notification the same lifetime.
|
||||
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
Timer {
|
||||
// Hovering holds the banner open; the countdown restarts on leave.
|
||||
running: root.timeoutMs > 0 && !hover.hovered
|
||||
// Hovering, or actively typing a reply, holds the banner open; the
|
||||
// countdown restarts (from the top, same as hover) once both let go.
|
||||
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
|
||||
interval: root.timeoutMs
|
||||
onTriggered: Notifs.dropPopup(root.notification)
|
||||
}
|
||||
@@ -45,7 +41,10 @@ Item {
|
||||
width: parent.width
|
||||
notification: root.notification
|
||||
onDismissed: Notifs.dropPopup(root.notification)
|
||||
onReplyFocusChanged: focused => root.replyFocusChanged(focused)
|
||||
onReplyFocusChanged: focused => {
|
||||
root.replyFocused = focused;
|
||||
root.replyFocusChanged(focused);
|
||||
}
|
||||
|
||||
// Slide in from the right edge. Runs once, on creation.
|
||||
NumberAnimation on x {
|
||||
|
||||
@@ -45,7 +45,17 @@ PanelWindow {
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
// Notifs.popups.slice() is a fresh array on every change (a new
|
||||
// arrival, a dismissal, a sibling toast timing out). Handing that
|
||||
// straight to Repeater would reset the model and rebuild every
|
||||
// delegate each time, blowing away whichever toast has a reply
|
||||
// field mid-typing. ScriptModel diffs by object identity
|
||||
// (Notification instances are unique QObjects), so only genuinely
|
||||
// added/removed notifications add/remove delegates — unrelated
|
||||
// toasts, and their slide-in animations, are untouched.
|
||||
model: ScriptModel {
|
||||
values: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
}
|
||||
|
||||
Toast {
|
||||
required property var modelData
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// The three system power profiles, as an expandable list.
|
||||
//
|
||||
// A row per profile rather than a cycling button: there are three, and cycling
|
||||
// through them means passing through one you did not want on a machine where
|
||||
// the change is immediate and audible. The same shape the audio device lists
|
||||
// use, so the panel reads consistently.
|
||||
//
|
||||
// The daemon owns the profile -- it survives a shell restart and anything else
|
||||
// on the system can change it -- so this reads back rather than assuming, the
|
||||
// same as monitor brightness.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: PowerProfiles.profiles
|
||||
|
||||
RowButton {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
icon: {
|
||||
switch (modelData) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
label: PowerProfiles.label(modelData)
|
||||
sublabel: PowerProfiles.detail(modelData)
|
||||
selected: modelData === PowerProfiles.active
|
||||
dimmed: PowerProfiles.busy
|
||||
onClicked: PowerProfiles.set(modelData)
|
||||
}
|
||||
}
|
||||
|
||||
// Only when it is true. A machine that cannot deliver the profile it is set
|
||||
// to is the one case where the label alone is misleading.
|
||||
Text {
|
||||
visible: PowerProfiles.degraded !== ""
|
||||
width: root.width
|
||||
text: "Limited right now: " + PowerProfiles.degraded
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
leftPadding: 10
|
||||
topPadding: 4
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ PanelWindow {
|
||||
if (root.visible) {
|
||||
KdeConnect.refresh();
|
||||
HomeAssistant.refresh();
|
||||
// The daemon owns the power profile and anything on the system can
|
||||
// change it, so the panel asks rather than trusting what it last saw.
|
||||
PowerProfiles.refresh();
|
||||
openAnim.restart();
|
||||
} else {
|
||||
panel.reset();
|
||||
|
||||
@@ -130,6 +130,21 @@ Item {
|
||||
onToggled: Caffeine.toggle()
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
|
||||
label: "Appearance"
|
||||
sublabel: ColorScheme.dark ? "Dark" : "Light"
|
||||
// "active" reads as the non-default state across this grid, and
|
||||
// dark is what Panama ships, so light is the lit one.
|
||||
active: !ColorScheme.dark
|
||||
// Writes the preference and stops. ColorScheme propagates it to
|
||||
// GTK, the portal, the terminals, the launcher, btop, tmux and
|
||||
// the lock screen; nothing here needs to know that list.
|
||||
onToggled: SystemSettings.commitPreference("colorScheme",
|
||||
ColorScheme.dark ? "light" : "dark")
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: "night-light-symbolic"
|
||||
@@ -190,6 +205,38 @@ Item {
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
// ── Power ───────────────────────────────────────────────────────────
|
||||
// Only where a power-profiles daemon is actually running. A desktop
|
||||
// without one should not show a control that cannot do anything.
|
||||
RowButton {
|
||||
visible: PowerProfiles.available
|
||||
width: content.width
|
||||
icon: {
|
||||
switch (PowerProfiles.active) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
label: "Power profile"
|
||||
sublabel: PowerProfiles.degraded !== ""
|
||||
? PowerProfiles.label(PowerProfiles.active) + " · limited"
|
||||
: PowerProfiles.label(PowerProfiles.active)
|
||||
selected: root.expandedSection === "power"
|
||||
onClicked: root.expand("power")
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "power"
|
||||
|
||||
PowerProfileList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sliders ─────────────────────────────────────────────────────────
|
||||
AudioSlider {
|
||||
width: content.width
|
||||
|
||||
@@ -137,7 +137,18 @@ Item {
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.networks
|
||||
// root.networks is a fresh array on every signal-strength tick (the
|
||||
// sort comparator reads signalStrength/connected/known, so any of
|
||||
// those changing on ANY network recomputes the whole list). Handing
|
||||
// that straight to Repeater would reset the model and rebuild every
|
||||
// delegate each tick, blowing away whichever row has its password
|
||||
// Section open and focused. ScriptModel diffs by object identity
|
||||
// (WifiNetwork instances are unique QObjects) and turns a reorder
|
||||
// into move operations, so existing delegates -- and their expanded
|
||||
// state -- survive.
|
||||
model: ScriptModel {
|
||||
values: root.networks
|
||||
}
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
|
||||
ScrollColumn 1.0 ScrollColumn.qml
|
||||
Section 1.0 Section.qml
|
||||
WifiList 1.0 WifiList.qml
|
||||
PowerProfileList 1.0 PowerProfileList.qml
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Choosing the desktop accent.
|
||||
//
|
||||
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
|
||||
// dot, because the gradient is the thing being chosen -- the focused window
|
||||
// border, the bar hairline and every active state are the two 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,16 @@ SettingsPage {
|
||||
? Wallpaper.lastError
|
||||
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||
|
||||
WallpaperControls {
|
||||
id: wallpaperControls
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
WallpaperPicker {
|
||||
id: wallpapers
|
||||
width: parent.width
|
||||
mode: wallpaperControls.mode
|
||||
selectedOutput: wallpaperControls.selectedOutput
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
@@ -75,13 +82,37 @@ 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 {
|
||||
title: "Colour scheme"
|
||||
subtitle: ColorScheme.lastError !== ""
|
||||
? ColorScheme.lastError
|
||||
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
|
||||
|
||||
ChoiceRow { setting: "colorScheme"; divider: false }
|
||||
ChoiceRow { setting: "colorScheme" }
|
||||
|
||||
// Drawn as the gradient each accent produces rather than a flat dot,
|
||||
// because the gradient is what is being chosen.
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -300,7 +331,10 @@ SettingsPage {
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
ToggleRow { setting: "showGpu"; divider: true }
|
||||
// Refresh interval was on the Home page, which split one concept across
|
||||
// two pages -- what the vitals show here, how often they update there.
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property var applications: AudioDevices.applications
|
||||
property bool pipewireReady: Pipewire.ready
|
||||
|
||||
readonly property int rowCount: applicationRepeater.count
|
||||
readonly property string statusText: {
|
||||
if (!root.pipewireReady)
|
||||
return "PipeWire is unavailable";
|
||||
if (root.applications.length === 0)
|
||||
return "Applications playing sound will appear here";
|
||||
return "";
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
id: applicationRepeater
|
||||
model: root.applications
|
||||
|
||||
ApplicationVolumeRow {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
application: modelData
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.statusText !== ""
|
||||
text: root.statusText
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: 18
|
||||
bottomPadding: 18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.modules.quicksettings
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property var application
|
||||
|
||||
readonly property real volume: AudioDevices.applicationVolume(root.application)
|
||||
readonly property bool muted: AudioDevices.applicationMuted(root.application)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 78
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.fg, 0.025)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.application?.nodes ?? []
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
id: applicationIcon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 11
|
||||
size: 20
|
||||
icon: root.application?.icon ?? "audio-x-generic-symbolic"
|
||||
iconFallback: "audio-x-generic-symbolic"
|
||||
tint: Theme.fg
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: applicationIcon.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.application?.label ?? "Unknown application"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: (root.application?.nodes?.length ?? 0) > 1
|
||||
text: `${root.application?.nodes?.length ?? 0} audio streams`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 6
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.muted || root.volume <= 0.001
|
||||
? "audio-volume-muted-symbolic"
|
||||
: "audio-volume-high-symbolic"
|
||||
iconFallback: "audio-volume-high-symbolic"
|
||||
onClicked: AudioDevices.setApplicationMuted(root.application, !root.muted)
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 7
|
||||
anchors.right: volumeText.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
onMoved: value => AudioDevices.setApplicationVolume(root.application, value)
|
||||
}
|
||||
|
||||
Text {
|
||||
id: volumeText
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
width: 38
|
||||
text: Math.round(root.volume * 100) + "%"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ SettingRow {
|
||||
|
||||
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
||||
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
|
||||
readonly property bool available: root.node?.audio
|
||||
readonly property bool available: !!root.node?.audio
|
||||
&& root.leftIndex >= 0 && root.rightIndex >= 0
|
||||
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
import "../../services/DisplayLayout.js" as DisplayLayout
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var displayService
|
||||
property string selectedOutput: ""
|
||||
property var draftLayout: []
|
||||
property bool interactionEnabled: true
|
||||
|
||||
signal selectionRequested(string output)
|
||||
|
||||
implicitHeight: content.implicitHeight
|
||||
readonly property var canvasData: DisplayLayout.canvasRects(
|
||||
root.draftLayout, canvas.width, canvas.height, 18)
|
||||
|
||||
function copied(layout): var {
|
||||
return (layout || []).map(record => Object.assign({}, record));
|
||||
}
|
||||
|
||||
function resetDraft(): void {
|
||||
root.draftLayout = root.copied(root.displayService.currentLayout());
|
||||
}
|
||||
|
||||
function setDraftPosition(output: string, x: real, y: real, snapToEdges: bool): bool {
|
||||
const next = root.copied(root.draftLayout);
|
||||
const record = next.find(candidate => candidate.name === output);
|
||||
if (!record)
|
||||
return false;
|
||||
record.x = Math.round(x);
|
||||
record.y = Math.round(y);
|
||||
root.draftLayout = snapToEdges ? DisplayLayout.snap(next, output, 16) : next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function nudge(output: string, dx: int, dy: int): bool {
|
||||
const record = root.draftLayout.find(candidate => candidate.name === output);
|
||||
return !!record && root.setDraftPosition(output, record.x + dx, record.y + dy, false);
|
||||
}
|
||||
|
||||
function applyDraft(): bool {
|
||||
return root.displayService.applyLayout(root.copied(root.draftLayout));
|
||||
}
|
||||
|
||||
function makePrimary(output: string): bool {
|
||||
const next = root.copied(root.draftLayout);
|
||||
if (!next.some(record => record.name === output))
|
||||
return false;
|
||||
for (const record of next)
|
||||
record.primary = record.name === output;
|
||||
root.draftLayout = DisplayLayout.normalize(next);
|
||||
return root.applyDraft();
|
||||
}
|
||||
|
||||
function canvasSnapshot(): var {
|
||||
return {
|
||||
bounds: root.canvasData.bounds,
|
||||
scale: root.canvasData.scale,
|
||||
rects: root.canvasData.rects.map(record => Object.assign({}, record))
|
||||
};
|
||||
}
|
||||
|
||||
Component.onCompleted: root.resetDraft()
|
||||
|
||||
Connections {
|
||||
target: root.displayService
|
||||
ignoreUnknownSignals: true
|
||||
function onMonitorsChanged(): void {
|
||||
if (!root.displayService.awaitingConfirmation)
|
||||
root.resetDraft();
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width
|
||||
spacing: 11
|
||||
|
||||
Rectangle {
|
||||
id: canvas
|
||||
width: parent.width
|
||||
height: root.width >= 620 ? 232 : 190
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.76)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
clip: true
|
||||
|
||||
// A restrained coordinate field makes the topology feel like a
|
||||
// precision instrument without turning it into a technical graph.
|
||||
Repeater {
|
||||
model: 4
|
||||
Rectangle {
|
||||
required property int index
|
||||
y: (index + 1) * canvas.height / 5
|
||||
width: canvas.width
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.025)
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.canvasData.rects
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
required property var modelData
|
||||
readonly property bool selected: root.selectedOutput === modelData.name
|
||||
readonly property var draft: root.draftLayout.find(
|
||||
record => record.name === modelData.name)
|
||||
|
||||
x: modelData.x
|
||||
y: modelData.y
|
||||
width: Math.max(64, modelData.width)
|
||||
height: Math.max(48, modelData.height)
|
||||
radius: 11
|
||||
color: tile.selected
|
||||
? Theme.alpha(Theme.bgHighlight, 0.92)
|
||||
: Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78)
|
||||
border.width: tile.selected || activeFocus ? 2 : 1
|
||||
border.color: activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14))
|
||||
opacity: root.interactionEnabled ? 1 : 0.5
|
||||
activeFocusOnTab: root.interactionEnabled
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: "Move " + tile.modelData.name
|
||||
Accessible.description: tile.draft && tile.draft.primary
|
||||
? "Primary display. Drag or use the arrow keys to move it."
|
||||
: "Drag or use the arrow keys to move this display."
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
radius: parent.radius - 2
|
||||
visible: tile.selected
|
||||
opacity: 0.24
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.accent }
|
||||
GradientStop { position: 1; color: Theme.accentSecondary }
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.margins: 11
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: tile.modelData.name
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: tile.draft
|
||||
? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}`
|
||||
: ""
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 7
|
||||
width: primaryText.implicitWidth + 12
|
||||
height: 20
|
||||
radius: Theme.pillRadius
|
||||
visible: tile.draft && tile.draft.primary
|
||||
color: Theme.alpha(Theme.accent, 0.2)
|
||||
|
||||
Text {
|
||||
id: primaryText
|
||||
anchors.centerIn: parent
|
||||
text: "Primary"
|
||||
color: Theme.accentAlt
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
enabled: root.interactionEnabled
|
||||
cursorShape: Qt.OpenHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.interactionEnabled
|
||||
onTapped: {
|
||||
root.selectionRequested(tile.modelData.name);
|
||||
tile.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
DragHandler {
|
||||
id: drag
|
||||
target: null
|
||||
enabled: root.interactionEnabled
|
||||
property real initialX: 0
|
||||
property real initialY: 0
|
||||
property bool moved: false
|
||||
|
||||
onActiveChanged: {
|
||||
if (active) {
|
||||
const record = root.draftLayout.find(
|
||||
candidate => candidate.name === tile.modelData.name);
|
||||
initialX = record ? record.x : 0;
|
||||
initialY = record ? record.y : 0;
|
||||
moved = false;
|
||||
root.selectionRequested(tile.modelData.name);
|
||||
tile.forceActiveFocus();
|
||||
} else if (moved) {
|
||||
const record = root.draftLayout.find(
|
||||
candidate => candidate.name === tile.modelData.name);
|
||||
if (record) {
|
||||
root.setDraftPosition(tile.modelData.name, record.x, record.y, true);
|
||||
root.applyDraft();
|
||||
}
|
||||
}
|
||||
}
|
||||
onTranslationChanged: {
|
||||
if (!active || root.canvasData.scale <= 0)
|
||||
return;
|
||||
moved = true;
|
||||
root.setDraftPosition(
|
||||
tile.modelData.name,
|
||||
initialX + translation.x / root.canvasData.scale,
|
||||
initialY + translation.y / root.canvasData.scale,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (!root.interactionEnabled)
|
||||
return;
|
||||
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
|
||||
let handled = true;
|
||||
if (event.key === Qt.Key_Left)
|
||||
root.nudge(tile.modelData.name, -step, 0);
|
||||
else if (event.key === Qt.Key_Right)
|
||||
root.nudge(tile.modelData.name, step, 0);
|
||||
else if (event.key === Qt.Key_Up)
|
||||
root.nudge(tile.modelData.name, 0, -step);
|
||||
else if (event.key === Qt.Key_Down)
|
||||
root.nudge(tile.modelData.name, 0, step);
|
||||
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
|
||||
root.applyDraft();
|
||||
else if (event.key === Qt.Key_Escape)
|
||||
root.resetDraft();
|
||||
else
|
||||
handled = false;
|
||||
event.accepted = handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
width: Math.max(0, parent.width - identifyButton.width
|
||||
- primaryButton.width - applyButton.width - 24)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.width >= 600
|
||||
? "Drag to arrange · arrows move 10 px · Shift moves 100 px"
|
||||
: "Drag or use the arrow keys"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: identifyButton
|
||||
text: "Identify"
|
||||
enabled: root.interactionEnabled
|
||||
onClicked: root.displayService.identify()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: primaryButton
|
||||
text: "Make primary"
|
||||
enabled: root.interactionEnabled && root.selectedOutput !== ""
|
||||
&& !root.draftLayout.find(record =>
|
||||
record.name === root.selectedOutput)?.primary
|
||||
onClicked: root.makePrimary(root.selectedOutput)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: applyButton
|
||||
text: "Apply"
|
||||
enabled: root.interactionEnabled
|
||||
onClicked: root.applyDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
property var modelData: null
|
||||
readonly property string connector: win.modelData?.name ?? "Display"
|
||||
readonly property int number: Math.max(1,
|
||||
Displays.monitors.findIndex(monitor => monitor.name === win.connector) + 1)
|
||||
readonly property string description: Displays.monitorNamed(win.connector)?.description ?? "Connected display"
|
||||
|
||||
screen: win.modelData
|
||||
visible: Displays.identifying
|
||||
implicitWidth: 260
|
||||
implicitHeight: 172
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
mask: Region {}
|
||||
|
||||
WlrLayershell.namespace: "qs-display-identify"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.96)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 32
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: String(win.number)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 68
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: win.connector
|
||||
color: Theme.accentAlt
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: win.description
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,20 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Displays.monitors.length > 1
|
||||
title: "Arrange displays"
|
||||
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
|
||||
|
||||
DisplayArrangement {
|
||||
width: parent.width
|
||||
displayService: Displays
|
||||
selectedOutput: root.selectedOutput
|
||||
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onSelectionRequested: output => root.selectedOutput = output
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Displays.monitors.length > 1
|
||||
title: "Connected display"
|
||||
|
||||
@@ -441,7 +441,7 @@ SettingsPage {
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:color"
|
||||
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"
|
||||
onTriggered: SystemSettings.openGnomePanel("color")
|
||||
}
|
||||
|
||||
@@ -117,12 +117,6 @@ SettingsPage {
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Processor, memory, and graphics activity in the bar"
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: false }
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: summaryCards
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string backgroundMode: DesktopPreferences.get("lockBackgroundMode")
|
||||
property int blurLevel: DesktopPreferences.get("lockBlurLevel")
|
||||
property bool showClock: DesktopPreferences.get("lockShowClock")
|
||||
property bool showDate: DesktopPreferences.get("lockShowDate")
|
||||
property bool showUser: DesktopPreferences.get("lockShowUser")
|
||||
property bool fadeOnEmpty: DesktopPreferences.get("lockFadeOnEmpty")
|
||||
property bool use24Hour: DesktopPreferences.get("use24Hour")
|
||||
property string wallpaperPath: Wallpaper.active !== ""
|
||||
? Wallpaper.active
|
||||
: (Wallpaper.configured !== "" ? Wallpaper.configured : Wallpaper.shippedPath)
|
||||
|
||||
readonly property string previewMode: root.backgroundMode
|
||||
readonly property real blurStrength: Math.max(0, Math.min(1, root.blurLevel / 5))
|
||||
readonly property bool wallpaperVisible: wallpaper.visible
|
||||
readonly property bool clockVisible: clockLabel.visible
|
||||
readonly property bool dateVisible: dateLabel.visible
|
||||
readonly property bool userVisible: userLabel.visible
|
||||
readonly property bool passwordVisible: passwordField.visible
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 230
|
||||
radius: Theme.cardRadius
|
||||
clip: true
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.10)
|
||||
|
||||
Image {
|
||||
id: wallpaper
|
||||
anchors.fill: parent
|
||||
visible: root.backgroundMode === "wallpaper"
|
||||
source: visible && root.wallpaperPath !== "" ? root.wallpaperPath : ""
|
||||
asynchronous: true
|
||||
cache: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize.width: 960
|
||||
sourceSize.height: 540
|
||||
}
|
||||
|
||||
// Screenshot mode stays representative instead of taking a real desktop
|
||||
// capture inside Settings. The layered window silhouettes communicate the
|
||||
// selected softness without a live ShaderEffect or GPU repaint loop.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.backgroundMode === "screenshot"
|
||||
color: Theme.bgDark
|
||||
|
||||
Rectangle {
|
||||
x: parent.width * 0.10
|
||||
y: parent.height * 0.12
|
||||
width: parent.width * 0.45
|
||||
height: parent.height * 0.66
|
||||
radius: 14 + root.blurStrength * 10
|
||||
color: Theme.alpha(Theme.bgHighlight, 0.52 - root.blurStrength * 0.18)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.14)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: parent.width * 0.48
|
||||
y: parent.height * 0.24
|
||||
width: parent.width * 0.40
|
||||
height: parent.height * 0.57
|
||||
radius: 14 + root.blurStrength * 10
|
||||
color: Theme.alpha(Theme.bgPanel, 0.64 - root.blurStrength * 0.20)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accentSecondary, 0.13)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.backgroundMode === "wallpaper"
|
||||
? Theme.alpha(Theme.bg, 0.42)
|
||||
: Theme.alpha(Theme.bg, 0.12 + root.blurStrength * 0.16)
|
||||
}
|
||||
|
||||
Text {
|
||||
id: clockLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 26
|
||||
visible: root.showClock
|
||||
text: Qt.formatDateTime(clock.date, root.use24Hour ? "HH:mm" : "h:mm")
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 42
|
||||
font.weight: Font.Light
|
||||
}
|
||||
|
||||
Text {
|
||||
id: dateLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: clockLabel.visible ? clockLabel.bottom : parent.top
|
||||
anchors.topMargin: clockLabel.visible ? 1 : 35
|
||||
visible: root.showDate
|
||||
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: passwordField
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: root.showUser ? 48 : 30
|
||||
visible: !root.fadeOnEmpty
|
||||
width: Math.min(270, parent.width * 0.48)
|
||||
height: 36
|
||||
radius: height / 2
|
||||
color: Theme.alpha(Theme.bgPanel, 0.84)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.78)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Password"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.italic: true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: userLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 19
|
||||
visible: root.showUser
|
||||
text: Quickshell.env("USER") || "User"
|
||||
color: Theme.alpha(Theme.fg, 0.90)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Minutes
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,17 @@ the owner instead of duplicating it.
|
||||
| `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`.
|
||||
@@ -85,9 +96,11 @@ 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 owned by the visual theme (and, eventually, an accent picker).
|
||||
`ColorScheme.qml` must never write the focused border, so changing schemes
|
||||
cannot erase a user-selected accent.
|
||||
**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
|
||||
|
||||
@@ -155,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/backups/` | Settings snapshots |
|
||||
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
|
||||
| `$XDG_STATE_HOME/panama/hyprlock.conf` | Generated lock-screen appearance |
|
||||
|
||||
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
|
||||
skipped one would be worse than having no reset, because nothing would say so.
|
||||
|
||||
@@ -51,9 +51,11 @@ Item {
|
||||
return typeof value === "number" ? value : root.minimum;
|
||||
}
|
||||
|
||||
// Shown while dragging; -1 means "nothing pending, show what is stored".
|
||||
property real pending: -1
|
||||
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
|
||||
// Shown while dragging; null means "nothing pending, show what is stored".
|
||||
// Not -1: several schema entries (e.g. pointerSensitivity) are legitimately
|
||||
// negative, and -1 would be indistinguishable from a real committed value.
|
||||
property var pending: null
|
||||
readonly property real shown: root.pending !== null ? root.pending : root.stored
|
||||
|
||||
// Below this the label and a usable slider cannot share a line without one
|
||||
// of them becoming useless.
|
||||
@@ -176,7 +178,7 @@ Item {
|
||||
id: commitTimer
|
||||
interval: 140
|
||||
onTriggered: {
|
||||
if (root.pending < 0)
|
||||
if (root.pending === null)
|
||||
return;
|
||||
SystemSettings.commitPreference(root.setting, root.pending);
|
||||
// Hand the display back to the stored value. If the write was
|
||||
@@ -188,6 +190,6 @@ Item {
|
||||
Timer {
|
||||
id: releaseTimer
|
||||
interval: 160
|
||||
onTriggered: root.pending = -1
|
||||
onTriggered: root.pending = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,15 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Applications"
|
||||
subtitle: "Control each application currently playing through PipeWire."
|
||||
|
||||
ApplicationMixer {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Sound feedback"
|
||||
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
||||
@@ -67,8 +76,8 @@ SettingsPage {
|
||||
title: "Advanced sound"
|
||||
|
||||
ActionRow {
|
||||
label: "Application volumes and profiles"
|
||||
detail: "Open Fedora's complete sound panel"
|
||||
label: "Device profiles"
|
||||
detail: "Open Fedora's complete device profile panel"
|
||||
divider: false
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("sound")
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string mode: DesktopPreferences.get("wallpaperMode")
|
||||
property var outputs: Wallpaper.outputNames()
|
||||
property string selectedOutput: root.outputs.length > 0 ? root.outputs[0] : ""
|
||||
|
||||
property var setModeAction: function(mode) { Wallpaper.setMode(mode); }
|
||||
property var setIntervalAction: function(minutes) { Wallpaper.setIntervalMinutes(minutes); }
|
||||
property var setShuffleAction: function(enabled) { Wallpaper.setShuffle(enabled); }
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 4
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Wallpaper mode"
|
||||
detail: "Use one image, rotate a collection, or choose per display"
|
||||
options: PreferenceSchema.spec("wallpaperMode").options
|
||||
current: root.mode
|
||||
onPicked: value => root.setModeAction(value)
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
visible: root.mode === "per-monitor"
|
||||
width: parent.width
|
||||
label: "Display"
|
||||
detail: "Choose which display the thumbnail grid assigns"
|
||||
options: root.outputs.map(output => ({ value: output, label: output }))
|
||||
current: root.selectedOutput
|
||||
onPicked: value => root.selectedOutput = value
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: root.mode === "slideshow"
|
||||
label: "Change background every"
|
||||
detail: "Time between slideshow images"
|
||||
controlWidth: 280
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: parent.left
|
||||
anchors.right: intervalText.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: (DesktopPreferences.get("wallpaperIntervalMinutes") - 5) / 1435
|
||||
onMoved: ratio => {
|
||||
const raw = 5 + ratio * 1435;
|
||||
root.setIntervalAction(Math.max(5, Math.min(1440, Math.round(raw / 5) * 5)));
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: intervalText
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 62
|
||||
text: DesktopPreferences.get("wallpaperIntervalMinutes") + " min"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: root.mode === "slideshow"
|
||||
label: "Shuffle"
|
||||
detail: "Show every selected image before repeating"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: DesktopPreferences.get("wallpaperShuffle") === true
|
||||
onToggled: enabled => root.setShuffleAction(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.mode === "slideshow"
|
||||
&& (DesktopPreferences.get("wallpaperSlideshowPaths") ?? []).length === 0
|
||||
text: "Select two or more images below to begin a slideshow."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: 5
|
||||
bottomPadding: 8
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ Item {
|
||||
// unreachable without a long scroll past pictures. Two rows by default,
|
||||
// all of them on request.
|
||||
property bool expanded: false
|
||||
property string mode: DesktopPreferences.get("wallpaperMode")
|
||||
property string selectedOutput: Wallpaper.outputNames()[0] ?? ""
|
||||
property var activeByOutput: Wallpaper.activeByOutput
|
||||
property var slideshowPaths: DesktopPreferences.get("wallpaperSlideshowPaths") ?? []
|
||||
property var assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({})
|
||||
property var setSingleAction: function(path) { Wallpaper.setSingle(path); }
|
||||
property var toggleSlideshowAction: function(path) { Wallpaper.toggleSlideshowPath(path); }
|
||||
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
|
||||
readonly property int collapsedRows: 2
|
||||
|
||||
readonly property var shown: root.expanded
|
||||
@@ -35,6 +43,27 @@ Item {
|
||||
readonly property int columns: Math.max(2, Math.floor(width / 190))
|
||||
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
||||
|
||||
function isCurrent(path: string): bool {
|
||||
return root.activeByOutput[root.selectedOutput] === path;
|
||||
}
|
||||
|
||||
function selected(path: string): bool {
|
||||
if (root.mode === "slideshow")
|
||||
return root.slideshowPaths.includes(path);
|
||||
if (root.mode === "per-monitor")
|
||||
return root.assignments[root.selectedOutput] === path;
|
||||
return root.isCurrent(path);
|
||||
}
|
||||
|
||||
function activate(path: string): void {
|
||||
if (root.mode === "slideshow")
|
||||
root.toggleSlideshowAction(path);
|
||||
else if (root.mode === "per-monitor")
|
||||
root.setAssignmentAction(root.selectedOutput, path);
|
||||
else
|
||||
root.setSingleAction(path);
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: grid
|
||||
|
||||
@@ -50,7 +79,8 @@ Item {
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool current: Wallpaper.active === tile.modelData
|
||||
readonly property bool current: root.isCurrent(tile.modelData)
|
||||
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
|
||||
|
||||
width: root.cellWidth
|
||||
height: Math.round(root.cellWidth * 9 / 16)
|
||||
@@ -114,6 +144,28 @@ Item {
|
||||
border.color: Theme.accent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 12
|
||||
visible: tile.member
|
||||
color: Theme.alpha(Theme.bgDark, 0.82)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.16)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "✓"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
@@ -132,7 +184,9 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 7
|
||||
text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData)
|
||||
text: tile.current
|
||||
? "Current wallpaper"
|
||||
: (tile.member ? "In slideshow" : Wallpaper.titleFor(tile.modelData))
|
||||
color: tile.current ? Theme.accent : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
@@ -144,7 +198,7 @@ Item {
|
||||
HoverHandler { id: hover }
|
||||
|
||||
TapHandler {
|
||||
onTapped: Wallpaper.set(tile.modelData)
|
||||
onTapped: root.activate(tile.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ ChoiceRow 1.0 ChoiceRow.qml
|
||||
ActionRow 1.0 ActionRow.qml
|
||||
TextRow 1.0 TextRow.qml
|
||||
DesktopPreview 1.0 DesktopPreview.qml
|
||||
LockScreenPreview 1.0 LockScreenPreview.qml
|
||||
PowerPage 1.0 PowerPage.qml
|
||||
DateTimePage 1.0 DateTimePage.qml
|
||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
WallpaperControls 1.0 WallpaperControls.qml
|
||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||
@@ -40,12 +42,16 @@ DockAppPicker 1.0 DockAppPicker.qml
|
||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||
DisplayArrangement 1.0 DisplayArrangement.qml
|
||||
DisplayIdentify 1.0 DisplayIdentify.qml
|
||||
WifiPanel 1.0 WifiPanel.qml
|
||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||
PasswordField 1.0 PasswordField.qml
|
||||
AudioBalance 1.0 AudioBalance.qml
|
||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||
ApplicationMixer 1.0 ApplicationMixer.qml
|
||||
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
|
||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||
LocationPicker 1.0 LocationPicker.qml
|
||||
FontPicker 1.0 FontPicker.qml
|
||||
@@ -55,3 +61,4 @@ PrivacyPage 1.0 PrivacyPage.qml
|
||||
RegionPage 1.0 RegionPage.qml
|
||||
SearchPicker 1.0 SearchPicker.qml
|
||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||
AccentPicker 1.0 AccentPicker.qml
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// The Alt-Tab overlay.
|
||||
//
|
||||
// Deliberately a list of names rather than thumbnails: at a glance you are
|
||||
// looking for "the other terminal", and a row of small live previews is both
|
||||
// slower to read and considerably more expensive to draw than the gesture
|
||||
// deserves. The dock already renders app identity this way, so the two agree.
|
||||
//
|
||||
// Only present while a switch is in progress -- there is nothing to keep alive
|
||||
// between gestures, and a hidden always-loaded overlay is a surface that can
|
||||
// go wrong while nobody is looking at it.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Loader {
|
||||
id: root
|
||||
|
||||
// Plain `modelData`, not `required property var screen`. Variants supplies
|
||||
// modelData, and shell.qml's own comment warns about exactly this: declaring
|
||||
// `required property var screen` means the screen never resolves, the window
|
||||
// is constructed and silently never maps, and NOTHING is logged. Bar and
|
||||
// Dock both take the screen this way.
|
||||
property var modelData: null
|
||||
|
||||
active: WindowSwitcherState.open
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
screen: root.modelData
|
||||
// Overlay so it sits above the focused window it is describing.
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.namespace: "qs-switcher"
|
||||
// Nothing here is clickable: the gesture is driven entirely from the
|
||||
// keyboard, and taking input would steal focus from the compositor
|
||||
// mid-switch, which is the one thing that would break it.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(560, parent.width - 96)
|
||||
implicitHeight: layout.implicitHeight + 24
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.97)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: WindowSwitcherState.windows
|
||||
|
||||
Rectangle {
|
||||
id: row
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool current: row.index === WindowSwitcherState.index
|
||||
readonly property string appId: row.modelData?.wayland?.appId ?? ""
|
||||
readonly property var entry: DesktopEntries.heuristicLookup(row.appId)
|
||||
|
||||
width: parent.width
|
||||
height: 44
|
||||
radius: 10
|
||||
border.width: 0
|
||||
color: row.current ? Theme.alpha(Theme.accent, 0.20) : "transparent"
|
||||
|
||||
Image {
|
||||
id: icon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 24
|
||||
height: 24
|
||||
sourceSize.width: 24
|
||||
sourceSize.height: 24
|
||||
source: row.entry?.icon ? Quickshell.iconPath(row.entry.icon, true) : ""
|
||||
visible: source !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: icon.visible ? icon.right : parent.left
|
||||
anchors.leftMargin: icon.visible ? 12 : 14
|
||||
anchors.right: appName.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// A window with no title yet is still a window you
|
||||
// can switch to; naming it after its application is
|
||||
// better than an empty row.
|
||||
text: row.modelData?.title || row.entry?.name || row.appId
|
||||
elide: Text.ElideRight
|
||||
color: row.current ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: row.current ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
Text {
|
||||
id: appName
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: (row.entry?.name ?? "") !== "" && row.entry.name !== row.modelData?.title
|
||||
text: row.entry?.name ?? ""
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,11 @@ def watch(start: int, end: int) -> int:
|
||||
return 1
|
||||
|
||||
loop = GLib.MainLoop()
|
||||
registry = EDataServer.SourceRegistry.new_sync(None)
|
||||
try:
|
||||
registry = EDataServer.SourceRegistry.new_sync(None)
|
||||
except Exception:
|
||||
_write_snapshot(_unavailable_snapshot())
|
||||
return 1
|
||||
debounce_source = 0
|
||||
subscriptions: list[int] = []
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ class DoctorConfig:
|
||||
runtime_dir: Path
|
||||
path: str
|
||||
timeout: float
|
||||
# Defaults to this file's own directory, where its sibling helpers
|
||||
# (panama-action, panama-brightness, calendar-agenda) actually live.
|
||||
scripts_dir: Path = Path(__file__).resolve().parent
|
||||
# Repair actions (service restarts, the Quickshell restart-shell action)
|
||||
# can legitimately run longer than a quick health-check probe -- a
|
||||
# Quickshell restart alone waits for the old process to exit, the new one
|
||||
# to start, and settle. Reusing `timeout` here would kill a slow-but-
|
||||
# successful repair and report it as failed even though a following
|
||||
# health scan would show everything recovered. See run_repair_command.
|
||||
repair_timeout: float = 15.0
|
||||
|
||||
@property
|
||||
def command_env(self) -> dict[str, str]:
|
||||
@@ -102,7 +112,7 @@ class RepairResult:
|
||||
|
||||
CHECK_ORDER = (
|
||||
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
|
||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire",
|
||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
|
||||
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
||||
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
||||
@@ -159,6 +169,7 @@ CHECK_TITLES = {
|
||||
"desktop.portals": "Desktop portals",
|
||||
"desktop.hyprpaper": "Hyprpaper",
|
||||
"desktop.hypridle": "Hypridle",
|
||||
"desktop.hyprlock": "Lock screen",
|
||||
"desktop.vicinae": "Vicinae",
|
||||
"input.pipewire": "PipeWire",
|
||||
"input.clipboard": "Clipboard",
|
||||
@@ -192,11 +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")))
|
||||
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
|
||||
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
|
||||
# Sibling helpers (panama-action, panama-brightness, calendar-agenda) live
|
||||
# next to this file. PATH is not a reliable way to find them -- nothing in
|
||||
# this repository puts the Quickshell scripts directory on PATH -- so they
|
||||
# are invoked by resolved path instead, the same way check_hyprlock already
|
||||
# resolves panama-lock.
|
||||
scripts_dir = environment_path("PANAMA_DOCTOR_SCRIPTS_DIR", Path(__file__).resolve().parent)
|
||||
try:
|
||||
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
|
||||
except ValueError:
|
||||
timeout = 3.0
|
||||
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
|
||||
timeout = max(0.05, min(timeout, 15.0))
|
||||
try:
|
||||
repair_timeout = float(os.environ.get("PANAMA_DOCTOR_REPAIR_TIMEOUT", "15"))
|
||||
except ValueError:
|
||||
repair_timeout = 15.0
|
||||
# Never shorter than the probe timeout, and bounded so a hung repair still
|
||||
# gives up rather than blocking the caller indefinitely.
|
||||
repair_timeout = max(timeout, min(repair_timeout, 30.0))
|
||||
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), timeout, scripts_dir, repair_timeout)
|
||||
|
||||
|
||||
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
|
||||
@@ -221,7 +246,7 @@ def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=config.timeout,
|
||||
timeout=config.repair_timeout,
|
||||
check=False,
|
||||
env=config.command_env,
|
||||
cwd=cwd,
|
||||
@@ -320,8 +345,43 @@ def check_portals(config: DoctorConfig) -> Check:
|
||||
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
|
||||
|
||||
|
||||
def check_hyprlock(config: DoctorConfig) -> Check:
|
||||
helper = config.root / "config/dot/quickshell/scripts/panama-lock"
|
||||
tracked_fallback = config.config_home / "hypr/hyprlock.conf"
|
||||
generated_config = config.state_home / "panama/hyprlock.conf"
|
||||
# The doctor seals PATH for every probe. Invoke the authored helper with a
|
||||
# fixed system-only PATH so its bash shebang and jq dependency remain
|
||||
# available without inheriting arbitrary parent executables.
|
||||
result = run_command(("/usr/bin/env", "PATH=/usr/bin:/bin", str(helper), "status"), config)
|
||||
|
||||
if result.state != "ok":
|
||||
if tracked_fallback.is_file():
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is unavailable; the tracked fallback remains available.")
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||
|
||||
try:
|
||||
state = json.loads(result.stdout)
|
||||
generated = state["generated"]
|
||||
path = state["path"]
|
||||
fallback = state["fallback"]
|
||||
error = state["error"]
|
||||
if not isinstance(generated, bool) or not isinstance(path, str) \
|
||||
or not isinstance(fallback, bool) or not isinstance(error, str):
|
||||
raise ValueError
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
if tracked_fallback.is_file():
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is invalid; the tracked fallback remains available.")
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||
|
||||
if generated and not fallback and generated_config.is_file():
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "ok", "Managed lock-screen configuration is available.")
|
||||
if tracked_fallback.is_file():
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "The tracked lock-screen fallback is in use.")
|
||||
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||
|
||||
|
||||
def check_brightness(config: DoctorConfig) -> Check:
|
||||
result = run_command(("panama-brightness", "list"), config)
|
||||
result = run_command((str(config.scripts_dir / "panama-brightness"), "list"), config)
|
||||
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
||||
if result.state == "timeout":
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
|
||||
@@ -382,7 +442,7 @@ def check_home_assistant(config: DoctorConfig) -> Check:
|
||||
|
||||
|
||||
def check_calendar(config: DoctorConfig) -> Check:
|
||||
result = run_command(("calendar-agenda", "probe"), config)
|
||||
result = run_command((str(config.scripts_dir / "calendar-agenda"), "probe"), config)
|
||||
action = Action("open", "Open Date & Time", target="datetime")
|
||||
if result.state == "missing":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
|
||||
@@ -514,7 +574,7 @@ def unavailable_versions() -> list[dict[str, str]]:
|
||||
def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||
probes: dict[str, Callable[[], Check]] = {
|
||||
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
||||
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
||||
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
||||
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
||||
@@ -547,6 +607,11 @@ def snapshot(config: DoctorConfig) -> dict[str, object]:
|
||||
|
||||
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
|
||||
command = REPAIR_COMMANDS[check_id]
|
||||
if check_id == "desktop.quickshell":
|
||||
# panama-action is a sibling helper script, not a PATH-resolved
|
||||
# executable; see check_brightness and check_calendar for the same
|
||||
# resolution against the same bug.
|
||||
command = (str(config.scripts_dir / command[0]), *command[1:])
|
||||
exit_code, _ = run_repair_command(command, config)
|
||||
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
|
||||
else "The authored repair command could not be completed."
|
||||
|
||||
@@ -25,6 +25,14 @@ generated="$state_dir/hypridle.conf"
|
||||
dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d"
|
||||
dropin="$dropin_dir/panama.conf"
|
||||
|
||||
# Set by generate() to the mktemp path it is currently writing, so concurrent
|
||||
# invocations (e.g. rapid settings changes each spawning `apply`) never share
|
||||
# a tmp file and interleave writes into a corrupt hypridle.conf. Cleared once
|
||||
# the atomic mv below lands, so this is a no-op on a normal exit.
|
||||
generated_tmp=""
|
||||
cleanup() { rm -f "$generated_tmp" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
read_setting() {
|
||||
local key="$1" fallback="$2"
|
||||
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
|
||||
@@ -53,13 +61,17 @@ generate() {
|
||||
load
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
# Unique per invocation, in the same directory as the destination so the
|
||||
# final mv is an atomic same-filesystem rename rather than a copy.
|
||||
generated_tmp="$(mktemp "$generated.XXXXXX")"
|
||||
|
||||
{
|
||||
printf '# Generated by panama-idle from %s\n' "$settings"
|
||||
printf '# Do not edit: it is rewritten whenever the idle settings change.\n'
|
||||
printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n'
|
||||
|
||||
printf 'general {\n'
|
||||
printf ' lock_cmd = pidof hyprlock || hyprlock\n'
|
||||
printf ' lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run\n'
|
||||
if [[ "$lock_on_sleep" == "true" ]]; then
|
||||
printf ' before_sleep_cmd = loginctl lock-session\n'
|
||||
fi
|
||||
@@ -91,9 +103,10 @@ generate() {
|
||||
printf ' on-timeout = systemctl suspend\n'
|
||||
printf '}\n'
|
||||
fi
|
||||
} >"$generated.tmp"
|
||||
} >"$generated_tmp"
|
||||
|
||||
mv "$generated.tmp" "$generated"
|
||||
mv "$generated_tmp" "$generated"
|
||||
generated_tmp=""
|
||||
}
|
||||
|
||||
install_dropin() {
|
||||
|
||||
@@ -39,13 +39,36 @@ import re
|
||||
import sys
|
||||
|
||||
|
||||
def daemon_origin():
|
||||
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||
def secrets_name_owner_pid():
|
||||
"""PID currently owning the org.freedesktop.secrets D-Bus name, if any.
|
||||
|
||||
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||
outside the app slice, so the cgroup tells the two apart.
|
||||
This is the only reliable way to identify which daemon actually answers
|
||||
Secret Service calls right now.
|
||||
"""
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
result = bus.call_sync(
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus",
|
||||
"GetConnectionUnixProcessID",
|
||||
GLib.Variant("(s)", ("org.freedesktop.secrets",)),
|
||||
GLib.VariantType("(u)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
return result.unpack()[0]
|
||||
except Exception: # noqa: BLE001 - no name owner is a legitimate state
|
||||
return None
|
||||
|
||||
|
||||
def any_keyring_daemon_running():
|
||||
try:
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
@@ -55,19 +78,46 @@ def daemon_origin():
|
||||
cmdline = handle.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
continue
|
||||
if "gnome-keyring-daemon" not in cmdline:
|
||||
continue
|
||||
try:
|
||||
with open(f"/proc/{pid}/cgroup", "r") as handle:
|
||||
cgroup = handle.read()
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
||||
return "dbus"
|
||||
return "pam"
|
||||
if "gnome-keyring-daemon" in cmdline:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return "none"
|
||||
return False
|
||||
|
||||
|
||||
def daemon_origin():
|
||||
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||
|
||||
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||
outside the app slice, so the cgroup tells the two apart.
|
||||
|
||||
A machine can have two gnome-keyring-daemon processes at once -- a
|
||||
lingering PAM one alongside its D-Bus-activated replacement -- so which
|
||||
process this reports on matters: it must be the one that actually owns
|
||||
org.freedesktop.secrets right now, not merely the first one /proc happens
|
||||
to enumerate.
|
||||
"""
|
||||
owner_pid = secrets_name_owner_pid()
|
||||
if owner_pid is None:
|
||||
return "unknown" if any_keyring_daemon_running() else "none"
|
||||
|
||||
try:
|
||||
with open(f"/proc/{owner_pid}/cmdline", "rb") as handle:
|
||||
cmdline = handle.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if "gnome-keyring-daemon" not in cmdline:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
with open(f"/proc/{owner_pid}/cgroup", "r") as handle:
|
||||
cgroup = handle.read()
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
||||
return "dbus"
|
||||
return "pam"
|
||||
|
||||
|
||||
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() {
|
||||
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
|
||||
printf '%s\n' "$percent"
|
||||
}
|
||||
|
||||
@@ -67,15 +67,19 @@ cmd_list() {
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local profile="${1:-}"
|
||||
local profile="${1:-}" status
|
||||
# Constrained rather than passed through: this reaches a system service.
|
||||
[[ "$profile" =~ ^[a-z-]+$ ]] || {
|
||||
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&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 \
|
||||
| head -2 >&2
|
||||
return 0
|
||||
status=$?
|
||||
return "$status"
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
|
||||
@@ -16,21 +16,116 @@
|
||||
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
|
||||
scheme="${1:-dark}"
|
||||
case "$scheme" in
|
||||
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
|
||||
|
||||
# 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 ─────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -116,9 +211,16 @@ theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||
status_kitty="skipped"
|
||||
if [[ -r "$theme_file" ]]; then
|
||||
# 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 \
|
||||
&& 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
|
||||
status_kitty="written"
|
||||
else
|
||||
rm -f "$kitty_dir/current-theme.conf.tmp"
|
||||
fi
|
||||
|
||||
# Live-apply to running terminals. kitty appends its PID to the socket name
|
||||
@@ -129,7 +231,8 @@ if [[ -r "$theme_file" ]]; then
|
||||
applied=0
|
||||
while read -r socket; do
|
||||
[[ -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))
|
||||
fi
|
||||
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
|
||||
@@ -146,4 +249,5 @@ jq -cn \
|
||||
--arg gtk "$status_gtk" \
|
||||
--arg btop "$status_btop" \
|
||||
--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
|
||||
|
||||
# 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() {
|
||||
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||
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'
|
||||
|
||||
cmd_list() {
|
||||
local rows=() name ssid psk
|
||||
local rows=() name type ssid psk
|
||||
while IFS= read -r name; do
|
||||
[[ -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"
|
||||
|
||||
# Only networks whose passphrase this user can actually read are
|
||||
# shareable. An enterprise network has no passphrase to share at all,
|
||||
# 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" \
|
||||
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
|
||||
'{name: $name, ssid: $ssid, shareable: $shareable}')")
|
||||
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
|
||||
| awk -F: '$2 == "802-11-wireless" { print $1 }')
|
||||
done < <(nmcli -e no -t -f NAME connection show 2>/dev/null)
|
||||
|
||||
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
|
||||
@@ -77,11 +98,16 @@ cmd_qr() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || emit_error 'no network named'
|
||||
|
||||
local ssid hidden psk_file payload_file out_dir out_file
|
||||
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||
local ssid hidden psk_file out_dir out_file
|
||||
# -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\"."
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
# 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'
|
||||
trap 'rm -f "$payload_file"' RETURN
|
||||
|
||||
{
|
||||
printf 'WIFI:T:WPA;S:'
|
||||
printf '%s' "$ssid" | escape_field
|
||||
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"
|
||||
} >"$payload_file"
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
import "AudioStreams.js" as AudioStreams
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
@@ -18,6 +20,13 @@ Singleton {
|
||||
!node.isStream
|
||||
&& (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 {
|
||||
return output ? root.outputs : root.inputs;
|
||||
}
|
||||
@@ -40,4 +49,20 @@ Singleton {
|
||||
return "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 {
|
||||
id: writer
|
||||
onExited: {
|
||||
reader.exited = false;
|
||||
reader.streamFinished = false;
|
||||
reader.settled = false;
|
||||
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
||||
reader.running = true;
|
||||
}
|
||||
@@ -145,19 +148,44 @@ Singleton {
|
||||
|
||||
Process {
|
||||
id: reader
|
||||
|
||||
property string outputText: ""
|
||||
property bool exited: false
|
||||
property bool streamFinished: false
|
||||
property bool settled: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const actual = parseInt(this.text.trim());
|
||||
if (!isNaN(actual)) {
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === root.writingBus
|
||||
? Object.assign({}, display, { value: actual })
|
||||
: display);
|
||||
}
|
||||
root.writingBus = -1;
|
||||
// Anything queued while this write was in flight goes now.
|
||||
root.pump();
|
||||
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)) {
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === root.writingBus
|
||||
? Object.assign({}, display, { value: actual })
|
||||
: display);
|
||||
}
|
||||
root.writingBus = -1;
|
||||
// Anything queued while this write was in flight goes now.
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ Singleton {
|
||||
property int fixtureNow: 0
|
||||
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 var nextEvent: {
|
||||
const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch);
|
||||
@@ -135,14 +142,20 @@ Singleton {
|
||||
function _restartWatch(): void {
|
||||
if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart)
|
||||
return;
|
||||
root.phase = root.events.length > 0 ? root.phase : "loading";
|
||||
// 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.watchWanted = false;
|
||||
root.watchProducedSnapshot = false;
|
||||
startTimer.restart();
|
||||
}
|
||||
|
||||
function consumeSnapshot(data: string): void {
|
||||
if (root.fixtureMode || data.trim() === "")
|
||||
return;
|
||||
root.watchProducedSnapshot = true;
|
||||
root.consecutiveWatchFailures = 0;
|
||||
try {
|
||||
const snapshot = JSON.parse(data);
|
||||
if (snapshot.ok !== true) {
|
||||
@@ -263,6 +276,7 @@ Singleton {
|
||||
root.errors = [];
|
||||
root.selectedDate = new Date();
|
||||
root.phase = "loading";
|
||||
root.consecutiveWatchFailures = 0;
|
||||
root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
|
||||
root._restartWatch();
|
||||
}
|
||||
@@ -275,6 +289,16 @@ Singleton {
|
||||
onRead: data => root.consumeSnapshot(data)
|
||||
}
|
||||
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)
|
||||
restartTimer.restart();
|
||||
}
|
||||
|
||||
@@ -402,20 +402,35 @@ esac'
|
||||
id: recProc
|
||||
onExited: (code, status) => {
|
||||
recTimer.stop();
|
||||
const path = root.recordingPath;
|
||||
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
|
||||
// user pressed stop and the file was finalised normally.
|
||||
if (root.recordingPath !== "") {
|
||||
StatusEvents.publish({
|
||||
key: "capture-recording",
|
||||
glyph: "\u{F044A}",
|
||||
title: "Screen recording saved",
|
||||
detail: root.recordingPath.split("/").pop(),
|
||||
tone: "ok",
|
||||
priority: StatusEvents.importantPriority,
|
||||
actionId: "open-path",
|
||||
actionData: root.recordingPath
|
||||
});
|
||||
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
|
||||
// user pressed stop and the file was finalised normally. Any other
|
||||
// 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({
|
||||
key: "capture-recording",
|
||||
glyph: "\u{F044A}",
|
||||
title: "Screen recording saved",
|
||||
detail: path.split("/").pop(),
|
||||
tone: "ok",
|
||||
priority: StatusEvents.importantPriority,
|
||||
actionId: "open-path",
|
||||
actionData: path
|
||||
});
|
||||
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.recordingSeconds = 0;
|
||||
|
||||
@@ -55,6 +55,18 @@ Singleton {
|
||||
// installed. Distinct from an empty history.
|
||||
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
|
||||
// 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.
|
||||
@@ -69,6 +81,8 @@ Singleton {
|
||||
if (query.running)
|
||||
return;
|
||||
root.loading = true;
|
||||
root._queryExited = false;
|
||||
root._queryStdoutDone = false;
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
@@ -146,16 +160,18 @@ Singleton {
|
||||
command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root._parse(this.text)
|
||||
onStreamFinished: {
|
||||
root._queryStdoutDone = true;
|
||||
root._queryStdoutText = this.text;
|
||||
root._settleQuery();
|
||||
}
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
root.loading = false;
|
||||
if (exitCode !== 0) {
|
||||
root.available = false;
|
||||
root.entries = [];
|
||||
root.refreshed();
|
||||
}
|
||||
root._queryExited = true;
|
||||
root._queryExitCode = exitCode;
|
||||
root._settleQuery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
root.available = true;
|
||||
root.queriedAt = Date.now() / 1000;
|
||||
|
||||
@@ -29,8 +29,28 @@ Singleton {
|
||||
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: ""
|
||||
|
||||
// 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
|
||||
// of these are separate programs.
|
||||
property var pending: []
|
||||
@@ -65,7 +85,7 @@ Singleton {
|
||||
Timer {
|
||||
id: settle
|
||||
interval: 1200
|
||||
onTriggered: root.apply()
|
||||
onTriggered: root.apply(true)
|
||||
}
|
||||
|
||||
Connections {
|
||||
@@ -76,44 +96,92 @@ Singleton {
|
||||
Timer {
|
||||
id: coalesce
|
||||
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 = "";
|
||||
|
||||
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
||||
const accentName = DesktopPreferences.get("accentName") || "blue";
|
||||
const schemeChanged = force || root.dark !== root.appliedDark;
|
||||
const accentChanged = force || accentName !== root.appliedAccentName;
|
||||
|
||||
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||
// broken while light mode looked fine:
|
||||
//
|
||||
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
|
||||
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
|
||||
// not exist makes GTK fall back to its built-in default, which is
|
||||
// LIGHT. So asking for light accidentally worked, asking for dark
|
||||
// silently produced light, and applications that take their cue from
|
||||
// the GTK theme rather than the portal -- Chromium and Electron, when
|
||||
// built against GTK -- stayed light no matter what the portal said.
|
||||
//
|
||||
// gtk-theme-contract asserts these names are actually installed,
|
||||
// because the failure mode is silent in exactly this way.
|
||||
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
|
||||
if (!schemeChanged && !accentChanged)
|
||||
return;
|
||||
|
||||
const commands = [
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
||||
];
|
||||
const commands = [];
|
||||
|
||||
// Unfocused window borders need scheme-relative contrast. The focused
|
||||
// Prism border is deliberately owned by the accent/theme layer.
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||
if (schemeChanged) {
|
||||
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
||||
|
||||
// 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.
|
||||
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
|
||||
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||
// broken while light mode looked fine:
|
||||
//
|
||||
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
|
||||
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
|
||||
// not exist makes GTK fall back to its built-in default, which is
|
||||
// LIGHT. So asking for light accidentally worked, asking for dark
|
||||
// silently produced light, and applications that take their cue from
|
||||
// the GTK theme rather than the portal -- Chromium and Electron, when
|
||||
// built against GTK -- stayed light no matter what the portal said.
|
||||
//
|
||||
// gtk-theme-contract asserts these names are actually installed,
|
||||
// because the failure mode is silent in exactly this way.
|
||||
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
|
||||
|
||||
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
|
||||
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
|
||||
|
||||
// Unfocused window borders need scheme-relative contrast, and stay
|
||||
// this service's to own. The FOCUSED border below is the accent
|
||||
// role and belongs to the theme -- see modules/settings/README.md.
|
||||
// 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",
|
||||
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||
}
|
||||
|
||||
if (schemeChanged || accentChanged) {
|
||||
// Each accent carries a separate pair for light and dark, so this
|
||||
// runs on either kind of change: a scheme flip restates the same
|
||||
// accent's other pair, and an accent change restates the same
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -146,4 +146,11 @@ Singleton {
|
||||
onWifiDeviceChanged: root.syncScanners()
|
||||
onWifiEnabledChanged: 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(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 QtQuick
|
||||
import qs.config
|
||||
import "DisplayLayout.js" as DisplayLayout
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -31,25 +32,25 @@ Singleton {
|
||||
property string lastError: ""
|
||||
|
||||
// Set while a change is applied but not yet confirmed.
|
||||
property string pendingOutput: ""
|
||||
property var pendingPrevious: null
|
||||
property var pendingRequested: null
|
||||
property var pendingPreviousLayout: null
|
||||
property var pendingRequestedLayout: null
|
||||
property bool pendingVerified: false
|
||||
property bool revertQueued: false
|
||||
property var revertExpected: null
|
||||
property var revertExpectedLayout: null
|
||||
property string revertReason: ""
|
||||
property bool revertVerificationActive: false
|
||||
property int operationGeneration: 0
|
||||
property int revertGeneration: -1
|
||||
property bool externalChangeBlocked: false
|
||||
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
|
||||
&& root.pendingVerified
|
||||
&& !root.busy
|
||||
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
||||
|| root.revertExpected !== null
|
||||
|| root.revertExpectedLayout !== null
|
||||
|
||||
readonly property int confirmSeconds: 15
|
||||
|
||||
@@ -94,7 +95,6 @@ Singleton {
|
||||
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
|
||||
return;
|
||||
}
|
||||
verifyTimer.attempts = 0;
|
||||
verifyTimer.ticks = 0;
|
||||
verifyTimer.restart();
|
||||
}
|
||||
@@ -106,7 +106,6 @@ Singleton {
|
||||
// Exit status is advisory only. Hyprland's Lua bridge can report
|
||||
// success without applying a value, so exact readback decides.
|
||||
root.revertVerificationActive = true;
|
||||
revertVerifyTimer.attempts = 0;
|
||||
revertVerifyTimer.ticks = 0;
|
||||
revertVerifyTimer.restart();
|
||||
}
|
||||
@@ -123,9 +122,24 @@ Singleton {
|
||||
return false;
|
||||
}
|
||||
|
||||
function identify(): void {
|
||||
root.identifying = true;
|
||||
identifyTimer.restart();
|
||||
}
|
||||
|
||||
function parse(text: string, generation: int): void {
|
||||
try {
|
||||
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 => {
|
||||
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
||||
const width = monitor.width ?? 0;
|
||||
@@ -144,31 +158,35 @@ Singleton {
|
||||
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
||||
scale: monitor.scale ?? 1,
|
||||
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 ?? "",
|
||||
colorPreset: monitor.colorManagementPreset ?? "",
|
||||
vrr: monitor.vrr === true,
|
||||
modes: modes
|
||||
};
|
||||
});
|
||||
if (root.awaitingConfirmation && root.pendingRequested
|
||||
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||
if (root.awaitingConfirmation && root.pendingRequestedLayout
|
||||
&& generation === root.operationGeneration
|
||||
&& root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
|
||||
root.pendingVerified = true;
|
||||
verifyTimer.stop();
|
||||
root.lastError = "";
|
||||
} else if (root.revertVerificationActive
|
||||
&& generation === root.revertGeneration
|
||||
&& root.revertExpected
|
||||
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
||||
&& root.revertExpectedLayout
|
||||
&& root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
|
||||
revertVerifyTimer.stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpected = null;
|
||||
root.revertExpectedLayout = null;
|
||||
if (root.revertReason === "")
|
||||
root.lastError = "";
|
||||
else
|
||||
root.lastError = 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 === "The display list could not be read.")) {
|
||||
root.lastError = "";
|
||||
@@ -216,6 +234,17 @@ Singleton {
|
||||
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 {
|
||||
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||
if (!match)
|
||||
@@ -250,16 +279,41 @@ Singleton {
|
||||
choices[0]);
|
||||
}
|
||||
|
||||
function matchesRequest(monitor: var, requested: var): bool {
|
||||
if (!monitor || !requested || monitor.name !== requested.output)
|
||||
function currentLayout(): var {
|
||||
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;
|
||||
const parts = root.modeParts(requested.mode);
|
||||
return !!parts
|
||||
&& monitor.width === parts.width
|
||||
&& monitor.height === parts.height
|
||||
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
||||
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
||||
&& monitor.transform === requested.transform;
|
||||
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);
|
||||
if (!parts || monitor.name !== requested.name
|
||||
|| monitor.width !== parts.width
|
||||
|| monitor.height !== parts.height
|
||||
|| Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
|
||||
|| Math.abs(monitor.scale - requested.scale) >= 0.001
|
||||
|| monitor.transform !== requested.transform
|
||||
|| monitor.x !== requested.x || monitor.y !== requested.y)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||
@@ -269,10 +323,48 @@ Singleton {
|
||||
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||
}
|
||||
|
||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||
// settings file is only written by confirm().
|
||||
function validRequestedLayout(layout: var): bool {
|
||||
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 {
|
||||
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.";
|
||||
return false;
|
||||
}
|
||||
@@ -284,58 +376,53 @@ Singleton {
|
||||
root.lastError = "Finish the current display change first.";
|
||||
return false;
|
||||
}
|
||||
const monitor = root.monitorNamed(output);
|
||||
if (!monitor) {
|
||||
root.lastError = "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
||||
root.lastError = "That display does not offer that mode.";
|
||||
return false;
|
||||
}
|
||||
if (!root.isScaleClean(mode, scale)) {
|
||||
root.lastError = "That scale does not divide this resolution cleanly.";
|
||||
return false;
|
||||
}
|
||||
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
||||
root.lastError = "That rotation is not one Panama offers.";
|
||||
const normalized = DisplayLayout.normalize(layout);
|
||||
if (!root.validRequestedLayout(normalized)) {
|
||||
root.lastError = "That complete display layout is not valid for the connected displays.";
|
||||
return false;
|
||||
}
|
||||
|
||||
root.pendingPrevious = {
|
||||
output: output,
|
||||
mode: monitor.mode,
|
||||
scale: monitor.scale,
|
||||
transform: monitor.transform
|
||||
};
|
||||
root.pendingPreviousLayout = root.currentLayout();
|
||||
root.operationGeneration++;
|
||||
root.pendingRequested = {
|
||||
output: output,
|
||||
mode: mode,
|
||||
scale: scale,
|
||||
transform: transform
|
||||
};
|
||||
root.pendingOutput = output;
|
||||
root.pendingRequestedLayout = normalized;
|
||||
root.pendingVerified = false;
|
||||
root.revertQueued = false;
|
||||
root.secondsLeft = root.confirmSeconds;
|
||||
root.lastError = "";
|
||||
countdown.restart();
|
||||
|
||||
root.push(output, mode, scale, transform);
|
||||
root.pushLayout(normalized, applyRun);
|
||||
return true;
|
||||
}
|
||||
|
||||
function push(output: string, mode: string, scale: real, transform: int): void {
|
||||
// Values are validated above and the output name comes from the
|
||||
// compositor's own list, so nothing user-authored reaches the payload.
|
||||
applyRun.exec(["hyprctl", "eval",
|
||||
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||
// Settings restore holds the external-change lock while it proves a
|
||||
// snapshot. This narrow entry point authorizes that one transaction while
|
||||
// keeping every user-facing control blocked until restore settles.
|
||||
function applyProtectedLayout(layout: var): bool {
|
||||
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 {
|
||||
if (!root.canConfirm || !root.matchesRequest(
|
||||
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||
if (!root.canConfirm
|
||||
|| !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
|
||||
if (root.awaitingConfirmation)
|
||||
root.lastError = "Wait for the display to finish applying before keeping it.";
|
||||
return false;
|
||||
@@ -343,11 +430,16 @@ Singleton {
|
||||
|
||||
const stored = DesktopPreferences.get("displays");
|
||||
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||
next[root.pendingOutput] = {
|
||||
mode: root.pendingRequested.mode,
|
||||
scale: root.pendingRequested.scale,
|
||||
transform: root.pendingRequested.transform
|
||||
};
|
||||
for (const record of root.pendingRequestedLayout) {
|
||||
next[record.name] = {
|
||||
mode: record.mode,
|
||||
scale: record.scale,
|
||||
transform: record.transform,
|
||||
x: record.x,
|
||||
y: record.y,
|
||||
primary: record.primary
|
||||
};
|
||||
}
|
||||
if (!DesktopPreferences.set("displays", next)) {
|
||||
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||
return false;
|
||||
@@ -361,9 +453,8 @@ Singleton {
|
||||
function clearPending(): void {
|
||||
countdown.stop();
|
||||
verifyTimer.stop();
|
||||
root.pendingOutput = "";
|
||||
root.pendingPrevious = null;
|
||||
root.pendingRequested = null;
|
||||
root.pendingPreviousLayout = null;
|
||||
root.pendingRequestedLayout = null;
|
||||
root.pendingVerified = false;
|
||||
root.revertQueued = false;
|
||||
root.secondsLeft = 0;
|
||||
@@ -390,16 +481,25 @@ Singleton {
|
||||
}
|
||||
|
||||
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.revertGeneration = root.operationGeneration;
|
||||
root.revertExpected = previous;
|
||||
root.revertExpectedLayout = previous.length > 0 ? previous : null;
|
||||
root.revertVerificationActive = false;
|
||||
root.clearPending();
|
||||
if (previous) {
|
||||
revertRun.exec(["hyprctl", "eval",
|
||||
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
||||
}
|
||||
if (previous.length > 0)
|
||||
root.pushLayout(previous, revertRun);
|
||||
else
|
||||
root.lastError = root.revertReason;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 {
|
||||
id: verifyTimer
|
||||
property int attempts: 0
|
||||
property int ticks: 0
|
||||
interval: 120
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
ticks++;
|
||||
if (ticks > 50) {
|
||||
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||
root.verificationTimedOut();
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
attempts++;
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: revertVerifyTimer
|
||||
property int attempts: 0
|
||||
property int ticks: 0
|
||||
interval: 120
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
ticks++;
|
||||
if (ticks > 50) {
|
||||
stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpected = null;
|
||||
root.revertReason = "";
|
||||
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||
root.revertVerificationTimedOut();
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
attempts++;
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ Singleton {
|
||||
root.searching = false;
|
||||
if (exitCode !== 0)
|
||||
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: ""
|
||||
|
||||
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) => {
|
||||
root.lastCopyResult = exitCode === 0
|
||||
? "Report copied."
|
||||
@@ -339,6 +348,10 @@ Singleton {
|
||||
return false;
|
||||
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
|
||||
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"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ Singleton {
|
||||
property string actionKind: ""
|
||||
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: {
|
||||
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
|
||||
return phones.find(device => device.reachable) ?? phones[0] ?? null;
|
||||
@@ -66,6 +76,8 @@ Singleton {
|
||||
root.transferActive = true;
|
||||
root.transferFileName = path.split("/").pop();
|
||||
root.transferDeviceName = root.preferredPhone.name;
|
||||
root.actionExited = false;
|
||||
root.actionStdoutDone = false;
|
||||
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
|
||||
actionProc.running = true;
|
||||
}
|
||||
@@ -83,6 +95,8 @@ Singleton {
|
||||
return;
|
||||
root.actionKind = kind;
|
||||
root.actionPath = "";
|
||||
root.actionExited = false;
|
||||
root.actionStdoutDone = false;
|
||||
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
|
||||
actionProc.running = true;
|
||||
}
|
||||
@@ -95,6 +109,22 @@ Singleton {
|
||||
root.transferActive = false;
|
||||
root.transferFileName = "";
|
||||
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 {
|
||||
@@ -198,11 +228,15 @@ Singleton {
|
||||
Process {
|
||||
id: actionProc
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.finishAction(this.text)
|
||||
onStreamFinished: {
|
||||
root.actionStdoutDone = true;
|
||||
root.actionStdoutText = this.text;
|
||||
root.settleAction();
|
||||
}
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (root.actionKind !== "")
|
||||
root.finishAction("");
|
||||
root.actionExited = true;
|
||||
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:
|
||||
//
|
||||
// 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
|
||||
// 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") : "";
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
function notificationAppId(notification: var): string {
|
||||
@@ -156,7 +171,7 @@ Singleton {
|
||||
}
|
||||
|
||||
// history grouped by app, in most-recent-app-first order — the shape
|
||||
// NotificationCenter.qml renders directly.
|
||||
// NotificationList.qml renders directly.
|
||||
readonly property var groups: {
|
||||
const out = [];
|
||||
const byApp = {};
|
||||
@@ -219,8 +234,44 @@ Singleton {
|
||||
root.unreadCount += 1;
|
||||
}
|
||||
|
||||
if (!root.doNotDisturb)
|
||||
if (!root.doNotDisturb) {
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
@@ -243,10 +294,7 @@ Singleton {
|
||||
if (next.length === root.popups.length)
|
||||
return;
|
||||
root.popups = next;
|
||||
|
||||
// Transients were never in history, so nothing else holds them.
|
||||
if (n.transient)
|
||||
n.dismiss();
|
||||
root.releaseTransient(n);
|
||||
}
|
||||
|
||||
function dismiss(n: Notification): void {
|
||||
@@ -257,10 +305,19 @@ Singleton {
|
||||
// Copy first: dismiss() re-enters through forget() and rewrites both
|
||||
// lists while we iterate.
|
||||
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.popups = [];
|
||||
for (const n of all)
|
||||
n.dismiss();
|
||||
for (const n of transients)
|
||||
root.releaseTransient(n);
|
||||
root.unreadCount = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,20 +41,28 @@ Singleton {
|
||||
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
||||
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
||||
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 applyIdle: function() { IdleLock.apply(); }
|
||||
property var idleBusy: function() { return IdleLock.busy; }
|
||||
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||
property var systemBusy: function() { return SystemSettings.busy; }
|
||||
property var currentWallpaper: function() {
|
||||
return String(DesktopPreferences.get("wallpaperPath") ?? "");
|
||||
}
|
||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||
property var applyWallpaperPolicy: function() { Wallpaper.applyCurrentPolicy(false); }
|
||||
property var wallpaperBusy: function() { return Wallpaper.busy; }
|
||||
property var regenerateLock: function() { LockScreen.regenerate(); }
|
||||
property var lockBusy: function() { return LockScreen.busy; }
|
||||
property var reloadShell: function() { Quickshell.reload(false); }
|
||||
property var protectedDisplays: ({})
|
||||
property var protectedDisplayLayout: []
|
||||
property var pendingRestoredLayout: null
|
||||
|
||||
readonly property bool busy: listQuery.running || actionRun.running
|
||||
|| applyRestoredState.running || settleReload.running
|
||||
|| settleDisplayRestore.running || applyRestoredState.running || settleReload.running
|
||||
|
||||
Process {
|
||||
id: listQuery
|
||||
@@ -89,18 +97,21 @@ Singleton {
|
||||
if (actionRun.restoring) {
|
||||
root.setDisplayBlocked(false);
|
||||
root.protectedDisplays = ({});
|
||||
root.protectedDisplayLayout = [];
|
||||
}
|
||||
return;
|
||||
}
|
||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||
if (actionRun.restoring) {
|
||||
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
|
||||
root.lastError = homeReloaded
|
||||
? ""
|
||||
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||
if (!homeReloaded) {
|
||||
const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
|
||||
if (restoreAccepted)
|
||||
root.lastError = "";
|
||||
else if (root.lastError === "")
|
||||
root.lastError = "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||
if (!restoreAccepted) {
|
||||
root.setDisplayBlocked(false);
|
||||
root.protectedDisplays = ({});
|
||||
root.protectedDisplayLayout = [];
|
||||
}
|
||||
} else
|
||||
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 {
|
||||
id: applyRestoredState
|
||||
interval: 80
|
||||
@@ -116,9 +149,11 @@ Singleton {
|
||||
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
||||
// These services also own state outside QML and need an explicit
|
||||
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
||||
root.applyCompositor();
|
||||
root.applyIdle();
|
||||
root.regenerateLock();
|
||||
root.applyWallpaperPolicy();
|
||||
root.reloadKeybinds();
|
||||
root.applyWallpaper(root.currentWallpaper());
|
||||
root.applyCompositor();
|
||||
|
||||
settleReload.attempts = 0;
|
||||
settleReload.restart();
|
||||
@@ -135,10 +170,12 @@ Singleton {
|
||||
// Let the current instances finish their external writes before a
|
||||
// soft reload replaces them. The cap keeps a failed external tool
|
||||
// from leaving restored Home state stale indefinitely.
|
||||
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
||||
if ((!root.idleBusy() && !root.keybindsReloading() && !root.systemBusy()
|
||||
&& !root.wallpaperBusy() && !root.lockBusy()) || attempts >= 30) {
|
||||
stop();
|
||||
root.setDisplayBlocked(false);
|
||||
root.protectedDisplays = ({});
|
||||
root.protectedDisplayLayout = [];
|
||||
root.reloadShell();
|
||||
}
|
||||
}
|
||||
@@ -177,12 +214,77 @@ Singleton {
|
||||
if (!root.reloadHomeState(text))
|
||||
return false;
|
||||
root.reloadDesktop();
|
||||
if (!root.protectDisplays(root.protectedDisplays))
|
||||
return false;
|
||||
applyRestoredState.restart();
|
||||
const restoredLayout = root.layoutFromStoredDisplays(root.readDisplays());
|
||||
if (restoredLayout === null || root.layoutsEqual(
|
||||
restoredLayout, root.protectedDisplayLayout)) {
|
||||
root.beginRestoredStateReplay();
|
||||
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
|
||||
// these methods keeps validation and persistence inside HomePreferences;
|
||||
// this service never mutates its aliases or private FileView directly.
|
||||
@@ -250,6 +352,8 @@ Singleton {
|
||||
const currentDisplays = root.readDisplays();
|
||||
root.protectedDisplays = JSON.parse(JSON.stringify(
|
||||
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||
root.protectedDisplayLayout = JSON.parse(JSON.stringify(
|
||||
root.readLiveDisplayLayout() ?? []));
|
||||
root.setDisplayBlocked(true);
|
||||
actionRun.restoring = true;
|
||||
actionRun.exec([root.helperPath, "restore", name]);
|
||||
|
||||
@@ -26,6 +26,7 @@ Singleton {
|
||||
// entry here still appears in results and routes to Home rather than being
|
||||
// dropped, so adding a group can never make a setting unreachable.
|
||||
readonly property var groupPages: ({
|
||||
"appearance": "appearance",
|
||||
"clock": "appearance",
|
||||
"vitals": "appearance",
|
||||
"typography": "appearance",
|
||||
@@ -34,6 +35,7 @@ Singleton {
|
||||
"windows": "appearance",
|
||||
"effects": "appearance",
|
||||
"wallpaper": "appearance",
|
||||
"lockAppearance": "appearance",
|
||||
"dock": "desktop",
|
||||
"focus": "desktop",
|
||||
"display": "displays",
|
||||
@@ -47,7 +49,7 @@ Singleton {
|
||||
"edges": "desktop",
|
||||
"master": "desktop",
|
||||
"notices": "desktop",
|
||||
"weather": "appearance",
|
||||
"weather": "home",
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence"
|
||||
})
|
||||
@@ -65,7 +67,13 @@ Singleton {
|
||||
{ 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: "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 {
|
||||
@@ -93,7 +101,8 @@ Singleton {
|
||||
for (const entry of PreferenceSchema.entries) {
|
||||
if (entry.internal)
|
||||
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)
|
||||
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
|
||||
}
|
||||
@@ -110,8 +119,16 @@ Singleton {
|
||||
|
||||
// Exact prefix matches first: typing "blur" should put "Blur" above
|
||||
// "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) => {
|
||||
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 bl = b.label.toLowerCase();
|
||||
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
|
||||
|
||||
@@ -35,13 +35,32 @@ Singleton {
|
||||
|
||||
function setEventSounds(enabled: bool): void {
|
||||
root.eventSounds = enabled;
|
||||
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)];
|
||||
eventWrite.running = true;
|
||||
root._writeEventSounds();
|
||||
}
|
||||
|
||||
function setInputFeedback(enabled: bool): void {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -71,6 +90,7 @@ Singleton {
|
||||
|
||||
Process {
|
||||
id: eventWrite
|
||||
property bool writtenValue: true
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.lastError = "Event sound preferences could not be changed.";
|
||||
@@ -78,11 +98,14 @@ Singleton {
|
||||
} else {
|
||||
root.lastError = "";
|
||||
}
|
||||
if (root.eventSounds !== eventWrite.writtenValue)
|
||||
root._writeEventSounds();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: inputWrite
|
||||
property bool writtenValue: false
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.lastError = "Input feedback preferences could not be changed.";
|
||||
@@ -90,6 +113,8 @@ Singleton {
|
||||
} else {
|
||||
root.lastError = "";
|
||||
}
|
||||
if (root.inputFeedback !== inputWrite.writtenValue)
|
||||
root._writeInputFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,25 @@ Singleton {
|
||||
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 {
|
||||
if (value === root.current)
|
||||
return;
|
||||
root.requestedValue = value;
|
||||
if (!apply.running)
|
||||
root._applyValue(value);
|
||||
}
|
||||
|
||||
function _applyValue(value: string): void {
|
||||
root.requestedValue = "";
|
||||
apply.command = [root.helperPath, "set", value];
|
||||
apply.pendingValue = value;
|
||||
apply.running = true;
|
||||
@@ -94,6 +110,16 @@ Singleton {
|
||||
} else {
|
||||
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 keybindsReloading: function() { return Keybinds.reloading; }
|
||||
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
|
||||
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
||||
@@ -507,16 +509,13 @@ Singleton {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentDisplays = root.readDisplays();
|
||||
const protectedDisplays = JSON.parse(JSON.stringify(
|
||||
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||
root.setDisplayBlocked(true);
|
||||
DesktopPreferences.resetDesktopDefaults();
|
||||
if (!root.protectDisplays(protectedDisplays)) {
|
||||
root.setDisplayBlocked(false);
|
||||
root.lastError = "The current display setting could not be protected during reset.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do not apply geometry during a reset: doing so would need the same
|
||||
// visible confirmation transaction as the Displays page. Clearing the
|
||||
// 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
|
||||
// that only cleared the schema store would silently leave a customised
|
||||
@@ -537,6 +536,7 @@ Singleton {
|
||||
root.applyPersistedDisplayPolicy();
|
||||
root.reloadKeybinds();
|
||||
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
||||
root.regenerateLock();
|
||||
resetRelease.attempts = 0;
|
||||
resetRelease.restart();
|
||||
}
|
||||
@@ -549,7 +549,7 @@ Singleton {
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
attempts++;
|
||||
if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) {
|
||||
if ((!root.keybindsReloading() && !root.busy && !root.lockBusy()) || attempts >= 50) {
|
||||
stop();
|
||||
root.setDisplayBlocked(false);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,55 @@
|
||||
pragma Singleton
|
||||
|
||||
// The desktop background.
|
||||
//
|
||||
// hyprpaper owns the actual painting; this owns choosing. Two things are worth
|
||||
// knowing about hyprpaper 0.8:
|
||||
//
|
||||
// * Its IPC is much smaller than the documentation for older versions
|
||||
// suggests. `wallpaper <output>,<path>` and `listactive` work; `preload`,
|
||||
// `listloaded`, `unload`, and `reload` all answer "invalid hyprpaper
|
||||
// request". So there is no preload step -- setting is a single call.
|
||||
// * hyprpaper.conf lives in the Panama repo via the ~/.config/hypr symlink,
|
||||
// so it cannot be rewritten at runtime without dirtying a tracked file.
|
||||
// The chosen wallpaper therefore lives in the shared settings store like
|
||||
// every other preference, and is re-applied when the shell starts.
|
||||
//
|
||||
// The argument is "<output>,<path>", so a path containing a comma would be
|
||||
// parsed as a different request. The schema's pattern rejects those, and the
|
||||
// value is passed as a single argv element rather than through a shell.
|
||||
// Verified wallpaper policy application. hyprpaper 0.8 applies one output per
|
||||
// IPC call, so Panama queues every connected output and persists a policy only
|
||||
// after listactive confirms the complete map.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import "WallpaperPolicy.js" as WallpaperPolicy
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Absolute paths of candidate images, newest first.
|
||||
property var available: []
|
||||
property string active: ""
|
||||
property var activeByOutput: ({})
|
||||
property string lastError: ""
|
||||
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 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: [
|
||||
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
||||
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
||||
@@ -47,20 +59,14 @@ Singleton {
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
|
||||
// -print0 would be safer against odd filenames, but the schema already
|
||||
// rejects paths containing commas or newlines, and this list is only
|
||||
// ever offered as candidates -- the value that gets stored is validated
|
||||
// again on the way in.
|
||||
command: ["bash", "-lc",
|
||||
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
||||
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
||||
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const paths = this.text.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
||||
root.available = paths;
|
||||
root.available = this.text.split("\n")
|
||||
.map(line => line.trim()).filter(line => line.length > 0);
|
||||
root.scanning = false;
|
||||
}
|
||||
}
|
||||
@@ -71,62 +77,81 @@ Singleton {
|
||||
command: ["hyprctl", "hyprpaper", "listactive"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
// "DP-2: /path/to/image.jpg", one line per output.
|
||||
const first = this.text.split("\n").find(line => line.indexOf(":") > 0);
|
||||
root.active = first ? first.slice(first.indexOf(":") + 1).trim() : "";
|
||||
const parsed = root.parseActive(this.text);
|
||||
if (parsed !== null)
|
||||
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 {
|
||||
id: apply
|
||||
|
||||
property string requested: ""
|
||||
property string storedValue: ""
|
||||
property var remaining: []
|
||||
|
||||
id: applyProcess
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (root.transaction === null)
|
||||
return;
|
||||
if (exitCode !== 0) {
|
||||
root.lastError = "hyprpaper could not load that image.";
|
||||
apply.remaining = [];
|
||||
root.lastError = "Hyprpaper did not apply that background.";
|
||||
root.transaction = null;
|
||||
root.schedulePendingHotplug();
|
||||
return;
|
||||
}
|
||||
if (apply.remaining.length > 0) {
|
||||
const next = apply.remaining[0];
|
||||
apply.remaining = apply.remaining.slice(1);
|
||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${next},${apply.requested}`]);
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
DesktopPreferences.set("wallpaperPath", apply.storedValue);
|
||||
root.refreshActive();
|
||||
root.drainTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: verifyProcess
|
||||
property string outputText: ""
|
||||
onStarted: outputText = ""
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: verifyProcess.outputText = this.text
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.rescan();
|
||||
root.refreshActive();
|
||||
restore.restart();
|
||||
}
|
||||
|
||||
// hyprpaper is started by the compositor's autostart, so it may not be
|
||||
// listening yet when the shell comes up. Re-applying the stored choice
|
||||
// after a short delay makes the wallpaper survive a reboot without needing
|
||||
// hyprpaper.conf to know about it.
|
||||
Timer {
|
||||
id: restore
|
||||
interval: 1500
|
||||
onTriggered: {
|
||||
const stored = root.configured;
|
||||
if (stored !== "" && stored !== root.active)
|
||||
root.set(stored);
|
||||
if (root.startupRestoreEnabled)
|
||||
root.applyCurrentPolicy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (scan.running)
|
||||
return;
|
||||
@@ -135,37 +160,268 @@ Singleton {
|
||||
}
|
||||
|
||||
function refreshActive(): void {
|
||||
if (!activeQuery.running)
|
||||
if (!activeQuery.running && !root.busy)
|
||||
activeQuery.running = true;
|
||||
}
|
||||
|
||||
// Applies to every connected output. Returns false when the path is not one
|
||||
// the schema will accept, so a caller can report the refusal.
|
||||
function set(path: string): bool {
|
||||
const effectivePath = path === "" ? root.shippedPath : path;
|
||||
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
|
||||
root.lastError = "That file path cannot be used as a wallpaper.";
|
||||
function candidates(extra: var): var {
|
||||
const result = [];
|
||||
const discovered = Array.isArray(root.candidateOverride)
|
||||
? root.candidateOverride : root.available;
|
||||
for (const path of discovered.concat([root.shippedPath, root.configured]).concat(extra || [])) {
|
||||
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;
|
||||
}
|
||||
if (apply.running)
|
||||
return false;
|
||||
|
||||
apply.requested = effectivePath;
|
||||
apply.storedValue = path;
|
||||
|
||||
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||
const outputs = root.outputNames();
|
||||
if (outputs.length === 0) {
|
||||
root.lastError = "No display to set a wallpaper on.";
|
||||
return false;
|
||||
}
|
||||
|
||||
apply.remaining = outputs.slice(1);
|
||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
|
||||
const expected = WallpaperPolicy.effectiveMap(
|
||||
normalised.mode, normalised.globalPath, normalised.slideshowPath,
|
||||
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;
|
||||
}
|
||||
|
||||
// The display name for a path: the file's own name, without extension,
|
||||
// with separators turned into spaces.
|
||||
function drainTransaction(): void {
|
||||
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 {
|
||||
const file = String(path).split("/").pop();
|
||||
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 bool displayOperationBusy: 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 {
|
||||
const next = root.calls.slice();
|
||||
@@ -45,7 +59,10 @@ ShellRoot {
|
||||
root.homeFavorites = root.homeFavorites.map(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.protectDisplays = function(value) {
|
||||
root.record("display.protect:" + JSON.stringify(value));
|
||||
@@ -53,16 +70,39 @@ ShellRoot {
|
||||
return true;
|
||||
};
|
||||
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) {
|
||||
root.record("display.block:" + blocked);
|
||||
root.displayBlocked = blocked;
|
||||
};
|
||||
SettingsBackup.applyIdle = function() { root.record("idle.apply"); };
|
||||
SettingsBackup.idleBusy = function() { return false; };
|
||||
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
|
||||
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
||||
SettingsBackup.keybindsReloading = function() { return false; };
|
||||
SettingsBackup.systemBusy = function() { return false; };
|
||||
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
|
||||
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
|
||||
SettingsBackup.applyWallpaperPolicy = function() { root.record("wallpaper.apply-policy"); };
|
||||
SettingsBackup.wallpaperBusy = function() { return false; };
|
||||
SettingsBackup.regenerateLock = function() { root.record("lock.regenerate"); };
|
||||
SettingsBackup.lockBusy = function() { return false; };
|
||||
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
|
||||
}
|
||||
|
||||
@@ -75,7 +115,15 @@ ShellRoot {
|
||||
root.homeFavorites = [];
|
||||
root.displayOperationBusy = 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.protectedDisplayLayout = root.liveLayout;
|
||||
}
|
||||
|
||||
function apply(output: string): bool {
|
||||
@@ -88,6 +136,11 @@ ShellRoot {
|
||||
return SettingsBackup.restore("settings-20260818-010203004.json");
|
||||
}
|
||||
|
||||
function applyDisplayFailure(output: string): bool {
|
||||
root.displayApplyAccepted = false;
|
||||
return SettingsBackup.handleRestoreOutput(output);
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
calls: root.calls,
|
||||
|
||||
@@ -46,6 +46,8 @@ ShellRoot {
|
||||
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
|
||||
SystemSettings.keybindsReloading = function() { return false; };
|
||||
SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); };
|
||||
SystemSettings.regenerateLock = function() { root.recordReset("lock.regenerate"); };
|
||||
SystemSettings.lockBusy = function() { return false; };
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
|
||||
@@ -28,6 +28,7 @@ import qs.config
|
||||
import qs.services
|
||||
import qs.modules.bar
|
||||
import qs.modules.dock
|
||||
import qs.modules.switcher
|
||||
import qs.modules.overview
|
||||
import qs.modules.quicksettings
|
||||
import qs.modules.notifications
|
||||
@@ -68,6 +69,13 @@ ShellRoot {
|
||||
Dock {}
|
||||
}
|
||||
|
||||
// The Alt-Tab overlay. Present only while a switch is in progress; the
|
||||
// Loader inside it keeps the window unbuilt the rest of the time.
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
WindowSwitcher {}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
SignalGlass {}
|
||||
@@ -78,6 +86,8 @@ ShellRoot {
|
||||
Osd {}
|
||||
}
|
||||
|
||||
DisplayIdentify {}
|
||||
|
||||
// ── Single-instance overlays ────────────────────────────────────────────
|
||||
// These are always constructed but only *visible* when ShellState says so.
|
||||
// They're cheap while hidden, and keeping them alive means opening the
|
||||
@@ -103,6 +113,18 @@ ShellRoot {
|
||||
// Every parameter AND the return type must be annotated, or Quickshell
|
||||
// silently declines to register the function — it will not warn you.
|
||||
|
||||
// Driven entirely from keybinds: Super+Tab steps, and a bind on Super
|
||||
// RELEASE commits. `commit` therefore runs on every Super release in the
|
||||
// session, so it returns immediately when no switch is open.
|
||||
IpcHandler {
|
||||
target: "switcher"
|
||||
|
||||
function next(): void { WindowSwitcherState.step(true); }
|
||||
function previous(): void { WindowSwitcherState.step(false); }
|
||||
function commit(): void { WindowSwitcherState.commit(); }
|
||||
function cancel(): void { WindowSwitcherState.cancel(); }
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "overview"
|
||||
function toggle(): void { ShellState.toggle("overview"); }
|
||||
@@ -175,6 +197,7 @@ ShellRoot {
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
active: Wallpaper.active,
|
||||
activeByOutput: Wallpaper.activeByOutput,
|
||||
configured: Wallpaper.configured,
|
||||
availableCount: Wallpaper.available.length,
|
||||
lastError: Wallpaper.lastError
|
||||
|
||||
@@ -9,11 +9,55 @@ import qs.services
|
||||
import qs.modules.settings
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
QtObject {
|
||||
id: fixtureAudio
|
||||
property real volume: 0.42
|
||||
property bool muted: false
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: fixtureNode
|
||||
property QtObject audio: fixtureAudio
|
||||
}
|
||||
|
||||
readonly property var fixtureApplication: ({
|
||||
key: "fixture.player",
|
||||
label: "Fixture Player",
|
||||
icon: "audio-x-generic-symbolic",
|
||||
nodes: [fixtureNode]
|
||||
})
|
||||
|
||||
SoundPage {
|
||||
width: 760
|
||||
height: 900
|
||||
}
|
||||
|
||||
ApplicationMixer {
|
||||
id: populatedMixer
|
||||
width: 620
|
||||
visible: false
|
||||
applications: [root.fixtureApplication]
|
||||
pipewireReady: true
|
||||
}
|
||||
|
||||
ApplicationMixer {
|
||||
id: emptyMixer
|
||||
width: 620
|
||||
visible: false
|
||||
applications: []
|
||||
pipewireReady: true
|
||||
}
|
||||
|
||||
ApplicationMixer {
|
||||
id: unavailableMixer
|
||||
width: 620
|
||||
visible: false
|
||||
applications: []
|
||||
pipewireReady: false
|
||||
}
|
||||
|
||||
PwObjectTracker {
|
||||
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
|
||||
}
|
||||
@@ -26,8 +70,15 @@ ShellRoot {
|
||||
ready: Pipewire.ready,
|
||||
outputs: AudioDevices.outputs.length,
|
||||
inputs: AudioDevices.inputs.length,
|
||||
applications: AudioDevices.applications.length,
|
||||
defaultOutput: AudioDevices.label(AudioDevices.current(true)),
|
||||
defaultInput: AudioDevices.label(AudioDevices.current(false))
|
||||
defaultInput: AudioDevices.label(AudioDevices.current(false)),
|
||||
populatedRows: populatedMixer.rowCount,
|
||||
populatedStatus: populatedMixer.statusText,
|
||||
emptyRows: emptyMixer.rowCount,
|
||||
emptyStatus: emptyMixer.statusText,
|
||||
unavailableRows: unavailableMixer.rowCount,
|
||||
unavailableStatus: unavailableMixer.statusText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import "services/WallpaperPolicy.js" as WallpaperPolicy
|
||||
|
||||
ShellRoot {
|
||||
readonly property var candidates: ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"]
|
||||
readonly property var outputs: ["DP-2", "HDMI-A-1"]
|
||||
|
||||
IpcHandler {
|
||||
target: "wallpaper-policy-test"
|
||||
|
||||
function status(): string {
|
||||
const collection = WallpaperPolicy.validCollection([
|
||||
"/images/a.jpg", "/invalid.jpg", "/images/b.jpg",
|
||||
"/images/a.jpg", "relative.jpg", "/images/c.jpg"
|
||||
], candidates);
|
||||
const assignments = WallpaperPolicy.validAssignments({
|
||||
"DP-2": "/images/b.jpg",
|
||||
"HDMI-A-1": "/invalid.jpg",
|
||||
"bad connector!": "/images/c.jpg"
|
||||
}, candidates);
|
||||
return JSON.stringify({
|
||||
validAbsolute: WallpaperPolicy.validPath("/images/a.jpg", candidates),
|
||||
invalidComma: WallpaperPolicy.validPath("/images/a,b.jpg", candidates),
|
||||
invalidUnknown: WallpaperPolicy.validPath("/images/nope.jpg", candidates),
|
||||
collection,
|
||||
assignments,
|
||||
single: WallpaperPolicy.effectiveMap(
|
||||
"single", "/images/a.jpg", "", assignments, outputs, candidates),
|
||||
perMonitor: WallpaperPolicy.effectiveMap(
|
||||
"per-monitor", "/images/a.jpg", "", assignments, outputs, candidates),
|
||||
slideshow: WallpaperPolicy.effectiveMap(
|
||||
"slideshow", "/images/a.jpg", "/images/c.jpg", assignments, outputs, candidates),
|
||||
ordered: [
|
||||
WallpaperPolicy.orderedNext(collection, "/images/a.jpg"),
|
||||
WallpaperPolicy.orderedNext(collection, "/images/b.jpg"),
|
||||
WallpaperPolicy.orderedNext(collection, "/images/c.jpg")
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function shuffle(): string {
|
||||
const values = [0.8, 0.1, 0.6, 0.2, 0.9, 0.4];
|
||||
let index = 0;
|
||||
const random = () => values[index++ % values.length];
|
||||
let state = { bag: [], current: "" };
|
||||
const emitted = [];
|
||||
for (let count = 0; count < 4; count++) {
|
||||
state = WallpaperPolicy.shuffledNext(candidates, state.bag, state.current, random);
|
||||
emitted.push(state.path);
|
||||
state.current = state.path;
|
||||
}
|
||||
return JSON.stringify({ emitted, remaining: state.bag });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
Component.onCompleted: {
|
||||
Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"];
|
||||
Wallpaper.candidateOverride = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"];
|
||||
Wallpaper.startupRestoreEnabled = false;
|
||||
Wallpaper.slideshowIntervalOverrideMs = 60000;
|
||||
Wallpaper.available = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"];
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "wallpaper-service-test"
|
||||
|
||||
function applyPerMonitor(): bool {
|
||||
return Wallpaper.applyPolicy({
|
||||
mode: "per-monitor",
|
||||
globalPath: "/images/a.jpg",
|
||||
collection: [],
|
||||
intervalMinutes: 30,
|
||||
shuffle: true,
|
||||
assignments: { "HDMI-A-1": "/images/b.jpg" },
|
||||
slideshowPath: ""
|
||||
}, true);
|
||||
}
|
||||
|
||||
function applySingle(path: string): bool {
|
||||
return Wallpaper.set(path);
|
||||
}
|
||||
|
||||
function seedSlideshow(paths: string, shuffle: bool): void {
|
||||
DesktopPreferences.set("wallpaperMode", "slideshow");
|
||||
DesktopPreferences.set("wallpaperPath", "/images/a.jpg");
|
||||
DesktopPreferences.set("wallpaperSlideshowPaths", JSON.parse(paths));
|
||||
DesktopPreferences.set("wallpaperShuffle", shuffle);
|
||||
Wallpaper.slideshowPath = "/images/a.jpg";
|
||||
Wallpaper.shuffleBag = [];
|
||||
}
|
||||
|
||||
function advanceSlideshow(): bool {
|
||||
return Wallpaper.advanceSlideshow();
|
||||
}
|
||||
|
||||
function rapidOutputs(): void {
|
||||
Wallpaper.outputOverride = ["DP-2"];
|
||||
Wallpaper.outputOverride = ["HDMI-A-1"];
|
||||
Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"];
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
busy: Wallpaper.busy,
|
||||
lastError: Wallpaper.lastError,
|
||||
activeByOutput: Wallpaper.activeByOutput,
|
||||
active: Wallpaper.active,
|
||||
mode: DesktopPreferences.get("wallpaperMode"),
|
||||
path: DesktopPreferences.get("wallpaperPath"),
|
||||
assignments: DesktopPreferences.get("wallpaperPerMonitor"),
|
||||
slideshowPath: Wallpaper.slideshowPath,
|
||||
shuffleBag: Wallpaper.shuffleBag,
|
||||
slideshowTimerRunning: Wallpaper.slideshowTimerRunning
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property var calls: []
|
||||
|
||||
function record(call: string): void {
|
||||
root.calls = root.calls.concat([call]);
|
||||
}
|
||||
|
||||
WallpaperControls {
|
||||
id: controls
|
||||
width: 620
|
||||
outputs: ["DP-2", "HDMI-A-1"]
|
||||
mode: "per-monitor"
|
||||
setModeAction: mode => root.record("mode:" + mode)
|
||||
setIntervalAction: minutes => root.record("interval:" + minutes)
|
||||
setShuffleAction: enabled => root.record("shuffle:" + enabled)
|
||||
}
|
||||
|
||||
WallpaperPicker {
|
||||
id: picker
|
||||
width: 620
|
||||
selectedOutput: "DP-2"
|
||||
mode: "single"
|
||||
activeByOutput: ({ "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" })
|
||||
slideshowPaths: ["/images/b.jpg", "/images/c.jpg"]
|
||||
assignments: ({ "DP-2": "/images/c.jpg" })
|
||||
setSingleAction: path => root.record("single:" + path)
|
||||
toggleSlideshowAction: path => root.record("toggle:" + path)
|
||||
setAssignmentAction: (output, path) => root.record("assign:" + output + ":" + path)
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "wallpaper-settings-test"
|
||||
|
||||
function activate(mode: string, path: string): string {
|
||||
root.calls = [];
|
||||
picker.mode = mode;
|
||||
picker.activate(path);
|
||||
return JSON.stringify(root.calls);
|
||||
}
|
||||
|
||||
function states(): string {
|
||||
picker.mode = "single";
|
||||
const single = {
|
||||
current: picker.isCurrent("/images/a.jpg"),
|
||||
selected: picker.selected("/images/a.jpg")
|
||||
};
|
||||
picker.mode = "slideshow";
|
||||
const slideshow = {
|
||||
current: picker.isCurrent("/images/b.jpg"),
|
||||
selected: picker.selected("/images/b.jpg")
|
||||
};
|
||||
picker.mode = "per-monitor";
|
||||
const perMonitor = {
|
||||
current: picker.isCurrent("/images/c.jpg"),
|
||||
selected: picker.selected("/images/c.jpg")
|
||||
};
|
||||
return JSON.stringify({
|
||||
selectedOutput: controls.selectedOutput,
|
||||
single,
|
||||
slideshow,
|
||||
perMonitor
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,12 @@ Item {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
function apply(mouseX) {
|
||||
const ratio = Math.max(0, Math.min(1, (mouseX + 8) / track.width));
|
||||
// mouseX is local to this MouseArea, whose anchors.margins: -8
|
||||
// pushes its own origin 8px before the track's left edge. So
|
||||
// MouseArea-local x=8 is track-local x=0 -- subtract the
|
||||
// margin to land back in the track's own coordinate space
|
||||
// before turning it into a fraction.
|
||||
const ratio = Math.max(0, Math.min(1, (mouseX - 8) / track.width));
|
||||
root.moved(ratio);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# Phase 2 Application Volume Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a live, non-persistent per-application playback mixer to Panama Settings.
|
||||
|
||||
**Architecture:** A small pure JavaScript module groups PipeWire playback nodes and owns aggregate calculations. `AudioDevices.qml` exposes the live groups, while focused QML components render and mutate the real tracked nodes.
|
||||
|
||||
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, `Quickshell.Services.Pipewire`, Bash contract harnesses
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- PipeWire remains the source of truth; do not persist stream identity or volume.
|
||||
- Include only ready `PwNodeType.AudioOutStream` nodes with writable audio state.
|
||||
- Group by `application.id`, binary, name, then node ID, in that order.
|
||||
- No polling, shell commands, or logging of stream metadata.
|
||||
- Preserve the existing output, input, balance, and Fedora device-profile controls.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Pure application-stream model
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/services/AudioStreams.js`
|
||||
- Create: `config/dot/quickshell/audio-streams-harness.qml`
|
||||
- Create: `tests/quickshell/application-volume-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: PipeWire-shaped nodes with `id`, `ready`, `type`, `properties`, and `audio`.
|
||||
- Produces: `group(nodes, audioOutStreamFlag)`, `volume(group)`, `muted(group)`, `setVolume(group, value)`, and `setMuted(group, muted)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing grouping contract**
|
||||
|
||||
Create complete fixture nodes for two Chromium streams, one Spotify stream, an input stream, an unready stream, and a metadata-free stream. Through IPC, assert the literal groups and labels:
|
||||
|
||||
```json
|
||||
[
|
||||
{"key":"org.chromium.Chromium","label":"Chromium","icon":"chromium","count":2},
|
||||
{"key":"spotify","label":"Spotify","icon":"audio-x-generic-symbolic","count":1},
|
||||
{"key":"node:99","label":"Unknown application","icon":"audio-x-generic-symbolic","count":1}
|
||||
]
|
||||
```
|
||||
|
||||
Also assert Chromium volume `0.6` from fixture values `0.4` and `0.8`, mixed mute reports `false`, setting volume writes both nodes, and setting mute normalizes both nodes.
|
||||
|
||||
- [ ] **Step 2: Run the contract and verify RED**
|
||||
|
||||
Run: `tests/quickshell/application-volume-contract.sh`
|
||||
|
||||
Expected: FAIL because `AudioStreams.js` and the IPC target do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal pure model**
|
||||
|
||||
Export these exact functions:
|
||||
|
||||
```javascript
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
Use literal property lookups and `Number.isFinite`; do not import PipeWire into the pure module.
|
||||
|
||||
- [ ] **Step 4: Run the contract and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/application-volume-contract.sh`
|
||||
|
||||
Expected: PASS for grouping, fallback metadata, aggregate volume, mixed mute, and writes.
|
||||
|
||||
- [ ] **Step 5: Commit the model**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/AudioStreams.js config/dot/quickshell/audio-streams-harness.qml tests/quickshell/application-volume-contract.sh
|
||||
git commit -m "Model live application audio streams"
|
||||
```
|
||||
|
||||
### Task 2: Live PipeWire service boundary
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/AudioDevices.qml`
|
||||
- Modify: `config/dot/quickshell/audio-streams-harness.qml`
|
||||
- Modify: `tests/quickshell/application-volume-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AudioStreams.group(nodes, PwNodeType.AudioOutStream)`.
|
||||
- Produces: `readonly property var applications` and typed wrappers `applicationVolume`, `applicationMuted`, `setApplicationVolume`, `setApplicationMuted`.
|
||||
|
||||
- [ ] **Step 1: Extend the contract for the service API**
|
||||
|
||||
Assert the harness can report real `AudioDevices.applications` without starting playback and that every returned group contains only nodes with the AudioOutStream flag. Assert mutator wrappers reject `null` and a group with no live audio nodes.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/application-volume-contract.sh`
|
||||
|
||||
Expected: FAIL because `AudioDevices.applications` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add the reactive service properties**
|
||||
|
||||
Implement:
|
||||
|
||||
```qml
|
||||
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)
|
||||
```
|
||||
|
||||
Delegate aggregate reads and writes to the pure model. Preserve `outputs`, `inputs`, `nodes()`, `current()`, and `select()` unchanged.
|
||||
|
||||
- [ ] **Step 4: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/application-volume-contract.sh`
|
||||
|
||||
Expected: PASS with zero or more real live applications and no mutation of the live streams.
|
||||
|
||||
- [ ] **Step 5: Commit the service boundary**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/AudioDevices.qml config/dot/quickshell/audio-streams-harness.qml tests/quickshell/application-volume-contract.sh
|
||||
git commit -m "Expose live application audio groups"
|
||||
```
|
||||
|
||||
### Task 3: Application mixer UI
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml`
|
||||
- Create: `config/dot/quickshell/modules/settings/ApplicationMixer.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/SoundPage.qml`
|
||||
- Modify: `tests/quickshell/application-volume-contract.sh`
|
||||
- Modify: `tests/quickshell/sound-page-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: one `AudioDevices.applications` group per row.
|
||||
- Produces: a real Sound **Applications** card and the empty-state sentence from the spec.
|
||||
|
||||
- [ ] **Step 1: Write the failing UI contract**
|
||||
|
||||
Construct `SoundPage.qml` in the existing isolated Settings harness. Assert no QML warnings and these rendered states: application rows when groups exist, **Applications playing sound will appear here** when empty, and **PipeWire is unavailable** when the service is not ready. Assert the advanced handoff label is exactly **Device profiles**.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/application-volume-contract.sh && tests/quickshell/sound-page-contract.sh`
|
||||
|
||||
Expected: FAIL because the mixer components and Applications card are absent.
|
||||
|
||||
- [ ] **Step 3: Implement the row and card**
|
||||
|
||||
`ApplicationVolumeRow.qml` must declare a `PwObjectTracker` over `application.nodes`, use `ThemedIcon`, `IconButton`, `ValueSlider`, and a tabular percentage. Its only writes are `AudioDevices.setApplicationMuted(root.application, value)` and `AudioDevices.setApplicationVolume(root.application, value)`. `ApplicationMixer.qml` owns the Repeater and empty/unavailable copy. Put the card after Input and before Sound feedback.
|
||||
|
||||
- [ ] **Step 4: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/application-volume-contract.sh && tests/quickshell/sound-page-contract.sh`
|
||||
|
||||
Expected: PASS with no QML warnings.
|
||||
|
||||
- [ ] **Step 5: Commit the finished application mixer**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml config/dot/quickshell/modules/settings/ApplicationMixer.qml config/dot/quickshell/modules/settings/SoundPage.qml tests/quickshell/application-volume-contract.sh tests/quickshell/sound-page-contract.sh
|
||||
git commit -m "Add application volume mixer"
|
||||
```
|
||||
|
||||
### Task 4: Slice verification
|
||||
|
||||
**Files:**
|
||||
- Modify only if verification exposes a defect in the files above.
|
||||
|
||||
- [ ] **Step 1: Run the focused static and runtime contracts once**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/application-volume-contract.sh
|
||||
tests/quickshell/sound-page-contract.sh
|
||||
tests/quickshell/settings-pages-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS; runtime enumeration may report zero applications without failing.
|
||||
|
||||
- [ ] **Step 2: Inspect Quickshell output**
|
||||
|
||||
The isolated harness output must contain no `ReferenceError`, `TypeError`, binding loop, failed property assignment, or `PwObjectTracker` warning.
|
||||
|
||||
- [ ] **Step 3: Review the diff**
|
||||
|
||||
Run: `git diff --check && git status --short`
|
||||
|
||||
Expected: clean formatting and no uncommitted changes after the Task 3 commit.
|
||||
@@ -0,0 +1,370 @@
|
||||
# Phase 2 Display Arrangement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add safe drag-and-snap multi-monitor positioning and a Panama primary-display role without weakening the existing 15-second rollback contract.
|
||||
|
||||
**Architecture:** A pure layout module validates, normalizes, scales, and snaps logical monitor rectangles. `Displays.qml` upgrades its pending operation to a complete-layout transaction, and a focused canvas component edits drafts before invoking that transaction.
|
||||
|
||||
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, Hyprland 0.56.2 Lua evaluation, Lua startup config, Bash/QML contracts
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- One connected output is primary; it anchors persisted coordinates at logical `0,0`.
|
||||
- Primary does not promise where third-party Wayland applications open.
|
||||
- Every layout operation captures, applies, verifies, confirms, reverts, and verifies the complete connected layout.
|
||||
- Keep is disabled until every output matches mode, scale, transform, x, and y.
|
||||
- Stored disconnected outputs remain untouched and do not enter a live transaction.
|
||||
- Development uses static fixtures; run the existing live display contract once at final completion.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Pure display-layout geometry
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/services/DisplayLayout.js`
|
||||
- Create: `config/dot/quickshell/display-layout-harness.qml`
|
||||
- Create: `tests/quickshell/display-layout-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: records `{name,width,height,scale,transform,x,y,primary}`.
|
||||
- Produces: `logicalSize`, `validate`, `normalize`, `snap`, `bounds`, and `canvasRects`.
|
||||
|
||||
- [ ] **Step 1: Write the failing geometry contract**
|
||||
|
||||
Use literal fixtures:
|
||||
|
||||
```json
|
||||
[
|
||||
{"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}
|
||||
]
|
||||
```
|
||||
|
||||
Assert transformed logical sizes, normalized DP-2 position `0,0`, normalized HDMI position `3000,0`, exactly one primary, and canvas rectangles preserving the complete desktop aspect ratio. Move HDMI within 16 logical pixels of DP-2's right edge and assert it snaps to x `3000`; move it 17 pixels away and assert no snap.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/display-layout-contract.sh`
|
||||
|
||||
Expected: FAIL because the geometry module and harness do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement geometry functions**
|
||||
|
||||
Rules:
|
||||
|
||||
```text
|
||||
transform 0 or 2 -> logical width = width/scale, height = height/scale
|
||||
transform 1 or 3 -> logical width = height/scale, height = width/scale
|
||||
coordinates -> finite integers between -100000 and 100000
|
||||
snap threshold -> 16 logical pixels
|
||||
canvas padding -> caller supplied; return data only, never QML objects
|
||||
```
|
||||
|
||||
`normalize` subtracts the primary x/y from every record and returns new objects. `snap` compares the moving rectangle's four edges with every stationary rectangle's opposite and same-axis edges; choose the smallest eligible delta, then stable output-name order on ties.
|
||||
|
||||
- [ ] **Step 4: Add invalid-layout cases**
|
||||
|
||||
Assert rejection of duplicate outputs, zero or two primaries, fractional coordinates, non-positive scale, unsupported transform, non-finite values, and rectangles with zero logical size.
|
||||
|
||||
- [ ] **Step 5: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/display-layout-contract.sh`
|
||||
|
||||
Expected: PASS for geometry, deterministic snapping, normalization, and invalid cases.
|
||||
|
||||
- [ ] **Step 6: Commit layout geometry**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/DisplayLayout.js config/dot/quickshell/display-layout-harness.qml tests/quickshell/display-layout-contract.sh
|
||||
git commit -m "Model multi-monitor layout geometry"
|
||||
```
|
||||
|
||||
### Task 2: Parse and persist extended monitor records
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/Displays.qml`
|
||||
- Modify: `config/dot/quickshell/displays-harness.qml`
|
||||
- Modify: `config/dot/hypr/monitors.lua`
|
||||
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
|
||||
- Modify: `tests/quickshell/displays-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-preferences-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `hyprctl -j monitors` x/y values and backward-compatible persisted entries.
|
||||
- Produces: monitor records with `x`, `y`, `primary`; Lua validation for optional `x`, `y`, `primary`.
|
||||
|
||||
- [ ] **Step 1: Extend the static contract and Lua fixture**
|
||||
|
||||
Assert parsed monitors retain literal x/y. Feed Lua old, valid new, and malformed records. Expected startup calls:
|
||||
|
||||
```lua
|
||||
DP-2 position = "0x0"
|
||||
HDMI-A-1 position = "3000x0"
|
||||
old valid entry position = "auto"
|
||||
malformed x/y/primary entry = ignored in favor of shipped/auto behavior
|
||||
```
|
||||
|
||||
Assert only one valid persisted primary is honored and DP-2's 10-bit/color policy remains unchanged.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh`
|
||||
|
||||
Expected: FAIL because x/y/primary are neither parsed nor replayed.
|
||||
|
||||
- [ ] **Step 3: Extend monitor parsing**
|
||||
|
||||
Read integer `monitor.x` and `monitor.y`. Derive the live primary from the connected persisted primary when valid, otherwise the output at `0,0`, otherwise the first connected monitor. Include the boolean only in the service model; Hyprland receives position, not a nonexistent primary flag.
|
||||
|
||||
- [ ] **Step 4: Extend Lua validation**
|
||||
|
||||
Add `valid_position(entry)` and `valid_primary(entry)`. An entry is extended only when all three new fields are present and valid; an entry with none remains legacy and uses `position = "auto"`; a partially extended entry is invalid. Render position with `string.format("%dx%d", entry.x, entry.y)`.
|
||||
|
||||
- [ ] **Step 5: Update schema documentation**
|
||||
|
||||
Change the internal `displays` detail to **Resolution, scale, rotation, position, and primary display**. Do not add a second preference.
|
||||
|
||||
- [ ] **Step 6: Run and verify GREEN**
|
||||
|
||||
Run: `PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh && tests/quickshell/settings-preferences-contract.sh`
|
||||
|
||||
Expected: PASS for old and new records.
|
||||
|
||||
- [ ] **Step 7: Commit extended persistence**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/Displays.qml config/dot/quickshell/displays-harness.qml config/dot/hypr/monitors.lua config/dot/quickshell/config/PreferenceSchema.qml tests/quickshell/displays-contract.sh tests/quickshell/settings-preferences-contract.sh
|
||||
git commit -m "Persist complete monitor layouts"
|
||||
```
|
||||
|
||||
### Task 3: Whole-layout transaction and rollback
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/Displays.qml`
|
||||
- Modify: `config/dot/quickshell/displays-harness.qml`
|
||||
- Modify: `tests/quickshell/displays-contract.sh`
|
||||
- Create: `tests/quickshell/display-transaction-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DisplayLayout.validate/normalize` and complete current monitor records.
|
||||
- Produces: public `Displays.currentLayout()`, `Displays.applyLayout(layout)`, `Displays.makePrimary(output)`; internal `matchesLayout(monitors, layout)`; and backward-compatible `apply(output, mode, scale, transform)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing fixture transaction contract**
|
||||
|
||||
Fake `hyprctl` monitor JSON and `eval`. Request a two-output layout and assert the evaluator receives both literal monitor calls in one argv payload. Assert `canConfirm` remains false when one output has wrong y, becomes true only when both match, and confirm persists both records with one primary.
|
||||
|
||||
- [ ] **Step 2: Add rollback and generation cases**
|
||||
|
||||
Assert timeout, explicit revert, non-zero apply exit, wrong readback, and a disconnect generation each restore all still-connected outputs. Return a stale pre-operation query after a newer operation starts and prove it cannot confirm or clear the newer transaction. Force wrong revert readback and assert the existing manual-restoration error.
|
||||
|
||||
- [ ] **Step 3: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/display-transaction-contract.sh`
|
||||
|
||||
Expected: FAIL because `Displays.qml` tracks only one output per operation.
|
||||
|
||||
- [ ] **Step 4: Replace pending records with complete layouts**
|
||||
|
||||
Use:
|
||||
|
||||
```qml
|
||||
property var pendingPreviousLayout: null
|
||||
property var pendingRequestedLayout: null
|
||||
property var revertExpectedLayout: null
|
||||
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
|
||||
```
|
||||
|
||||
Retain the existing generation counters and timers. `matchesLayout` requires equal connected output-name sets and exact x/y/transform, with the existing tolerances for refresh and scale.
|
||||
|
||||
- [ ] **Step 5: Implement one validated evaluation payload**
|
||||
|
||||
Build each call only from compositor-reported output names and validated numeric/mode fields:
|
||||
|
||||
```text
|
||||
hl.monitor({ output = "DP-2", mode = "4500x3000@60.00", position = "0x0", scale = 1.5, transform = 0 }); hl.monitor({ output = "HDMI-A-1", mode = "2560x1440@60.00", position = "3000x0", scale = 1, transform = 0 })
|
||||
```
|
||||
|
||||
Reject quotes or non-connector characters in output names before generation. Preserve sequential Process/readback timing around the single eval.
|
||||
|
||||
- [ ] **Step 6: Preserve one-field callers**
|
||||
|
||||
Keep `apply(output, mode, scale, transform)` by cloning `currentLayout()`, replacing one output's four existing fields, retaining every position and primary flag, then calling `applyLayout`. This keeps `DisplayModePicker`, scale, rotation, and the existing live contract working.
|
||||
|
||||
- [ ] **Step 7: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/display-transaction-contract.sh && PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh`
|
||||
|
||||
Expected: PASS for apply, confirm, timeout, all revert paths, disconnect, and stale generations.
|
||||
|
||||
- [ ] **Step 8: Commit safe transactions**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/Displays.qml config/dot/quickshell/displays-harness.qml tests/quickshell/displays-contract.sh tests/quickshell/display-transaction-contract.sh
|
||||
git commit -m "Apply monitor layouts transactionally"
|
||||
```
|
||||
|
||||
### Task 4: Arrangement canvas
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/settings/DisplayArrangement.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/DisplaysPage.qml`
|
||||
- Create: `tests/quickshell/display-arrangement-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-pages-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Displays.currentLayout()`, `Displays.applyLayout()`, `Displays.makePrimary()`, and `DisplayLayout.canvasRects/snap`.
|
||||
- Produces: selected output, pointer/keyboard draft positioning, **Make primary**, and narrow-layout textual selection.
|
||||
|
||||
- [ ] **Step 1: Write the failing visual-behavior contract**
|
||||
|
||||
Render two literal monitors at wide and 500 px content widths. Through harness IPC, drag HDMI next to DP-2, release, and assert one `applyLayout` call with snapped logical x. Focus HDMI and send Left plus Shift+Left; assert 10 and 100 logical-pixel draft steps. Activate Make primary and assert normalized DP-2 coordinates become negative while HDMI becomes `0,0`.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/display-arrangement-contract.sh`
|
||||
|
||||
Expected: FAIL because no arrangement component exists.
|
||||
|
||||
- [ ] **Step 3: Implement responsive canvas data flow**
|
||||
|
||||
Keep `draftLayout` as copied plain records. Recalculate canvas rectangles from `DisplayLayout.canvasRects` when width or monitors change, but never mutate live service records. Use `DragHandler` for the selected tile and call `DisplayLayout.snap` before mapping canvas movement back to logical coordinates.
|
||||
|
||||
- [ ] **Step 4: Add keyboard and primary actions**
|
||||
|
||||
Each tile is focusable and exposes accessible name **Move DISPLAY_NAME**. Arrow keys modify the draft by 10 logical pixels; Shift uses 100. Enter applies the draft. Escape discards it. **Make primary** changes one boolean, normalizes through the pure module, then applies through the same transaction.
|
||||
|
||||
- [ ] **Step 5: Integrate Displays page**
|
||||
|
||||
Show the card only for two or more monitors. Keep the existing connected-display ChoiceGrid below/inside the selected-display area for narrow tiled widths. Bind arrangement selection and `root.selectedOutput` both ways without loops.
|
||||
|
||||
- [ ] **Step 6: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/display-arrangement-contract.sh && PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh && tests/quickshell/settings-pages-contract.sh`
|
||||
|
||||
Expected: PASS at both widths with no QML warnings.
|
||||
|
||||
- [ ] **Step 7: Commit the canvas**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/settings/DisplayArrangement.qml config/dot/quickshell/modules/settings/DisplaysPage.qml tests/quickshell/display-arrangement-contract.sh tests/quickshell/settings-pages-contract.sh
|
||||
git commit -m "Add monitor arrangement canvas"
|
||||
```
|
||||
|
||||
### Task 5: Static display identification overlays
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/settings/DisplayIdentify.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/DisplayArrangement.qml`
|
||||
- Modify: `config/dot/quickshell/shell.qml`
|
||||
- Modify: `tests/quickshell/display-arrangement-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Displays.identifying` and each `Quickshell.screens` entry.
|
||||
- Produces: `Displays.identify()` and one non-interactive three-second numbered overlay per screen.
|
||||
|
||||
- [ ] **Step 1: Write the failing overlay contract**
|
||||
|
||||
Invoke identify through the fixture harness. Assert screen 1 renders connector/name and number 1, screen 2 renders number 2, overlays accept no keyboard focus or pointer input, and all become invisible after one 3,000 ms single-shot timer. Assert repeated invocation restarts that timer without creating more windows.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/display-arrangement-contract.sh`
|
||||
|
||||
Expected: FAIL because identify state and overlays do not exist.
|
||||
|
||||
- [ ] **Step 3: Add service state and shell-owned windows**
|
||||
|
||||
`Displays.identify()` sets one boolean and restarts one Timer. `DisplayIdentify.qml` uses a `Variants` model over `Quickshell.screens`, one transparent non-focusable `PanelWindow` per screen, centered static number card, and no animations. Instantiate it once from `shell.qml`; the Settings button only calls the service.
|
||||
|
||||
- [ ] **Step 4: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/display-arrangement-contract.sh`
|
||||
|
||||
Expected: PASS with a fixed window count and no focus-grab warnings.
|
||||
|
||||
- [ ] **Step 5: Commit identification**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/settings/DisplayIdentify.qml config/dot/quickshell/modules/settings/DisplayArrangement.qml config/dot/quickshell/shell.qml tests/quickshell/display-arrangement-contract.sh
|
||||
git commit -m "Add display identification overlays"
|
||||
```
|
||||
|
||||
### Task 6: Search, backup, and ownership integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
||||
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/README.md`
|
||||
- Modify: `tests/quickshell/settings-search-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-backup-live-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-commit-reset-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-ownership-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the existing internal `displays` preference and complete-layout transaction.
|
||||
- Produces: search terms for arrangement/primary and protected whole-layout restore.
|
||||
|
||||
- [ ] **Step 1: Write failing integration assertions**
|
||||
|
||||
Assert **arrange displays**, **monitor position**, and **primary display** route to Displays. Restore a two-output snapshot while a current layout is protected; assert complete apply/verify occurs before shell reload and any failed restore retains/proves the original layout. Reset must clear confirmed arrangement fields so startup returns to shipped DP-2 plus automatic placement for other outputs.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh`
|
||||
|
||||
Expected: FAIL for missing terms and one-output restore assumptions.
|
||||
|
||||
- [ ] **Step 3: Update search and restore**
|
||||
|
||||
Add three manual search entries because `displays` is internal. Replace any single-output assumptions in SettingsBackup with cloned complete layout objects and wait on the existing `Displays.busy || Displays.awaitingConfirmation` boundary.
|
||||
|
||||
- [ ] **Step 4: Update ownership documentation**
|
||||
|
||||
Document Displays as the sole owner of mode, scale, rotation, arrangement, and primary role. No mirror entry is added.
|
||||
|
||||
- [ ] **Step 5: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/settings-ownership-contract.sh`
|
||||
|
||||
Expected: PASS with no new duplicated controls.
|
||||
|
||||
- [ ] **Step 6: Commit integration**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/SettingsSearch.qml config/dot/quickshell/services/SettingsBackup.qml config/dot/quickshell/modules/settings/README.md tests/quickshell/settings-search-contract.sh tests/quickshell/settings-backup-live-contract.sh tests/quickshell/settings-commit-reset-contract.sh tests/quickshell/settings-ownership-contract.sh
|
||||
git commit -m "Integrate complete display layouts"
|
||||
```
|
||||
|
||||
### Task 7: Slice verification
|
||||
|
||||
- [ ] **Step 1: Run all static display contracts**
|
||||
|
||||
```bash
|
||||
tests/quickshell/display-layout-contract.sh
|
||||
tests/quickshell/display-transaction-contract.sh
|
||||
tests/quickshell/display-arrangement-contract.sh
|
||||
PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
tests/quickshell/settings-backup-live-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS without touching the physical display.
|
||||
|
||||
- [ ] **Step 2: Validate startup configuration once**
|
||||
|
||||
Run: `Hyprland --verify-config`
|
||||
|
||||
Expected: `config ok`.
|
||||
|
||||
- [ ] **Step 3: Defer the live display test**
|
||||
|
||||
Do not run the state-changing portion of `tests/quickshell/displays-contract.sh` here. The master Phase 2 completion gate runs it once after every slice is stable and restores the observed mode, scale, transform, and position.
|
||||
|
||||
- [ ] **Step 4: Review branch state**
|
||||
|
||||
Run: `git diff --check && git status --short`
|
||||
|
||||
Expected: clean after the Task 6 commit.
|
||||
@@ -0,0 +1,266 @@
|
||||
# Phase 2 Expectation Gaps Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Deliver application volume, managed lock appearance, wallpaper modes, and safe multi-monitor arrangement as one coherent Panama Settings phase.
|
||||
|
||||
**Architecture:** Phase 2 is four independent subsystems, so each has its own executable plan and focused commits. This master plan fixes their order, cross-slice integration, and the single conservative live verification gate.
|
||||
|
||||
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, PipeWire 1.6, Hyprland 0.56.2 Lua, hyprlock 0.9.6, hyprpaper 0.8.4, Bash, jq
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Implement the approved **A — Continuity** direction; do not introduce an inspector or extra navigation level.
|
||||
- Use schema/service boundaries already established by Panama Settings.
|
||||
- Write and observe a focused failing test before every production behavior.
|
||||
- Keep live desktop mutations out of development loops.
|
||||
- Do not start playback, acquire the live lock screen, rotate the live wallpaper through fixtures, or emulate a second physical display.
|
||||
- Run changed-page QML harnesses and the live display contract once at completion, not after every edit.
|
||||
- Each subsystem must be independently green and committed before the next begins.
|
||||
|
||||
## Plan set
|
||||
|
||||
1. `docs/superpowers/plans/2026-08-18-phase2-application-volume.md`
|
||||
2. `docs/superpowers/plans/2026-08-18-phase2-lock-screen.md`
|
||||
3. `docs/superpowers/plans/2026-08-18-phase2-wallpaper-modes.md`
|
||||
4. `docs/superpowers/plans/2026-08-18-phase2-display-arrangement.md`
|
||||
|
||||
## Spec coverage map
|
||||
|
||||
| Approved requirement | Owning plan/tasks |
|
||||
|---|---|
|
||||
| PipeWire application grouping, controls, empty/error states | Application Volume Tasks 1–4 |
|
||||
| Generated lock config, safe fallback, preview, search, restore, health | Lock Screen Tasks 1–5 |
|
||||
| Single/slideshow/per-monitor policy, verification, timer, UI, restore | Wallpaper Modes Tasks 1–6 |
|
||||
| Position, primary role, transaction/revert, canvas, identify, restore | Display Arrangement Tasks 1–7 |
|
||||
| Shared ownership, search vocabulary, deterministic restore ordering | Master Task 5 |
|
||||
| Conservative runtime/config/visual verification and publication | Master Tasks 6–7 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Execute the application-volume plan
|
||||
|
||||
**Files:** Defined in `2026-08-18-phase2-application-volume.md`.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `AudioDevices.applications` and the Sound Applications card.
|
||||
- Independent of: lock, wallpaper, and display state.
|
||||
|
||||
- [ ] **Step 1: Read the spec and application-volume plan completely**
|
||||
|
||||
Run: `sed -n '1,520p' docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md && sed -n '1,420p' docs/superpowers/plans/2026-08-18-phase2-application-volume.md`
|
||||
|
||||
- [ ] **Step 2: Execute every unchecked application-volume task in order**
|
||||
|
||||
Use strict red-green-refactor cycles and the exact commit boundaries in that plan.
|
||||
|
||||
- [ ] **Step 3: Confirm the slice head**
|
||||
|
||||
Run: `tests/quickshell/application-volume-contract.sh && tests/quickshell/sound-page-contract.sh && git status --short`
|
||||
|
||||
Expected: both PASS and no uncommitted files.
|
||||
|
||||
### Task 2: Execute the lock-screen plan
|
||||
|
||||
**Files:** Defined in `2026-08-18-phase2-lock-screen.md`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing schema, wallpaper fallback values, and SettingsBackup settle sequencing.
|
||||
- Produces: `panama-lock`, `LockScreen.qml`, generated config, Appearance card, and health/restore integration.
|
||||
|
||||
- [ ] **Step 1: Read the lock-screen plan completely**
|
||||
|
||||
Run: `sed -n '1,520p' docs/superpowers/plans/2026-08-18-phase2-lock-screen.md`
|
||||
|
||||
- [ ] **Step 2: Execute every unchecked lock-screen task in order**
|
||||
|
||||
Never invoke `panama-lock run` outside its fake-hyprlock fixture.
|
||||
|
||||
- [ ] **Step 3: Confirm the slice head**
|
||||
|
||||
Run: `tests/quickshell/lock-screen-helper-contract.sh && tests/quickshell/lock-screen-service-contract.sh && tests/quickshell/lock-screen-settings-contract.sh && git status --short`
|
||||
|
||||
Expected: all PASS, no live `hyprlock` test process, and no uncommitted files.
|
||||
|
||||
### Task 3: Execute the wallpaper-modes plan
|
||||
|
||||
**Files:** Defined in `2026-08-18-phase2-wallpaper-modes.md`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: schema and connected output names.
|
||||
- Produces: policy calculation, verified hyprpaper orchestration, runtime slideshow, controls, and restore integration.
|
||||
|
||||
- [ ] **Step 1: Read the wallpaper plan completely**
|
||||
|
||||
Run: `sed -n '1,620p' docs/superpowers/plans/2026-08-18-phase2-wallpaper-modes.md`
|
||||
|
||||
- [ ] **Step 2: Execute every unchecked wallpaper task in order**
|
||||
|
||||
All apply tests use fake hyprpaper IPC and temporary image files.
|
||||
|
||||
- [ ] **Step 3: Confirm the slice head**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-policy-contract.sh && tests/quickshell/wallpaper-service-contract.sh && tests/quickshell/wallpaper-settings-contract.sh && git status --short`
|
||||
|
||||
Expected: all PASS and no uncommitted files.
|
||||
|
||||
### Task 4: Execute the display-arrangement plan
|
||||
|
||||
**Files:** Defined in `2026-08-18-phase2-display-arrangement.md`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing Displays apply/revert contract and SettingsBackup display protection.
|
||||
- Produces: pure geometry, extended persistence, whole-layout transactions, arrangement UI, identify overlays, and search/restore integration.
|
||||
|
||||
- [ ] **Step 1: Read the display plan completely**
|
||||
|
||||
Run: `sed -n '1,720p' docs/superpowers/plans/2026-08-18-phase2-display-arrangement.md`
|
||||
|
||||
- [ ] **Step 2: Execute every unchecked display task except the live display test**
|
||||
|
||||
Use fixture monitor JSON for all multi-monitor cases.
|
||||
|
||||
- [ ] **Step 3: Confirm the static slice head**
|
||||
|
||||
Run: `tests/quickshell/display-layout-contract.sh && tests/quickshell/display-transaction-contract.sh && tests/quickshell/display-arrangement-contract.sh && PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh && git status --short`
|
||||
|
||||
Expected: all PASS and no uncommitted files.
|
||||
|
||||
### Task 5: Cross-slice Settings integration audit
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
|
||||
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/README.md`
|
||||
- Modify: relevant existing contracts only when the combined behavior requires it.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `LockScreen.regenerate()`, `Wallpaper.applyCurrentPolicy(false)`, and complete display-layout restore.
|
||||
- Verifies: one deterministic restore sequence and one ownership/search vocabulary.
|
||||
|
||||
- [ ] **Step 1: Expand the combined restore fixture before the final audit**
|
||||
|
||||
The slice plans already extend `tests/quickshell/settings-backup-live-contract.sh` test-first. Confirm its final fixture now contains lock, slideshow, and two-output layout values and asserts this exact order: preferences reload, display apply/verify, idle apply, lock generate, wallpaper apply/verify, keybind/compositor settle, shell reload. It must also assert bounded failure remains in service state and shell reload waits for the settle cap.
|
||||
|
||||
- [ ] **Step 2: Run the combined audit**
|
||||
|
||||
Run: `tests/quickshell/settings-backup-live-contract.sh`
|
||||
|
||||
Expected: PASS. A failure means the independently green hooks conflict and must be debugged before any consolidation.
|
||||
|
||||
- [ ] **Step 3: Consolidate only if the audit exposes duplication**
|
||||
|
||||
Keep `SettingsBackup.qml` as the established restore coordinator and its injected wrapper pattern for Displays, Wallpaper, LockScreen, Keybinds, and SystemSettings. If two slices added equivalent settle state, collapse them under the existing timer without changing observable order. Do not create another restore service.
|
||||
|
||||
- [ ] **Step 4: Reconcile search and ownership once**
|
||||
|
||||
Run the ownership contract against final pages. Manual search entries may exist only for internal JSON controls with no schema row. Remove duplicate terms that the schema already indexes.
|
||||
|
||||
- [ ] **Step 5: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 6: Commit only audit-driven corrections**
|
||||
|
||||
If Step 2 or Step 5 required corrections, stage only those files and commit them as `Integrate Phase 2 desktop settings`. If the audit is already green and no consolidation is needed, leave the branch unchanged; an empty ceremony commit is forbidden.
|
||||
|
||||
### Task 6: Consolidated completion gate
|
||||
|
||||
**Files:**
|
||||
- Modify only if verification finds a defect.
|
||||
|
||||
- [ ] **Step 1: Run new contracts**
|
||||
|
||||
```bash
|
||||
tests/quickshell/application-volume-contract.sh
|
||||
tests/quickshell/lock-screen-helper-contract.sh
|
||||
tests/quickshell/lock-screen-service-contract.sh
|
||||
tests/quickshell/lock-screen-settings-contract.sh
|
||||
tests/quickshell/wallpaper-policy-contract.sh
|
||||
tests/quickshell/wallpaper-service-contract.sh
|
||||
tests/quickshell/wallpaper-settings-contract.sh
|
||||
tests/quickshell/display-layout-contract.sh
|
||||
tests/quickshell/display-transaction-contract.sh
|
||||
tests/quickshell/display-arrangement-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 2: Run affected existing contracts**
|
||||
|
||||
```bash
|
||||
tests/quickshell/sound-page-contract.sh
|
||||
tests/quickshell/settings-pages-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
tests/quickshell/settings-ownership-contract.sh
|
||||
tests/quickshell/settings-preferences-contract.sh
|
||||
tests/quickshell/settings-commit-reset-contract.sh
|
||||
tests/quickshell/settings-backup-live-contract.sh
|
||||
tests/quickshell/migrations-contract.sh
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/schema-hypr-shape-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 3: Run the one state-changing display contract**
|
||||
|
||||
Run: `tests/quickshell/displays-contract.sh`
|
||||
|
||||
Expected: PASS and cleanup proves the physical monitor returns to its captured original mode, scale, transform, x, and y. Stop immediately if the preflight reports a dirty display baseline.
|
||||
|
||||
- [ ] **Step 4: Run config and source checks**
|
||||
|
||||
```bash
|
||||
bash -n config/dot/quickshell/scripts/panama-lock
|
||||
bash -n config/dot/quickshell/scripts/panama-idle
|
||||
git diff --check
|
||||
Hyprland --verify-config
|
||||
```
|
||||
|
||||
Expected: shell syntax exits 0, diff check is empty, and Hyprland reports `config ok`.
|
||||
|
||||
- [ ] **Step 5: Construct changed QML once**
|
||||
|
||||
Run the isolated harness collection once and inspect its complete log for `ERROR`, `WARN`, `ReferenceError`, `TypeError`, binding loops, invalid anchors, failed property assignments, and focus-grab failures. Expected: none.
|
||||
|
||||
- [ ] **Step 6: Review requirement coverage and branch state**
|
||||
|
||||
Compare the final diff with every heading in the approved spec. Run `git status --short --branch` and `git log --oneline origin/main..HEAD`. Expected: only intentional commits and a clean tree.
|
||||
|
||||
### Task 7: Merge, activate, and publish
|
||||
|
||||
**Files:** None unless final activation exposes a defect.
|
||||
|
||||
- [ ] **Step 1: Push the feature branch**
|
||||
|
||||
Run: `git push -u origin feat/phase2-expectation-gaps`
|
||||
|
||||
Expected: remote branch points to the verified head.
|
||||
|
||||
- [ ] **Step 2: Fast-forward clean main**
|
||||
|
||||
In `/home/gib/.local/share/Panama`, fetch, prove `main` is clean and not behind an unexpected remote commit, then run `git merge --ff-only feat/phase2-expectation-gaps`.
|
||||
|
||||
- [ ] **Step 3: Push main**
|
||||
|
||||
Run: `git push origin main`
|
||||
|
||||
Expected: `origin/main` equals local `main`.
|
||||
|
||||
- [ ] **Step 4: Restart Quickshell once**
|
||||
|
||||
Use Panama's verified graceful shell restart action. Do not open repeated harness windows. Confirm `qs list --all` reports one live production instance and inspect only the fresh startup log.
|
||||
|
||||
- [ ] **Step 5: Perform one visual review**
|
||||
|
||||
Open Settings once and inspect Sound Applications, Appearance Lock screen and Wallpaper, and Displays Arrangement. Confirm narrow tiled layout, empty states, focus order, and preview/canvas geometry. Locking, wallpaper rotation, and adding a monitor remain user-driven real-world follow-ups unless a safe no-op state already exercises them.
|
||||
|
||||
- [ ] **Step 6: Report exact delivery evidence**
|
||||
|
||||
Provide commit range, pushed branch/main state, contract counts, the live display restoration result, Quickshell instance state, and any capability that could not be exercised without external hardware or acquiring the live lock screen.
|
||||
@@ -0,0 +1,285 @@
|
||||
# Phase 2 Lock Screen Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make lock-screen appearance configurable from Appearance without editing the tracked hyprlock file or risking an unlocked session.
|
||||
|
||||
**Architecture:** Schema-backed values feed an atomic `panama-lock` generator under `$XDG_STATE_HOME`. Hypridle invokes the helper, a small Quickshell service proactively regenerates on relevant changes, and an ordinary QML preview mirrors the selected roles without launching the real locker.
|
||||
|
||||
**Tech Stack:** Bash, jq, hyprlock 0.9.6 hyprlang, Quickshell 0.3, Qt 6 QML
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Never rewrite `config/dot/hypr/hyprlock.conf` at runtime.
|
||||
- Authentication, PAM, arbitrary commands, markup, fonts, and paths are not configurable.
|
||||
- Generation is atomic; failure preserves the last valid generated config.
|
||||
- `run` falls back to the tracked config if generation fails.
|
||||
- Tests never acquire the live session lock.
|
||||
- Appearance owns visual controls; Power and Privacy retain their established timing controls.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Schema and deterministic generator
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
|
||||
- Create: `config/dot/quickshell/scripts/panama-lock`
|
||||
- Create: `tests/quickshell/lock-screen-helper-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `lockBackgroundMode`, `lockBlurLevel`, `lockShowClock`, `lockShowDate`, `lockShowUser`, `lockFadeOnEmpty`, `use24Hour`, `colorScheme`, and wallpaper policy from `settings.json`.
|
||||
- Produces: `panama-lock generate`, `panama-lock status`, and `panama-lock run`.
|
||||
|
||||
- [ ] **Step 1: Write the failing helper contract**
|
||||
|
||||
Use temporary `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `HOME`, fake `hyprctl`, and fake `hyprlock`. Cover literal dark defaults and every background mode. Parse the generated file and assert:
|
||||
|
||||
```text
|
||||
background.path = screenshot
|
||||
background.blur_passes = 3
|
||||
background.blur_size = 8
|
||||
clock command = date +"%-I:%M"
|
||||
date label present = true
|
||||
user label present = true
|
||||
input-field.fade_on_empty = false
|
||||
```
|
||||
|
||||
For light solid mode assert `rgba(245, 246, 250, 1.0)`. For wallpaper mode with two fake outputs assert one background block per output and the per-monitor path fallback. Prove invalid enum, blur, boolean, JSON, and path values return to shipped defaults.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/lock-screen-helper-contract.sh`
|
||||
|
||||
Expected: FAIL because `panama-lock` does not exist.
|
||||
|
||||
- [ ] **Step 3: Add the six schema entries**
|
||||
|
||||
Use exact types and defaults:
|
||||
|
||||
```qml
|
||||
{
|
||||
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" }
|
||||
```
|
||||
|
||||
Insert these entries using the repository's existing schema object shape; do not add a new schema feature.
|
||||
|
||||
- [ ] **Step 4: Implement `panama-lock`**
|
||||
|
||||
Commands and results:
|
||||
|
||||
```text
|
||||
panama-lock generate -> atomically writes state/panama/hyprlock.conf
|
||||
panama-lock status -> {"generated":true,"path":"...","fallback":false,"error":""}
|
||||
panama-lock run -> exec hyprlock -c generated; fallback to config/hypr/hyprlock.conf
|
||||
```
|
||||
|
||||
Read values with `jq`, validate again in Bash, map blur levels exactly as:
|
||||
|
||||
```text
|
||||
0 -> passes 0, size 1
|
||||
1 -> passes 1, size 3
|
||||
2 -> passes 2, size 5
|
||||
3 -> passes 3, size 8
|
||||
4 -> passes 4, size 10
|
||||
5 -> passes 5, size 12
|
||||
```
|
||||
|
||||
Write `generated.tmp`, validate that it is non-empty and contains `auth`, `background`, and `input-field` blocks, then `mv` it into place. On failure remove only the temporary file.
|
||||
|
||||
- [ ] **Step 5: Extend the contract for atomicity and fallback**
|
||||
|
||||
Seed a valid generated file, force generation failure through an unwritable fixture target, and prove its checksum is unchanged. Make fake `hyprlock` record argv and prove `run` uses the generated path after success and the tracked fallback after forced failure.
|
||||
|
||||
- [ ] **Step 6: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/lock-screen-helper-contract.sh`
|
||||
|
||||
Expected: PASS for modes, visibility, clock format, validation, atomicity, and fallback.
|
||||
|
||||
- [ ] **Step 7: Commit the generator**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/config/PreferenceSchema.qml config/dot/quickshell/scripts/panama-lock tests/quickshell/lock-screen-helper-contract.sh
|
||||
git commit -m "Generate managed lock screen configuration"
|
||||
```
|
||||
|
||||
### Task 2: Make every lock path use the generator
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/hypr/hypridle.conf`
|
||||
- Modify: `config/dot/quickshell/scripts/panama-idle`
|
||||
- Create: `config/dot/quickshell/services/LockScreen.qml`
|
||||
- Create: `config/dot/quickshell/lock-screen-harness.qml`
|
||||
- Create: `tests/quickshell/lock-screen-service-contract.sh`
|
||||
- Modify: `tests/quickshell/lock-screen-helper-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `panama-lock generate/status` and `DesktopPreferences.revision`.
|
||||
- Produces: `LockScreen.generated`, `LockScreen.path`, `LockScreen.lastError`, `LockScreen.busy`, `LockScreen.regenerate()`, and `LockScreen.refresh()`.
|
||||
|
||||
- [ ] **Step 1: Write the failing service and idle-path contract**
|
||||
|
||||
Assert both hypridle sources emit exactly:
|
||||
|
||||
```text
|
||||
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
|
||||
```
|
||||
|
||||
Through the QML harness, change one lock preference three times inside 250 ms and prove exactly one helper generation starts. Return malformed status JSON and prove the previous valid path remains while `lastError` becomes **The lock-screen configuration could not be read.**
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/lock-screen-service-contract.sh`
|
||||
|
||||
Expected: FAIL because the idle paths still invoke `hyprlock` directly and the service is absent.
|
||||
|
||||
- [ ] **Step 3: Update shipped and generated hypridle commands**
|
||||
|
||||
Change only `lock_cmd`; preserve blanking, sleep, and lock timing behavior. Keep both shipped/generated idle-command assertions in `tests/quickshell/lock-screen-service-contract.sh`; this repository has no separate idle-lock contract.
|
||||
|
||||
- [ ] **Step 4: Implement `LockScreen.qml`**
|
||||
|
||||
Use one `Process` for generation and one for status. Coalesce `DesktopPreferences.revision` through a 250 ms single-shot Timer. On generation exit, call status; accept only JSON with boolean `generated` and string `path`. Keep the last valid status on malformed output.
|
||||
|
||||
- [ ] **Step 5: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/lock-screen-helper-contract.sh && tests/quickshell/lock-screen-service-contract.sh`
|
||||
|
||||
Expected: PASS with one coalesced generation and no live locker process.
|
||||
|
||||
- [ ] **Step 6: Commit lock invocation and service state**
|
||||
|
||||
```bash
|
||||
git add config/dot/hypr/hypridle.conf config/dot/quickshell/scripts/panama-idle config/dot/quickshell/services/LockScreen.qml config/dot/quickshell/lock-screen-harness.qml tests/quickshell/lock-screen-helper-contract.sh tests/quickshell/lock-screen-service-contract.sh
|
||||
git commit -m "Route session locking through Panama"
|
||||
```
|
||||
|
||||
### Task 3: Appearance card and representative preview
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/settings/LockScreenPreview.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml`
|
||||
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
||||
- Create: `tests/quickshell/lock-screen-settings-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-search-contract.sh`
|
||||
- Modify: `config/dot/quickshell/modules/settings/README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: schema values, `Wallpaper` effective preview path, `Theme`, and `LockScreen.lastError`.
|
||||
- Produces: Appearance **Lock screen** card and search routes for background, blur, clock, date, user name, and password-field behavior.
|
||||
|
||||
- [ ] **Step 1: Write the failing UI and routing contract**
|
||||
|
||||
Construct the real Appearance page in an isolated QML harness for screenshot, wallpaper, and solid modes. Assert the preview changes background source and visibility without starting `hyprlock`. Assert the six settings route to `appearance`, and no new duplicated setting row appears on Power or Privacy.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/lock-screen-settings-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
|
||||
|
||||
Expected: FAIL because the preview, card, and group route are absent.
|
||||
|
||||
- [ ] **Step 3: Implement the preview**
|
||||
|
||||
Use a clipped `Rectangle` with one asynchronous, decode-bounded `Image` only in wallpaper mode. Screenshot mode uses a static themed approximation with the configured blur level; solid uses `Theme.bg`. Render clock/date/user/password-field elements from the same preferences. Do not use ShaderEffect, live screencopy, pulse, shimmer, or a repeating animation.
|
||||
|
||||
- [ ] **Step 4: Add the Appearance controls**
|
||||
|
||||
Place the card after Wallpaper and before Typography. Use `ChoiceRow` for background, `SliderRow` with `zeroLabel: "Off"` for blur, and `ToggleRow` for the four booleans. Show `LockScreen.lastError` in the card only when non-empty.
|
||||
|
||||
- [ ] **Step 5: Add search and ownership documentation**
|
||||
|
||||
Map `lockAppearance` to `appearance`; add manual terms **lock screen background** and **password field** only if the schema labels do not already find them. Document that lock visuals belong to Appearance while lock timing belongs to Power and the existing Privacy mirror.
|
||||
|
||||
- [ ] **Step 6: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/lock-screen-settings-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
|
||||
|
||||
Expected: PASS with zero QML warnings and no additional mirrors.
|
||||
|
||||
- [ ] **Step 7: Commit the lock-screen Settings experience**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/settings/LockScreenPreview.qml config/dot/quickshell/modules/settings/AppearancePage.qml config/dot/quickshell/services/SettingsSearch.qml config/dot/quickshell/modules/settings/README.md tests/quickshell/lock-screen-settings-contract.sh tests/quickshell/settings-search-contract.sh
|
||||
git commit -m "Add lock screen appearance settings"
|
||||
```
|
||||
|
||||
### Task 4: Backup, reset, and diagnostics integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
|
||||
- Modify: `config/dot/quickshell/scripts/panama-doctor`
|
||||
- Modify: `tests/quickshell/settings-backup-live-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-commit-reset-contract.sh`
|
||||
- Modify: `tests/quickshell/panama-doctor-contract.sh`
|
||||
- Modify: `tests/quickshell/lock-screen-service-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `LockScreen.regenerate()` and `panama-lock status`.
|
||||
- Produces: lock regeneration during restore and a redacted `desktop.hyprlock` health check.
|
||||
|
||||
- [ ] **Step 1: Write failing restore and health assertions**
|
||||
|
||||
Restore a fixture snapshot with non-default lock values and assert regeneration happens after preferences reload and before shell reload. Reset and assert screenshot background, blur level 3, clock/date/user visible, and the empty password field visible, followed by one regeneration. For doctor fixtures, assert `desktop.hyprlock` is `ok` for a valid generated file, `warning` when fallback is in use, and `error` only when neither generated nor tracked config can be used. Assert no wallpaper path appears in copied diagnostics.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/panama-doctor-contract.sh`
|
||||
|
||||
Expected: FAIL because restore and Health do not know the lock generator.
|
||||
|
||||
- [ ] **Step 3: Add restore ordering and health probe**
|
||||
|
||||
Inject `regenerateLock` into `SettingsBackup.qml`, start it after preference reload and idle regeneration, and include its bounded busy state in the existing settle timer. Add one authored doctor check whose parsed status includes no config contents or paths.
|
||||
|
||||
- [ ] **Step 4: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/panama-doctor-contract.sh && tests/quickshell/lock-screen-service-contract.sh`
|
||||
|
||||
Expected: PASS with restore ordering and redacted status.
|
||||
|
||||
- [ ] **Step 5: Commit integration**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/SettingsBackup.qml config/dot/quickshell/scripts/panama-doctor tests/quickshell/settings-backup-live-contract.sh tests/quickshell/settings-commit-reset-contract.sh tests/quickshell/panama-doctor-contract.sh tests/quickshell/lock-screen-service-contract.sh
|
||||
git commit -m "Integrate managed lock screen recovery"
|
||||
```
|
||||
|
||||
### Task 5: Slice verification
|
||||
|
||||
- [ ] **Step 1: Run all lock contracts once**
|
||||
|
||||
```bash
|
||||
tests/quickshell/lock-screen-helper-contract.sh
|
||||
tests/quickshell/lock-screen-service-contract.sh
|
||||
tests/quickshell/lock-screen-settings-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
tests/quickshell/settings-ownership-contract.sh
|
||||
tests/quickshell/settings-backup-live-contract.sh
|
||||
tests/quickshell/settings-commit-reset-contract.sh
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS and no process named `hyprlock` is started by the tests.
|
||||
|
||||
- [ ] **Step 2: Validate formatting and process safety**
|
||||
|
||||
Run: `bash -n config/dot/quickshell/scripts/panama-lock && bash -n config/dot/quickshell/scripts/panama-idle && git diff --check`
|
||||
|
||||
Expected: exit 0.
|
||||
@@ -0,0 +1,327 @@
|
||||
# Phase 2 Wallpaper Modes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extend Panama's thumbnail-first wallpaper picker with verified single, slideshow, and per-monitor policies.
|
||||
|
||||
**Architecture:** A pure policy module validates collections, calculates output maps, and owns deterministic shuffle bags. `Wallpaper.qml` remains the sole hyprpaper process boundary and persists manual changes only after `listactive` verifies every output.
|
||||
|
||||
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, hyprpaper 0.8.4 IPC, Bash fixture contracts
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve `wallpaperPath` as the single-image and migration fallback.
|
||||
- Reject non-absolute paths, commas, newlines, and UI selections outside scanned candidates.
|
||||
- Automatic slideshow changes never rewrite durable policy or spin on failure.
|
||||
- No filesystem watcher, animated wallpaper, per-workspace mode, or continuous repaint.
|
||||
- A manual policy mutation persists only after full output-map readback succeeds.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Schema and pure wallpaper policy
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
|
||||
- Create: `config/dot/quickshell/services/WallpaperPolicy.js`
|
||||
- Create: `config/dot/quickshell/wallpaper-policy-harness.qml`
|
||||
- Create: `tests/quickshell/wallpaper-policy-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: mode, global path, collection, per-monitor map, connected outputs, valid candidates, and shuffle bag.
|
||||
- Produces: `validPath`, `validCollection`, `validAssignments`, `effectiveMap`, `orderedNext`, `shuffledBag`, and `shuffledNext`.
|
||||
|
||||
- [ ] **Step 1: Write the failing policy contract**
|
||||
|
||||
Use literal paths `/images/a.jpg`, `/images/b.jpg`, and `/images/c.jpg` with connected outputs `DP-2` and `HDMI-A-1`. Assert:
|
||||
|
||||
```json
|
||||
single -> {"DP-2":"/images/a.jpg","HDMI-A-1":"/images/a.jpg"}
|
||||
per-monitor -> {"DP-2":"/images/b.jpg","HDMI-A-1":"/images/a.jpg"}
|
||||
slideshow item c -> {"DP-2":"/images/c.jpg","HDMI-A-1":"/images/c.jpg"}
|
||||
```
|
||||
|
||||
Assert missing assignments fall back to the global path, invalid collection entries are skipped, ordered rotation wraps `a -> b -> c -> a`, and a seeded shuffle emits all three literal paths exactly once before any repeat.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-policy-contract.sh`
|
||||
|
||||
Expected: FAIL because the policy module and harness are missing.
|
||||
|
||||
- [ ] **Step 3: Add schema entries**
|
||||
|
||||
Add exact keys and defaults:
|
||||
|
||||
```qml
|
||||
{
|
||||
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"
|
||||
}
|
||||
```
|
||||
|
||||
Use `group: "wallpaper"` for every key. Keep the existing `wallpaperPath` pattern unchanged.
|
||||
|
||||
- [ ] **Step 4: Implement the pure policy API**
|
||||
|
||||
`validPath` accepts only strings matching `^/[^,\n]+$` that occur in the supplied candidate set. `validCollection` de-duplicates while preserving first appearance. `validAssignments` keeps only connector keys matching `^[A-Za-z0-9_.-]+$` and valid paths. `effectiveMap` returns a plain output-keyed object. Shuffle accepts an injected `random()` function so tests use the literal sequence `0.8, 0.1, 0.6` without mocking global state.
|
||||
|
||||
- [ ] **Step 5: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-policy-contract.sh`
|
||||
|
||||
Expected: PASS for validation, fallback maps, ordered wrap, and shuffle-without-repeat.
|
||||
|
||||
- [ ] **Step 6: Commit policy model**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/config/PreferenceSchema.qml config/dot/quickshell/services/WallpaperPolicy.js config/dot/quickshell/wallpaper-policy-harness.qml tests/quickshell/wallpaper-policy-contract.sh
|
||||
git commit -m "Model wallpaper display policies"
|
||||
```
|
||||
|
||||
### Task 2: Verified multi-output hyprpaper transaction
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/Wallpaper.qml`
|
||||
- Create: `config/dot/quickshell/wallpaper-service-harness.qml`
|
||||
- Create: `tests/quickshell/wallpaper-service-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-pages-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `WallpaperPolicy.effectiveMap(...)` and hyprpaper `listactive` output.
|
||||
- Produces: `activeByOutput`, `applyPolicy(candidatePolicy, persist)`, `applyCurrentPolicy(persist)`, `setSingle(path)`, `setAssignment(output, path)`, `toggleSlideshowPath(path)`, and `setMode(mode)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing IPC transaction contract**
|
||||
|
||||
Put fake `hyprctl` first on PATH. Make it record each argv and return fixture `listactive` maps. Assert a two-output request calls exactly:
|
||||
|
||||
```text
|
||||
hyprctl hyprpaper wallpaper DP-2,/images/a.jpg
|
||||
hyprctl hyprpaper wallpaper HDMI-A-1,/images/b.jpg
|
||||
hyprctl hyprpaper listactive
|
||||
```
|
||||
|
||||
Assert preferences are written only after exact readback, wrong readback leaves the old policy untouched, and a second output failure stops the transaction and reports **Hyprpaper did not apply that background.**
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-service-contract.sh`
|
||||
|
||||
Expected: FAIL because `Wallpaper.qml` only tracks one active path and persists after exit status.
|
||||
|
||||
- [ ] **Step 3: Parse complete active state**
|
||||
|
||||
Replace `active` as the primary state with `property var activeByOutput: ({})`; keep a compatibility `active` property bound to the first current screen. Parse every `OUTPUT: /absolute/path` line. Reject malformed lines instead of accepting partial state as verification.
|
||||
|
||||
- [ ] **Step 4: Implement the transaction queue**
|
||||
|
||||
The operation record contains:
|
||||
|
||||
```qml
|
||||
{
|
||||
expected: { "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" },
|
||||
remaining: ["DP-2", "HDMI-A-1"],
|
||||
candidatePolicy: { mode: "per-monitor", assignments: { "HDMI-A-1": "/images/b.jpg" } },
|
||||
persist: true,
|
||||
automatic: false
|
||||
}
|
||||
```
|
||||
|
||||
Apply outputs sequentially, then call `listactive`. Compare every expected key and path. Only then write the candidate schema values through `DesktopPreferences.set`. Keep the last verified map visible during work.
|
||||
|
||||
- [ ] **Step 5: Preserve the legacy API safely**
|
||||
|
||||
Keep `set(path)` as `return root.setSingle(path)` so SettingsBackup and existing IPC remain compatible until their dedicated integration task changes them. Keep title formatting and scan behavior unchanged.
|
||||
|
||||
- [ ] **Step 6: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-service-contract.sh && tests/quickshell/settings-pages-contract.sh`
|
||||
|
||||
Expected: PASS for exact argv, readback gating, old API compatibility, and failure copy.
|
||||
|
||||
- [ ] **Step 7: Commit verified application**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/Wallpaper.qml config/dot/quickshell/wallpaper-service-harness.qml tests/quickshell/wallpaper-service-contract.sh tests/quickshell/settings-pages-contract.sh
|
||||
git commit -m "Verify wallpaper policy application"
|
||||
```
|
||||
|
||||
### Task 3: Event-driven slideshow and hotplug settle
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/Wallpaper.qml`
|
||||
- Modify: `config/dot/quickshell/services/WallpaperPolicy.js`
|
||||
- Modify: `config/dot/quickshell/wallpaper-service-harness.qml`
|
||||
- Modify: `tests/quickshell/wallpaper-policy-contract.sh`
|
||||
- Modify: `tests/quickshell/wallpaper-service-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: validated slideshow collection, interval, shuffle flag, and `Quickshell.screens`.
|
||||
- Produces: `advanceSlideshow()`, runtime `slideshowIndex`, `shuffleBag`, and one interval Timer active only for a collection of at least two valid images.
|
||||
|
||||
- [ ] **Step 1: Write failing timer and failure tests**
|
||||
|
||||
Use a harness-adjustable interval measured in milliseconds while production derives `minutes * 60000`. Assert: empty and one-item collections do not repeat; two items advance once per trigger; automatic application uses `persist: false`; failure keeps the same policy and schedules only the next normal interval; three rapid screen-set changes coalesce into one reapply.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-policy-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
|
||||
|
||||
Expected: FAIL because no slideshow state or screen-set coalescing exists.
|
||||
|
||||
- [ ] **Step 3: Implement runtime rotation**
|
||||
|
||||
Add one repeating Timer whose `running` expression requires slideshow mode, at least two valid paths, and no active transaction. `advanceSlideshow()` chooses ordered or shuffled next state through `WallpaperPolicy`, updates runtime state only after verified apply, and never sets a preference.
|
||||
|
||||
- [ ] **Step 4: Implement connected-screen coalescing**
|
||||
|
||||
Bind a sorted screen-name signature and restart a 350 ms single-shot Timer when it changes. Reapply the current policy with `persist: false`. If a transaction is active, set one boolean follow-up flag rather than creating another queue.
|
||||
|
||||
- [ ] **Step 5: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-policy-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
|
||||
|
||||
Expected: PASS without rapid retry or durable writes during rotation.
|
||||
|
||||
- [ ] **Step 6: Commit runtime policy**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/Wallpaper.qml config/dot/quickshell/services/WallpaperPolicy.js config/dot/quickshell/wallpaper-service-harness.qml tests/quickshell/wallpaper-policy-contract.sh tests/quickshell/wallpaper-service-contract.sh
|
||||
git commit -m "Add event-driven wallpaper rotation"
|
||||
```
|
||||
|
||||
### Task 4: Continuity wallpaper controls
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/settings/WallpaperControls.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/WallpaperPicker.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml`
|
||||
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
||||
- Create: `tests/quickshell/wallpaper-settings-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-search-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Wallpaper modes, connected displays, collection membership, assignments, and transaction state.
|
||||
- Produces: compact mode controls above the existing grid and mode-aware tile actions/badges.
|
||||
|
||||
- [ ] **Step 1: Write the failing UI contract**
|
||||
|
||||
Render Appearance in all three modes. Assert Single tile activation calls `setSingle`; Slideshow activation toggles membership and exposes interval/shuffle; Per monitor exposes a display selector and calls `setAssignment` for the selected output. Assert the active image Prism and collection-member check are separate states.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-settings-contract.sh && tests/quickshell/settings-search-contract.sh`
|
||||
|
||||
Expected: FAIL because only single-image tile activation exists.
|
||||
|
||||
- [ ] **Step 3: Implement `WallpaperControls.qml`**
|
||||
|
||||
Use `ChoiceGrid` for mode, `ChoiceGrid` for output only in per-monitor mode, `SliderRow`-equivalent layout for interval only in slideshow mode, and `SettingsToggle` for shuffle. Controls call Wallpaper transactional methods rather than writing preferences directly.
|
||||
|
||||
- [ ] **Step 4: Make the picker mode-aware**
|
||||
|
||||
Add `selectedOutput`, `mode`, `selected(path)`, and `activate(path)` properties/functions. Keep thumbnail decode bounds and event-driven fade. In slideshow mode, draw a quiet check in the upper-right for membership; preserve the Prism border exclusively for the image actually active on the selected/current output.
|
||||
|
||||
- [ ] **Step 5: Integrate and route search**
|
||||
|
||||
Place `WallpaperControls` in the existing wallpaper card above `WallpaperPicker`. Add search terms for slideshow, shuffle interval, and per-monitor assignment without creating a new Settings page.
|
||||
|
||||
- [ ] **Step 6: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/wallpaper-settings-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
|
||||
|
||||
Expected: PASS with no new mirrors or QML warnings.
|
||||
|
||||
- [ ] **Step 7: Commit the wallpaper UI**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/settings/WallpaperControls.qml config/dot/quickshell/modules/settings/WallpaperPicker.qml config/dot/quickshell/modules/settings/AppearancePage.qml config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/wallpaper-settings-contract.sh tests/quickshell/settings-search-contract.sh
|
||||
git commit -m "Add wallpaper mode controls"
|
||||
```
|
||||
|
||||
### Task 5: Restore and reset integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
|
||||
- Modify: `tests/quickshell/settings-backup-live-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-commit-reset-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Wallpaper.applyCurrentPolicy(false)`.
|
||||
- Produces: policy-aware restore and shipped single-wallpaper reset.
|
||||
|
||||
- [ ] **Step 1: Write failing restore assertions**
|
||||
|
||||
Restore slideshow and per-monitor fixture snapshots. Assert the restored policy applies after preferences reload, uses `persist: false`, and shell reload waits for the bounded wallpaper transaction. Reset must yield mode `single`, empty collection/assignments, interval 30, shuffle true, and shipped `wallpaperPath` fallback.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh`
|
||||
|
||||
Expected: FAIL because restore calls `Wallpaper.set(path)` only.
|
||||
|
||||
- [ ] **Step 3: Replace path-only restore**
|
||||
|
||||
Inject `applyWallpaperPolicy: function() { return Wallpaper.applyCurrentPolicy(false); }`, include `Wallpaper.busy` in the existing bounded settle condition, and remove the path parameter from the restore callback. Do not create a second wallpaper snapshot format; all policy keys are already in Settings JSON.
|
||||
|
||||
- [ ] **Step 4: Run and verify GREEN**
|
||||
|
||||
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
|
||||
|
||||
Expected: PASS for both policy modes and shipped reset.
|
||||
|
||||
- [ ] **Step 5: Commit restore integration**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/SettingsBackup.qml tests/quickshell/settings-backup-live-contract.sh tests/quickshell/settings-commit-reset-contract.sh
|
||||
git commit -m "Restore complete wallpaper policies"
|
||||
```
|
||||
|
||||
### Task 6: Slice verification
|
||||
|
||||
- [ ] **Step 1: Run all wallpaper contracts once**
|
||||
|
||||
```bash
|
||||
tests/quickshell/wallpaper-policy-contract.sh
|
||||
tests/quickshell/wallpaper-service-contract.sh
|
||||
tests/quickshell/wallpaper-settings-contract.sh
|
||||
tests/quickshell/settings-backup-live-contract.sh
|
||||
tests/quickshell/settings-commit-reset-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS. The live desktop wallpaper is never changed by fixture tests.
|
||||
|
||||
- [ ] **Step 2: Perform one read-only live comparison**
|
||||
|
||||
Run: `hyprctl hyprpaper listactive`
|
||||
|
||||
Expected: every connected output reports an absolute path; compare it with `Wallpaper.activeByOutput` through the existing IPC/harness without applying an image.
|
||||
|
||||
- [ ] **Step 3: Review formatting and shell state**
|
||||
|
||||
Run: `git diff --check && git status --short`
|
||||
|
||||
Expected: clean after the Task 5 commit.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user