Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f4bdb547 | ||
|
|
1a93a788fa | ||
|
|
1debd37d01 | ||
|
|
4341628b2f | ||
|
|
7777f5c1aa | ||
|
|
bc7bf3185c | ||
|
|
f3d91c14a1 | ||
|
|
21beb066e1 | ||
|
|
d2898d8806 | ||
|
|
0c3935f818 | ||
|
|
70a9d27c98 | ||
|
|
5cf2943ccf | ||
|
|
d0d9e196b0 | ||
|
|
2613768efd | ||
|
|
101797f8f0 | ||
|
|
b9336d5930 | ||
|
|
c36e423890 | ||
|
|
1c12892252 | ||
|
|
52818b7290 | ||
|
|
c8004e400a | ||
|
|
3e98cc2216 | ||
|
|
a80a6f4dda | ||
|
|
3520700983 | ||
|
|
a845534110 | ||
|
|
db34b1e6ed | ||
|
|
0f3bddc452 | ||
|
|
9aea519e54 | ||
|
|
e5a2a430e0 | ||
|
|
bfa9c58b09 | ||
|
|
1360a80f07 | ||
|
|
8be3fc2fdd | ||
|
|
2d69ce7648 | ||
|
|
d87f4b6d6a | ||
|
|
9fc1fdbbbb | ||
|
|
dd8d93c387 | ||
|
|
b8832a0174 | ||
|
|
3f07d25858 | ||
|
|
df1dcdfad5 | ||
|
|
91f7273f41 | ||
|
|
f9eba1e8c5 | ||
|
|
b1edfb6fe4 | ||
|
|
1afa41526a | ||
|
|
ccf46c40ef |
@@ -21,3 +21,4 @@ __pycache__/
|
|||||||
/config/dot/gtk-3.0/settings.ini
|
/config/dot/gtk-3.0/settings.ini
|
||||||
/config/dot/gtk-4.0/settings.ini
|
/config/dot/gtk-4.0/settings.ini
|
||||||
/config/dot/tmux/current-theme.conf
|
/config/dot/tmux/current-theme.conf
|
||||||
|
/config/dot/hypr/hyprlock.conf
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
-- instead. They still work here; see the session notes in autostart.lua.
|
-- instead. They still work here; see the session notes in autostart.lua.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
-- ── GPU selection ───────────────────────────────────────────────────────────
|
-- ── GPU selection ───────────────────────────────────────────────────────────
|
||||||
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
|
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
|
||||||
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
|
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
|
||||||
@@ -47,7 +49,7 @@ end
|
|||||||
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
|
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
|
||||||
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
|
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
|
||||||
-- XCursor path, which is what we want.
|
-- XCursor path, which is what we want.
|
||||||
hl.env("XCURSOR_THEME", "oreo_blue_cursors")
|
hl.env("XCURSOR_THEME", prefs.get("cursorTheme", "oreo_blue_cursors"))
|
||||||
hl.env("XCURSOR_SIZE", "24")
|
hl.env("XCURSOR_SIZE", "24")
|
||||||
|
|
||||||
-- ── Toolkits ────────────────────────────────────────────────────────────────
|
-- ── Toolkits ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
general {
|
general {
|
||||||
# `pidof` guard prevents stacking lockers if this fires twice.
|
# `pidof` guard prevents stacking lockers if this fires twice.
|
||||||
lock_cmd = pidof hyprlock || hyprlock
|
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
|
||||||
before_sleep_cmd = loginctl lock-session
|
before_sleep_cmd = loginctl lock-session
|
||||||
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
|
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# hyprlock — lock screen
|
# hyprlock — lock screen.
|
||||||
#
|
#
|
||||||
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
|
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||||
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
|
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||||
|
# next colour scheme change.
|
||||||
|
#
|
||||||
|
# The colours here follow the desktop's light/dark setting. They used to be
|
||||||
|
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
|
||||||
|
# stayed dark when everything else went light.
|
||||||
|
#
|
||||||
|
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
|
||||||
|
# template carries "R, G, B" triples where the rest of Panama uses hex. The two
|
||||||
|
# Pango markup values are the exception and want ##rrggbb.
|
||||||
#
|
#
|
||||||
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
|
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -45,7 +54,7 @@ background {
|
|||||||
vibrancy_darkness = 0.05
|
vibrancy_darkness = 0.05
|
||||||
|
|
||||||
# Shown if the screenshot is unavailable.
|
# Shown if the screenshot is unavailable.
|
||||||
color = rgba(34, 36, 54, 1.0)
|
color = rgba(@BG@, 1.0)
|
||||||
|
|
||||||
zindex = -1
|
zindex = -1
|
||||||
}
|
}
|
||||||
@@ -54,7 +63,7 @@ background {
|
|||||||
label {
|
label {
|
||||||
monitor =
|
monitor =
|
||||||
text = cmd[update:1000] date +"%-I:%M"
|
text = cmd[update:1000] date +"%-I:%M"
|
||||||
color = rgba(200, 211, 245, 1.0)
|
color = rgba(@FG@, 1.0)
|
||||||
font_size = 120
|
font_size = 120
|
||||||
font_family = Adwaita Sans Light
|
font_family = Adwaita Sans Light
|
||||||
position = 0, 260
|
position = 0, 260
|
||||||
@@ -65,7 +74,7 @@ label {
|
|||||||
label {
|
label {
|
||||||
monitor =
|
monitor =
|
||||||
text = cmd[update:60000] date +"%A, %B %-d"
|
text = cmd[update:60000] date +"%A, %B %-d"
|
||||||
color = rgba(130, 139, 184, 1.0)
|
color = rgba(@MUTED@, 1.0)
|
||||||
font_size = 24
|
font_size = 24
|
||||||
font_family = Adwaita Sans
|
font_family = Adwaita Sans
|
||||||
position = 0, 160
|
position = 0, 160
|
||||||
@@ -84,18 +93,18 @@ input-field {
|
|||||||
outline_thickness = 2
|
outline_thickness = 2
|
||||||
rounding = 26
|
rounding = 26
|
||||||
|
|
||||||
outer_color = rgba(130, 170, 255, 0.9)
|
outer_color = rgba(@ACCENT@, 0.9)
|
||||||
inner_color = rgba(46, 47, 61, 0.85)
|
inner_color = rgba(@FIELD@, 0.85)
|
||||||
font_color = rgba(200, 211, 245, 1.0)
|
font_color = rgba(@FG@, 1.0)
|
||||||
check_color = rgba(130, 170, 255, 1.0)
|
check_color = rgba(@ACCENT@, 1.0)
|
||||||
fail_color = rgba(255, 117, 127, 1.0)
|
fail_color = rgba(@ERROR@, 1.0)
|
||||||
|
|
||||||
dots_size = 0.25
|
dots_size = 0.25
|
||||||
dots_spacing = 0.3
|
dots_spacing = 0.3
|
||||||
dots_center = true
|
dots_center = true
|
||||||
|
|
||||||
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
|
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
|
||||||
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
|
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||||
|
|
||||||
fade_on_empty = false
|
fade_on_empty = false
|
||||||
hide_input = false
|
hide_input = false
|
||||||
@@ -105,7 +114,7 @@ input-field {
|
|||||||
label {
|
label {
|
||||||
monitor =
|
monitor =
|
||||||
text = $USER
|
text = $USER
|
||||||
color = rgba(200, 211, 245, 0.9)
|
color = rgba(@FG@, 0.9)
|
||||||
font_size = 16
|
font_size = 16
|
||||||
font_family = Adwaita Sans
|
font_family = Adwaita Sans
|
||||||
position = 0, -110
|
position = 0, -110
|
||||||
@@ -10,9 +10,9 @@ local prefs = require("prefs")
|
|||||||
hl.config({
|
hl.config({
|
||||||
input = {
|
input = {
|
||||||
kb_layout = prefs.get("keyboardLayout", "us"),
|
kb_layout = prefs.get("keyboardLayout", "us"),
|
||||||
kb_variant = "",
|
kb_variant = prefs.get("keyboardVariant", ""),
|
||||||
kb_model = "",
|
kb_model = "",
|
||||||
kb_options = "",
|
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
|
||||||
kb_rules = "",
|
kb_rules = "",
|
||||||
|
|
||||||
numlock_by_default = prefs.get("numlockByDefault", true),
|
numlock_by_default = prefs.get("numlockByDefault", true),
|
||||||
|
|||||||
@@ -195,9 +195,21 @@ bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = t
|
|||||||
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||||
|
|
||||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
|
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||||
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
-- showing what you are choosing between.
|
||||||
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
--
|
||||||
|
-- The gesture needs three binds, not two. Tab steps the selection, and the
|
||||||
|
-- switch is only COMMITTED when the modifier is released -- which is the sole
|
||||||
|
-- way the compositor can tell the gesture is finished. That release bind is on
|
||||||
|
-- the bare modifier, so it fires on EVERY Super release in the session; the
|
||||||
|
-- handler returns immediately when no switch is open, which is why this is
|
||||||
|
-- affordable.
|
||||||
|
--
|
||||||
|
-- The release bind carries no description on purpose: it is not a shortcut
|
||||||
|
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
|
||||||
|
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
|
||||||
|
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
|
||||||
|
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
|
||||||
-- Jump back to the previously focused window.
|
-- Jump back to the previously focused window.
|
||||||
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
||||||
|
|
||||||
|
|||||||
+49
-20
@@ -22,21 +22,22 @@ hl.config({
|
|||||||
border_size = prefs.get("borderSize", 2),
|
border_size = prefs.get("borderSize", 2),
|
||||||
|
|
||||||
col = {
|
col = {
|
||||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
-- The focused accent role: blue leads, orchid follows, on a
|
||||||
-- is visible on both a tall and a wide window. Same two colours as
|
-- diagonal so the pair is visible on both a tall and a wide
|
||||||
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
|
-- window. ColorScheme never writes this role; a future accent
|
||||||
-- tmux theme this palette came from.
|
-- picker can own it without fighting light/dark mode.
|
||||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||||
-- Unfocused windows get no colour at all. The gradient only means
|
-- The neutral inactive role follows the colour scheme because a
|
||||||
-- something if exactly one window on screen is wearing it.
|
-- dark neutral disappears against a light desktop.
|
||||||
-- Follows the colour scheme: a dark neutral is invisible against a
|
-- services/ColorScheme.qml applies the same values live; this is
|
||||||
-- light desktop. services/ColorScheme.qml applies changes live;
|
-- the value a fresh session starts from.
|
||||||
-- this is the value a fresh session starts from.
|
|
||||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||||
},
|
},
|
||||||
|
|
||||||
resize_on_border = true,
|
resize_on_border = prefs.get("resizeOnBorder", true),
|
||||||
|
extend_border_grab_area = prefs.getInt("borderGrabArea", 15),
|
||||||
|
hover_icon_on_border = prefs.get("hoverIconOnBorder", true),
|
||||||
|
|
||||||
-- Enables the per-window "immediate" rule used for games in rules.lua.
|
-- Enables the per-window "immediate" rule used for games in rules.lua.
|
||||||
-- Harmless on its own; tearing only happens where a rule opts in.
|
-- Harmless on its own; tearing only happens where a rule opts in.
|
||||||
@@ -44,16 +45,22 @@ hl.config({
|
|||||||
|
|
||||||
layout = "dwindle",
|
layout = "dwindle",
|
||||||
|
|
||||||
snap = { enabled = true },
|
snap = {
|
||||||
|
enabled = true,
|
||||||
|
window_gap = prefs.getInt("snapWindowGap", 10),
|
||||||
|
monitor_gap = prefs.getInt("snapMonitorGap", 10),
|
||||||
|
respect_gaps = prefs.get("snapRespectGaps", false),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
decoration = {
|
decoration = {
|
||||||
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
||||||
-- next to each other read as the same object family.
|
-- next to each other read as the same object family.
|
||||||
rounding = prefs.get("windowRounding", 18),
|
rounding = prefs.get("windowRounding", 18),
|
||||||
rounding_power = 2,
|
rounding_power = prefs.get("roundingPower", 2),
|
||||||
|
|
||||||
active_opacity = 1.0,
|
active_opacity = prefs.get("activeOpacity", 1.0),
|
||||||
|
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
|
||||||
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
||||||
|
|
||||||
blur = {
|
blur = {
|
||||||
@@ -83,9 +90,13 @@ hl.config({
|
|||||||
shadow = {
|
shadow = {
|
||||||
enabled = prefs.get("shadowEnabled", true),
|
enabled = prefs.get("shadowEnabled", true),
|
||||||
range = prefs.get("shadowRange", 20),
|
range = prefs.get("shadowRange", 20),
|
||||||
render_power = 3,
|
render_power = prefs.getInt("shadowRenderPower", 3),
|
||||||
sharp = false,
|
sharp = prefs.get("shadowSharp", false),
|
||||||
color = "rgba(15161eee)",
|
color = "rgba(15161eee)",
|
||||||
|
-- Deliberately not a setting: a two-axis offset needs a control we
|
||||||
|
-- do not have, and a slider bound to half a value is worse than
|
||||||
|
-- leaving it alone. SystemSettings understands the vec2 shape
|
||||||
|
-- already, so adding it later is only a matter of the widget.
|
||||||
offset = { 0, 4 },
|
offset = { 0, 4 },
|
||||||
scale = 1.0,
|
scale = 1.0,
|
||||||
},
|
},
|
||||||
@@ -110,14 +121,32 @@ hl.config({
|
|||||||
dwindle = {
|
dwindle = {
|
||||||
-- Keep the split orientation a window was created with. Closest match
|
-- Keep the split orientation a window was created with. Closest match
|
||||||
-- to how the Forge extension behaved on GNOME.
|
-- to how the Forge extension behaved on GNOME.
|
||||||
preserve_split = true,
|
preserve_split = prefs.get("preserveSplit", true),
|
||||||
smart_resizing = true,
|
smart_resizing = true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
|
||||||
|
-- but Settings offers master as a choice, and a layout you can select and
|
||||||
|
-- cannot configure is barely a choice at all.
|
||||||
|
master = {
|
||||||
|
mfact = prefs.get("masterFactor", 0.55),
|
||||||
|
orientation = prefs.get("masterOrientation", "left"),
|
||||||
|
new_status = prefs.get("masterNewStatus", "slave"),
|
||||||
|
new_on_top = prefs.get("masterNewOnTop", false),
|
||||||
|
},
|
||||||
|
|
||||||
misc = {
|
misc = {
|
||||||
force_default_wallpaper = 0,
|
force_default_wallpaper = 0,
|
||||||
disable_hyprland_logo = true,
|
-- Stored as "show the logo / show the splash" and written as Hyprland's
|
||||||
disable_splash_rendering = true,
|
-- `disable_*`, matching the `invert` flag on these entries in
|
||||||
|
-- PreferenceSchema so both sides agree about which way round they are.
|
||||||
|
disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
|
||||||
|
disable_splash_rendering = not prefs.get("hyprlandSplash", false),
|
||||||
|
|
||||||
|
-- Keep native Wayland selection paste and GTK's matching preference
|
||||||
|
-- in lockstep. DesktopStyle applies the GTK half only after this value
|
||||||
|
-- has been read back and stored by SystemSettings.
|
||||||
|
middle_click_paste = prefs.get("middleClickPaste", true),
|
||||||
|
|
||||||
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
||||||
-- followed the preference, the compositor and the shell would disagree
|
-- followed the preference, the compositor and the shell would disagree
|
||||||
@@ -172,8 +201,8 @@ hl.config({
|
|||||||
},
|
},
|
||||||
|
|
||||||
ecosystem = {
|
ecosystem = {
|
||||||
no_update_news = true,
|
no_update_news = not prefs.get("hyprlandUpdateNews", false),
|
||||||
no_donation_nag = true,
|
no_donation_nag = not prefs.get("hyprlandDonationNag", false),
|
||||||
},
|
},
|
||||||
|
|
||||||
xwayland = {
|
xwayland = {
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
local prefs = require("prefs")
|
local prefs = require("prefs")
|
||||||
|
|
||||||
-- Per-output overrides written by Panama Settings, keyed by output name:
|
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||||
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
-- { ["DP-2"] = {
|
||||||
|
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
||||||
|
-- x = 0, y = 0, primary = true,
|
||||||
|
-- } }
|
||||||
--
|
--
|
||||||
-- Only mode, scale, and transform are read. Colour management and bit depth
|
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||||
-- stay here, because those are the settings with a documented reason attached
|
-- stay here, because those are the settings with a documented reason attached
|
||||||
@@ -71,6 +74,26 @@ local function valid_transform(transform)
|
|||||||
and transform <= 3
|
and transform <= 3
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function valid_coordinate(value)
|
||||||
|
return type(value) == "number"
|
||||||
|
and value == value
|
||||||
|
and value == math.floor(value)
|
||||||
|
and value >= -100000
|
||||||
|
and value <= 100000
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_position(entry)
|
||||||
|
return valid_coordinate(entry.x) and valid_coordinate(entry.y)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_primary(entry)
|
||||||
|
return type(entry.primary) == "boolean"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_layout_fields(entry)
|
||||||
|
return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
local function display_entry(output)
|
local function display_entry(output)
|
||||||
if type(output) ~= "string" or output == ""
|
if type(output) ~= "string" or output == ""
|
||||||
or output:match("^[%w_.-]+$") == nil then
|
or output:match("^[%w_.-]+$") == nil then
|
||||||
@@ -85,9 +108,23 @@ local function display_entry(output)
|
|||||||
or not valid_transform(entry.transform) then
|
or not valid_transform(entry.transform) then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
-- Legacy records have none of the layout fields and keep automatic
|
||||||
|
-- placement. A partially written extended record is unsafe: accepting its
|
||||||
|
-- mode but guessing its position could overlap or strand another output.
|
||||||
|
if has_layout_fields(entry)
|
||||||
|
and (not valid_position(entry) or not valid_primary(entry)) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
return entry
|
return entry
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function display_position(entry, fallback)
|
||||||
|
if entry ~= nil and has_layout_fields(entry) then
|
||||||
|
return string.format("%dx%d", entry.x, entry.y)
|
||||||
|
end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
local shipped_mode = "4500x3000@60"
|
local shipped_mode = "4500x3000@60"
|
||||||
local shipped_scale = 1.5
|
local shipped_scale = 1.5
|
||||||
local shipped_transform = 0
|
local shipped_transform = 0
|
||||||
@@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2")
|
|||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "DP-2",
|
output = "DP-2",
|
||||||
mode = dp2 and dp2.mode or shipped_mode,
|
mode = dp2 and dp2.mode or shipped_mode,
|
||||||
position = "0x0",
|
position = display_position(dp2, "0x0"),
|
||||||
scale = dp2 and dp2.scale or shipped_scale,
|
scale = dp2 and dp2.scale or shipped_scale,
|
||||||
transform = dp2 and dp2.transform or shipped_transform,
|
transform = dp2 and dp2.transform or shipped_transform,
|
||||||
|
|
||||||
@@ -119,7 +156,7 @@ for output, _ in pairs(displays) do
|
|||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = output,
|
output = output,
|
||||||
mode = entry.mode,
|
mode = entry.mode,
|
||||||
position = "auto",
|
position = display_position(entry, "auto"),
|
||||||
scale = entry.scale,
|
scale = entry.scale,
|
||||||
transform = entry.transform,
|
transform = entry.transform,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import "services/AudioStreams.js" as AudioStreams
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
readonly property int audioOutStreamFlag: 4
|
||||||
|
|
||||||
|
property var fixtureNodes: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
ready: true,
|
||||||
|
type: audioOutStreamFlag,
|
||||||
|
properties: {
|
||||||
|
"application.id": "org.chromium.Chromium",
|
||||||
|
"application.name": "Chromium",
|
||||||
|
"application.icon_name": "chromium"
|
||||||
|
},
|
||||||
|
description: "Chromium audio",
|
||||||
|
audio: { volume: 0.4, muted: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
ready: true,
|
||||||
|
type: audioOutStreamFlag,
|
||||||
|
properties: {
|
||||||
|
"application.id": "org.chromium.Chromium",
|
||||||
|
"application.name": "Chromium",
|
||||||
|
"application.icon_name": "chromium"
|
||||||
|
},
|
||||||
|
description: "Chromium audio",
|
||||||
|
audio: { volume: 0.8, muted: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 20,
|
||||||
|
ready: true,
|
||||||
|
type: audioOutStreamFlag,
|
||||||
|
properties: {
|
||||||
|
"application.process.binary": "spotify",
|
||||||
|
"application.name": "Spotify"
|
||||||
|
},
|
||||||
|
description: "Spotify",
|
||||||
|
audio: { volume: 0.25, muted: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 30,
|
||||||
|
ready: true,
|
||||||
|
type: 2,
|
||||||
|
properties: { "application.name": "Microphone capture" },
|
||||||
|
description: "Input stream",
|
||||||
|
audio: { volume: 0.5, muted: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 40,
|
||||||
|
ready: false,
|
||||||
|
type: audioOutStreamFlag,
|
||||||
|
properties: { "application.name": "Not ready" },
|
||||||
|
description: "Unready stream",
|
||||||
|
audio: { volume: 0.5, muted: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 99,
|
||||||
|
ready: true,
|
||||||
|
type: audioOutStreamFlag,
|
||||||
|
properties: {},
|
||||||
|
description: "",
|
||||||
|
audio: { volume: 1, muted: false }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function groups(): var {
|
||||||
|
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "application-volume-test"
|
||||||
|
|
||||||
|
function summary(): string {
|
||||||
|
const applications = groups();
|
||||||
|
const chromium = applications.find(application =>
|
||||||
|
application.key === "org.chromium.Chromium");
|
||||||
|
return JSON.stringify({
|
||||||
|
groups: applications.map(application => ({
|
||||||
|
key: application.key,
|
||||||
|
label: application.label,
|
||||||
|
icon: application.icon,
|
||||||
|
count: application.nodes.length
|
||||||
|
})),
|
||||||
|
chromiumVolume: AudioStreams.volume(chromium),
|
||||||
|
chromiumMuted: AudioStreams.muted(chromium)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateVolume(): string {
|
||||||
|
const chromium = groups().find(application =>
|
||||||
|
application.key === "org.chromium.Chromium");
|
||||||
|
const changed = AudioStreams.setVolume(chromium, 0.7);
|
||||||
|
return JSON.stringify({
|
||||||
|
changed,
|
||||||
|
volumes: chromium.nodes.map(node => node.audio.volume),
|
||||||
|
muted: chromium.nodes.map(node => node.audio.muted)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateMute(): string {
|
||||||
|
const chromium = groups().find(application =>
|
||||||
|
application.key === "org.chromium.Chromium");
|
||||||
|
const changed = AudioStreams.setMuted(chromium, true);
|
||||||
|
return JSON.stringify({
|
||||||
|
changed,
|
||||||
|
muted: chromium.nodes.map(node => node.audio.muted)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceSummary(): string {
|
||||||
|
const applications = AudioDevices.applications;
|
||||||
|
return JSON.stringify({
|
||||||
|
count: applications.length,
|
||||||
|
validTypes: applications.every(application =>
|
||||||
|
application.nodes.every(node =>
|
||||||
|
(node.type & PwNodeType.AudioOutStream)
|
||||||
|
=== PwNodeType.AudioOutStream))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidMutations(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
nullVolume: AudioDevices.setApplicationVolume(null, 0.5),
|
||||||
|
emptyMute: AudioDevices.setApplicationMuted({ nodes: [] }, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,6 +206,147 @@ Singleton {
|
|||||||
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||||
|
group: "windows",
|
||||||
|
label: "Focused window opacity",
|
||||||
|
detail: "Fade even the focused window; 1.0 is fully opaque",
|
||||||
|
hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||||
|
group: "windows",
|
||||||
|
label: "Fullscreen opacity",
|
||||||
|
detail: "Applied instead of the focused opacity when a window is fullscreen",
|
||||||
|
hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
|
||||||
|
group: "windows",
|
||||||
|
label: "Corner shape",
|
||||||
|
detail: "2 is a circular corner; higher values approach a squircle",
|
||||||
|
hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Window edges ────────────────────────────────────────────────────
|
||||||
|
// How the pointer interacts with a window's border, and how windows
|
||||||
|
// behave near each other. All shipped by looks.lua with no way to
|
||||||
|
// change any of it.
|
||||||
|
{
|
||||||
|
key: "resizeOnBorder", type: "bool", def: true, group: "edges",
|
||||||
|
label: "Resize by dragging the border",
|
||||||
|
detail: "Drag a window's edge to resize it, instead of only with the keyboard",
|
||||||
|
hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Border grab area",
|
||||||
|
detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
|
||||||
|
hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
|
||||||
|
label: "Show the resize cursor",
|
||||||
|
detail: "Change the pointer when it is over a resizable border",
|
||||||
|
hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Snap distance between windows",
|
||||||
|
detail: "How close two floating windows must be before they snap together",
|
||||||
|
hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Snap distance to screen edges",
|
||||||
|
detail: "How close a floating window must be to an edge before it snaps to it",
|
||||||
|
hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapRespectGaps", type: "bool", def: false, group: "edges",
|
||||||
|
label: "Snapping respects gaps",
|
||||||
|
detail: "Snapped windows keep the configured gap instead of touching",
|
||||||
|
hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Master layout ───────────────────────────────────────────────────
|
||||||
|
// Only meaningful when the tiling layout is Master and stack. Offering
|
||||||
|
// that layout with none of its options was an omission: it is the one
|
||||||
|
// layout whose whole behaviour is in these settings.
|
||||||
|
{
|
||||||
|
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
|
||||||
|
group: "master",
|
||||||
|
label: "Master area size",
|
||||||
|
detail: "How much of the screen the master window takes",
|
||||||
|
hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterOrientation", type: "enum", def: "left", group: "master",
|
||||||
|
label: "Master area position",
|
||||||
|
detail: "Which side of the screen the master window occupies",
|
||||||
|
options: [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "right", label: "Right" },
|
||||||
|
{ value: "top", label: "Top" },
|
||||||
|
{ value: "bottom", label: "Bottom" },
|
||||||
|
{ value: "center", label: "Centre" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterNewStatus", type: "enum", def: "slave", group: "master",
|
||||||
|
label: "New windows become",
|
||||||
|
detail: "Whether a new window takes the master area or joins the stack",
|
||||||
|
options: [
|
||||||
|
{ value: "master", label: "The master window" },
|
||||||
|
{ value: "slave", label: "Part of the stack" },
|
||||||
|
{ value: "inherit", label: "Whatever the focused window is" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterNewOnTop", type: "bool", def: false, group: "master",
|
||||||
|
label: "Add new windows at the top",
|
||||||
|
detail: "New stack windows go above the others rather than below",
|
||||||
|
hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hyprland's own notices ──────────────────────────────────────────
|
||||||
|
// Panama turns all four off on the user's behalf. That is a defensible
|
||||||
|
// default and was not a decision anyone could reverse without editing
|
||||||
|
// looks.lua, which is precisely the kind of thing this app exists to
|
||||||
|
// stop.
|
||||||
|
{
|
||||||
|
key: "hyprlandLogo", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Hyprland wallpaper",
|
||||||
|
detail: "The stock background Hyprland draws when no wallpaper is set",
|
||||||
|
hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandSplash", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Splash text",
|
||||||
|
detail: "The line of text Hyprland renders over the stock background",
|
||||||
|
hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Update announcements",
|
||||||
|
detail: "The window Hyprland opens after an update to describe what changed",
|
||||||
|
hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Donation reminders",
|
||||||
|
detail: "The prompt Hyprland shows twice a year asking for support",
|
||||||
|
hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Effects ─────────────────────────────────────────────────────────
|
// ── Effects ─────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
||||||
@@ -241,6 +382,19 @@ Singleton {
|
|||||||
detail: "How far the shadow spreads from the window edge",
|
detail: "How far the shadow spreads from the window edge",
|
||||||
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "shadowSharp", type: "bool", def: false, group: "effects",
|
||||||
|
label: "Hard-edged shadow",
|
||||||
|
detail: "A crisp shadow instead of a soft falloff",
|
||||||
|
hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
|
||||||
|
group: "effects",
|
||||||
|
label: "Shadow falloff",
|
||||||
|
detail: "How sharply the shadow fades out. Higher is tighter to the window",
|
||||||
|
hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
||||||
label: "Focus glow",
|
label: "Focus glow",
|
||||||
@@ -282,7 +436,7 @@ Singleton {
|
|||||||
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "keyboardOptions", type: "string", def: "", group: "input",
|
key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
|
||||||
// XKB option names are colon-separated pairs in a comma-separated
|
// XKB option names are colon-separated pairs in a comma-separated
|
||||||
// list, e.g. "compose:ralt,caps:escape".
|
// list, e.g. "compose:ralt,caps:escape".
|
||||||
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
||||||
@@ -313,7 +467,7 @@ Singleton {
|
|||||||
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
|
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "followMouse", type: "enum", def: 1, group: "input",
|
key: "followMouse", type: "enum", def: 1, group: "pointer",
|
||||||
label: "Pointer focus",
|
label: "Pointer focus",
|
||||||
detail: "What moving the pointer does to which window is focused",
|
detail: "What moving the pointer does to which window is focused",
|
||||||
// These labels were wrong, and wrong in the worst way: value 1 was
|
// These labels were wrong, and wrong in the worst way: value 1 was
|
||||||
@@ -341,7 +495,7 @@ Singleton {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
|
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
|
||||||
group: "input",
|
group: "pointer",
|
||||||
label: "Pointer speed",
|
label: "Pointer speed",
|
||||||
detail: "Zero is flat, unaccelerated response",
|
detail: "Zero is flat, unaccelerated response",
|
||||||
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
|
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
|
||||||
@@ -349,7 +503,7 @@ Singleton {
|
|||||||
{
|
{
|
||||||
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||||
unit: "s",
|
unit: "s",
|
||||||
group: "input",
|
group: "pointer",
|
||||||
label: "Hide pointer after",
|
label: "Hide pointer after",
|
||||||
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
||||||
// Reported as a float even though it is only ever set to whole
|
// Reported as a float even though it is only ever set to whole
|
||||||
@@ -394,6 +548,12 @@ Singleton {
|
|||||||
detail: "Swap the primary and secondary buttons",
|
detail: "Swap the primary and secondary buttons",
|
||||||
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
|
||||||
|
label: "Middle-click paste",
|
||||||
|
detail: "Paste the primary selection in GTK and native Wayland applications",
|
||||||
|
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Touchpad ────────────────────────────────────────────────────────
|
// ── Touchpad ────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -587,6 +747,66 @@ Singleton {
|
|||||||
label: "Wallpaper",
|
label: "Wallpaper",
|
||||||
detail: "Shown on every output"
|
detail: "Shown on every output"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
|
||||||
|
label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
|
||||||
|
options: [
|
||||||
|
{ value: "single", label: "Single" },
|
||||||
|
{ value: "slideshow", label: "Slideshow" },
|
||||||
|
{ value: "per-monitor", label: "Per display" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
|
||||||
|
label: "Slideshow collection", detail: "Backgrounds selected for rotation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
|
||||||
|
unit: "min", group: "wallpaper", label: "Change background every",
|
||||||
|
detail: "Time between slideshow images"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
|
||||||
|
label: "Shuffle", detail: "Show every selected image before repeating"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
|
||||||
|
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Lock-screen appearance ─────────────────────────────────────────
|
||||||
|
// scripts/panama-lock validates these again before generating a state
|
||||||
|
// config. The tracked hyprlock.conf remains the safe fallback.
|
||||||
|
{
|
||||||
|
key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
|
||||||
|
label: "Background", detail: "What appears behind the lock screen",
|
||||||
|
options: [
|
||||||
|
{ value: "screenshot", label: "Blurred desktop" },
|
||||||
|
{ value: "wallpaper", label: "Current wallpaper" },
|
||||||
|
{ value: "solid", label: "Solid color" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
|
||||||
|
group: "lockAppearance", label: "Background blur",
|
||||||
|
detail: "Softens what is behind the password field"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lockShowClock", type: "bool", def: true, group: "lockAppearance",
|
||||||
|
label: "Show clock", detail: "Use the desktop's 12 or 24-hour format"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lockShowDate", type: "bool", def: true, group: "lockAppearance",
|
||||||
|
label: "Show date", detail: "Show the weekday and full date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lockShowUser", type: "bool", def: true, group: "lockAppearance",
|
||||||
|
label: "Show user name", detail: "Identify the signed-in account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance",
|
||||||
|
label: "Hide password field until typing", detail: "Keep the empty field out of the way"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Idle, lock, and sleep ───────────────────────────────────────────
|
// ── Idle, lock, and sleep ───────────────────────────────────────────
|
||||||
// Written into a generated hypridle config; see scripts/panama-idle.
|
// Written into a generated hypridle config; see scripts/panama-idle.
|
||||||
@@ -652,6 +872,24 @@ Singleton {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Application themes ─────────────────────────────────────────────
|
||||||
|
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||||
|
// choices GNOME applications expose independently of that palette:
|
||||||
|
// their icons and pointer. DesktopStyle only accepts names found in
|
||||||
|
// the read-only XDG catalog before storing them.
|
||||||
|
{
|
||||||
|
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Pointer theme",
|
||||||
|
detail: "The pointer design used by applications and Hyprland"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Application icons",
|
||||||
|
detail: "The icon set used by GTK applications"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Typography ──────────────────────────────────────────────────────
|
// ── Typography ──────────────────────────────────────────────────────
|
||||||
// The single largest thing in this desktop that used to be changeable
|
// The single largest thing in this desktop that used to be changeable
|
||||||
// only by editing Theme.qml.
|
// only by editing Theme.qml.
|
||||||
@@ -681,6 +919,91 @@ Singleton {
|
|||||||
label: "Interface text size",
|
label: "Interface text size",
|
||||||
detail: "The base size the rest of the shell's type scales from"
|
detail: "The base size the rest of the shell's type scales from"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Application font",
|
||||||
|
detail: "Used by menus, controls, and labels in applications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Application text size",
|
||||||
|
detail: "The base text size used by applications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Document font",
|
||||||
|
detail: "Used for document content when an application follows the system choice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Document text size",
|
||||||
|
detail: "The default text size for document content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Monospace font",
|
||||||
|
detail: "Used by terminals, editors, and code fields that follow the system choice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Monospace text size",
|
||||||
|
detail: "The default text size for terminals and code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fontHinting", type: "enum", def: "slight", group: "typography",
|
||||||
|
label: "Font hinting",
|
||||||
|
detail: "How strongly text aligns to the pixel grid",
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "slight", label: "Slight" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "full", label: "Full" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
|
||||||
|
label: "Text smoothing",
|
||||||
|
detail: "How application text softens its edges",
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "grayscale", label: "Grayscale" },
|
||||||
|
{ value: "rgba", label: "Subpixel" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Application titlebars ──────────────────────────────────────────
|
||||||
|
// These affect applications that honour GNOME's window preferences.
|
||||||
|
// Hyprland itself has no server-side titlebar buttons, so minimize is
|
||||||
|
// deliberately absent rather than presented as a switch that lies.
|
||||||
|
{
|
||||||
|
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
|
||||||
|
label: "Button side",
|
||||||
|
detail: "Place application titlebar buttons on the left or right",
|
||||||
|
options: [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "right", label: "Right" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
|
||||||
|
label: "Maximize button",
|
||||||
|
detail: "Show a maximize button in application titlebars that support it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
|
||||||
|
label: "Double-click titlebar",
|
||||||
|
detail: "Choose what a double-click on an application titlebar does",
|
||||||
|
options: [
|
||||||
|
{ value: "toggle-maximize", label: "Toggle maximize" },
|
||||||
|
{ value: "none", label: "Do nothing" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// ── Accessibility ───────────────────────────────────────────────────
|
// ── Accessibility ───────────────────────────────────────────────────
|
||||||
// Backed by gsettings so GTK applications agree with the shell, and
|
// Backed by gsettings so GTK applications agree with the shell, and
|
||||||
@@ -868,7 +1191,7 @@ Singleton {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Display configuration ───────────────────────────────────────────
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
// { "<output>": { mode, scale, transform } }, applied by
|
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
||||||
// hypr/monitors.lua on top of the shipped values. Colour management and
|
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||||
// bit depth are deliberately not here: those carry a documented
|
// bit depth are deliberately not here: those carry a documented
|
||||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||||
@@ -877,7 +1200,7 @@ Singleton {
|
|||||||
key: "displays", type: "json", def: ({}), group: "display",
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
internal: true,
|
internal: true,
|
||||||
label: "Display configuration",
|
label: "Display configuration",
|
||||||
detail: "Resolution, scale, and rotation per connected display"
|
detail: "Resolution, scale, rotation, position, and primary display"
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Per-application notification rules ──────────────────────────────
|
// ── Per-application notification rules ──────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.modules.settings
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
DisplayIdentify {}
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: fixtureService
|
||||||
|
|
||||||
|
property var monitors: [
|
||||||
|
{
|
||||||
|
name: "DP-2", description: "Primary display", width: 4500, height: 3000,
|
||||||
|
refreshRate: 60, mode: "[email protected]", scale: 1.5,
|
||||||
|
transform: 0, x: 0, y: 0, primary: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HDMI-A-1", description: "Second display", width: 2560, height: 1440,
|
||||||
|
refreshRate: 60, mode: "[email protected]", scale: 1,
|
||||||
|
transform: 0, x: 3000, y: 0, primary: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
property var applied: []
|
||||||
|
|
||||||
|
function currentLayout(): var {
|
||||||
|
return monitors.map(record => Object.assign({}, record));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayout(layout: var): bool {
|
||||||
|
applied = layout.map(record => Object.assign({}, record));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisplayArrangement {
|
||||||
|
id: arrangement
|
||||||
|
width: 800
|
||||||
|
displayService: fixtureService
|
||||||
|
selectedOutput: "HDMI-A-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "display-arrangement-test"
|
||||||
|
|
||||||
|
function status(width: int): string {
|
||||||
|
arrangement.width = width;
|
||||||
|
arrangement.resetDraft();
|
||||||
|
return JSON.stringify(arrangement.canvasSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragFixture(): string {
|
||||||
|
arrangement.resetDraft();
|
||||||
|
arrangement.setDraftPosition("HDMI-A-1", 3016, 0, true);
|
||||||
|
arrangement.applyDraft();
|
||||||
|
return JSON.stringify(fixtureService.applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardFixture(): string {
|
||||||
|
arrangement.resetDraft();
|
||||||
|
arrangement.nudge("HDMI-A-1", -10, 0);
|
||||||
|
const afterArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
|
||||||
|
arrangement.nudge("HDMI-A-1", -100, 0);
|
||||||
|
const afterShiftArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
|
||||||
|
return JSON.stringify({ afterArrow, afterShiftArrow });
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryFixture(): string {
|
||||||
|
arrangement.resetDraft();
|
||||||
|
arrangement.makePrimary("HDMI-A-1");
|
||||||
|
return JSON.stringify(fixtureService.applied.map(record => ({
|
||||||
|
name: record.name, x: record.x, y: record.y, primary: record.primary
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function identify(): void { Displays.identify(); }
|
||||||
|
function identifying(): bool { return Displays.identifying; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import "services/DisplayLayout.js" as DisplayLayout
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
readonly property var fixture: [
|
||||||
|
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true },
|
||||||
|
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
|
||||||
|
]
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "display-layout-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
const normalized = DisplayLayout.normalize(fixture);
|
||||||
|
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
||||||
|
const near = normalized.map(record => Object.assign({}, record));
|
||||||
|
near[1].x = 3016;
|
||||||
|
const far = normalized.map(record => Object.assign({}, record));
|
||||||
|
far[1].x = 3017;
|
||||||
|
return JSON.stringify({
|
||||||
|
valid: DisplayLayout.validate(fixture),
|
||||||
|
sizes: fixture.map(DisplayLayout.logicalSize),
|
||||||
|
normalized: normalized.map(record => ({ name: record.name, x: record.x, y: record.y, primary: record.primary })),
|
||||||
|
bounds: canvas.bounds,
|
||||||
|
canvasScale: canvas.scale,
|
||||||
|
canvasRects: canvas.rects,
|
||||||
|
near: DisplayLayout.snap(near, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x,
|
||||||
|
far: DisplayLayout.snap(far, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(): string {
|
||||||
|
const base = fixture.map(record => Object.assign({}, record));
|
||||||
|
const cases = [];
|
||||||
|
const add = layout => cases.push(DisplayLayout.validate(layout));
|
||||||
|
add([base[0], Object.assign({}, base[1], { name: "DP-2" })]);
|
||||||
|
add(base.map(record => Object.assign({}, record, { primary: false })));
|
||||||
|
add(base.map(record => Object.assign({}, record, { primary: true })));
|
||||||
|
add([Object.assign({}, base[0], { x: 0.5 }), base[1]]);
|
||||||
|
add([Object.assign({}, base[0], { scale: 0 }), base[1]]);
|
||||||
|
add([Object.assign({}, base[0], { transform: 4 }), base[1]]);
|
||||||
|
add([Object.assign({}, base[0], { width: Infinity }), base[1]]);
|
||||||
|
add([Object.assign({}, base[0], { width: 0 }), base[1]]);
|
||||||
|
return JSON.stringify(cases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ ShellRoot {
|
|||||||
mode: monitor ? monitor.mode : "",
|
mode: monitor ? monitor.mode : "",
|
||||||
scale: monitor ? monitor.scale : 0,
|
scale: monitor ? monitor.scale : 0,
|
||||||
transform: monitor ? monitor.transform : -1,
|
transform: monitor ? monitor.transform : -1,
|
||||||
|
x: monitor ? monitor.x : 0,
|
||||||
|
y: monitor ? monitor.y : 0,
|
||||||
|
primary: monitor ? monitor.primary : false,
|
||||||
modes: monitor ? monitor.modes.length : 0,
|
modes: monitor ? monitor.modes.length : 0,
|
||||||
awaiting: Displays.awaitingConfirmation,
|
awaiting: Displays.awaitingConfirmation,
|
||||||
canConfirm: Displays.canConfirm,
|
canConfirm: Displays.canConfirm,
|
||||||
@@ -36,6 +39,49 @@ ShellRoot {
|
|||||||
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transactionStatus(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
layout: Displays.currentLayout(),
|
||||||
|
pending: Displays.pendingRequestedLayout,
|
||||||
|
previous: Displays.pendingPreviousLayout,
|
||||||
|
reverting: Displays.revertExpectedLayout,
|
||||||
|
awaiting: Displays.awaitingConfirmation,
|
||||||
|
canConfirm: Displays.canConfirm,
|
||||||
|
busy: Displays.busy,
|
||||||
|
generation: Displays.operationGeneration,
|
||||||
|
revertGeneration: Displays.revertGeneration,
|
||||||
|
lastError: Displays.lastError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayoutFixture(secondX: int, secondY: int): bool {
|
||||||
|
const layout = Displays.currentLayout();
|
||||||
|
if (layout.length !== 2) return false;
|
||||||
|
layout[0].x = 0;
|
||||||
|
layout[0].y = 0;
|
||||||
|
layout[0].primary = true;
|
||||||
|
layout[1].x = secondX;
|
||||||
|
layout[1].y = secondY;
|
||||||
|
layout[1].primary = false;
|
||||||
|
return Displays.applyLayout(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePrimaryFixture(output: string): bool {
|
||||||
|
return Displays.makePrimary(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectReadback(text: string, generation: int): void {
|
||||||
|
Displays.parse(text, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireApplyVerification(): void {
|
||||||
|
Displays.verificationTimedOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireRevertVerification(): void {
|
||||||
|
Displays.revertVerificationTimedOut();
|
||||||
|
}
|
||||||
|
|
||||||
function refreshIdentityFixture(): string {
|
function refreshIdentityFixture(): string {
|
||||||
const modes = Displays.normaliseModes([
|
const modes = Displays.normaliseModes([
|
||||||
"[email protected]",
|
"[email protected]",
|
||||||
@@ -49,6 +95,30 @@ ShellRoot {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function positionFixture(): string {
|
||||||
|
const previous = Displays.monitors;
|
||||||
|
Displays.parse(JSON.stringify([
|
||||||
|
{
|
||||||
|
name: "DP-2", description: "Primary", width: 4500, height: 3000,
|
||||||
|
refreshRate: 60, scale: 1.5, transform: 0, x: 140, y: 80,
|
||||||
|
availableModes: ["[email protected]"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HDMI-A-1", description: "Second", width: 2560, height: 1440,
|
||||||
|
refreshRate: 60, scale: 1, transform: 0, x: 3140, y: 80,
|
||||||
|
availableModes: ["[email protected]"]
|
||||||
|
}
|
||||||
|
]), Displays.operationGeneration);
|
||||||
|
const result = JSON.stringify(Displays.monitors.map(monitor => ({
|
||||||
|
name: monitor.name,
|
||||||
|
x: monitor.x,
|
||||||
|
y: monitor.y,
|
||||||
|
primary: monitor.primary
|
||||||
|
})));
|
||||||
|
Displays.monitors = previous;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function applyBad(kind: string): bool {
|
function applyBad(kind: string): bool {
|
||||||
const monitor = Displays.monitors[0];
|
const monitor = Displays.monitors[0];
|
||||||
if (!monitor) return false;
|
if (!monitor) return false;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "lock-screen-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
generated: LockScreen.generated,
|
||||||
|
path: LockScreen.path,
|
||||||
|
fallback: LockScreen.fallback,
|
||||||
|
lastError: LockScreen.lastError,
|
||||||
|
busy: LockScreen.busy
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function burst(): void {
|
||||||
|
DesktopPreferences.set("lockBlurLevel", 1);
|
||||||
|
DesktopPreferences.set("lockBlurLevel", 2);
|
||||||
|
DesktopPreferences.set("lockBlurLevel", 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
LockScreen.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.modules.settings
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string fixtureWallpaper: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
||||||
|
|
||||||
|
LockScreenPreview {
|
||||||
|
id: screenshotPreview
|
||||||
|
width: 620
|
||||||
|
backgroundMode: "screenshot"
|
||||||
|
blurLevel: 4
|
||||||
|
showClock: true
|
||||||
|
showDate: true
|
||||||
|
showUser: true
|
||||||
|
fadeOnEmpty: false
|
||||||
|
wallpaperPath: root.fixtureWallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
LockScreenPreview {
|
||||||
|
id: wallpaperPreview
|
||||||
|
width: 620
|
||||||
|
backgroundMode: "wallpaper"
|
||||||
|
blurLevel: 2
|
||||||
|
showClock: false
|
||||||
|
showDate: true
|
||||||
|
showUser: false
|
||||||
|
fadeOnEmpty: true
|
||||||
|
wallpaperPath: root.fixtureWallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
LockScreenPreview {
|
||||||
|
id: solidPreview
|
||||||
|
width: 620
|
||||||
|
backgroundMode: "solid"
|
||||||
|
blurLevel: 0
|
||||||
|
showClock: true
|
||||||
|
showDate: false
|
||||||
|
showUser: true
|
||||||
|
fadeOnEmpty: false
|
||||||
|
wallpaperPath: root.fixtureWallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "lock-screen-settings-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
screenshot: {
|
||||||
|
mode: screenshotPreview.previewMode,
|
||||||
|
wallpaperVisible: screenshotPreview.wallpaperVisible,
|
||||||
|
blurStrength: screenshotPreview.blurStrength,
|
||||||
|
clockVisible: screenshotPreview.clockVisible,
|
||||||
|
dateVisible: screenshotPreview.dateVisible,
|
||||||
|
userVisible: screenshotPreview.userVisible,
|
||||||
|
passwordVisible: screenshotPreview.passwordVisible
|
||||||
|
},
|
||||||
|
wallpaper: {
|
||||||
|
mode: wallpaperPreview.previewMode,
|
||||||
|
wallpaperVisible: wallpaperPreview.wallpaperVisible,
|
||||||
|
blurStrength: wallpaperPreview.blurStrength,
|
||||||
|
clockVisible: wallpaperPreview.clockVisible,
|
||||||
|
dateVisible: wallpaperPreview.dateVisible,
|
||||||
|
userVisible: wallpaperPreview.userVisible,
|
||||||
|
passwordVisible: wallpaperPreview.passwordVisible
|
||||||
|
},
|
||||||
|
solid: {
|
||||||
|
mode: solidPreview.previewMode,
|
||||||
|
wallpaperVisible: solidPreview.wallpaperVisible,
|
||||||
|
blurStrength: solidPreview.blurStrength,
|
||||||
|
clockVisible: solidPreview.clockVisible,
|
||||||
|
dateVisible: solidPreview.dateVisible,
|
||||||
|
userVisible: solidPreview.userVisible,
|
||||||
|
passwordVisible: solidPreview.passwordVisible
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ Pill {
|
|||||||
horizontalPadding: 8
|
horizontalPadding: 8
|
||||||
onActivated: ShellState.toggle("activity")
|
onActivated: ShellState.toggle("activity")
|
||||||
|
|
||||||
|
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
|
||||||
|
onSecondaryActivated: ShellState.openSettings("privacy")
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: {
|
text: {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Pill {
|
|||||||
horizontalPadding: 8
|
horizontalPadding: 8
|
||||||
onActivated: ShellState.openDateMenu("agenda")
|
onActivated: ShellState.openDateMenu("agenda")
|
||||||
|
|
||||||
|
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
|
||||||
|
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||||
|
|
||||||
ToolTip.visible: root.hovered && root.visible
|
ToolTip.visible: root.hovered && root.visible
|
||||||
ToolTip.delay: 500
|
ToolTip.delay: 500
|
||||||
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
|
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ Pill {
|
|||||||
|
|
||||||
onActivated: ShellState.toggleDateMenu("agenda")
|
onActivated: ShellState.toggleDateMenu("agenda")
|
||||||
|
|
||||||
|
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
|
||||||
|
// format toggles are mirrored on Appearance, but someone right-clicking a
|
||||||
|
// clock is far more often after the time itself than its typography.
|
||||||
|
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: Qt.formatDateTime(clock.date, root.format)
|
text: Qt.formatDateTime(clock.date, root.format)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell.Services.Mpris
|
import Quickshell.Services.Mpris
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Pill {
|
Pill {
|
||||||
@@ -34,6 +35,9 @@ Pill {
|
|||||||
onActivated: if (root.player?.canTogglePlaying)
|
onActivated: if (root.player?.canTogglePlaying)
|
||||||
root.player.togglePlaying()
|
root.player.togglePlaying()
|
||||||
|
|
||||||
|
// Right-click opens the settings that govern this widget. Output device and per-application volume.
|
||||||
|
onSecondaryActivated: ShellState.openSettings("sound")
|
||||||
|
|
||||||
// Scroll up = previous, down = next — the same direction as the workspace
|
// Scroll up = previous, down = next — the same direction as the workspace
|
||||||
// switcher, so the whole bar scrolls consistently.
|
// switcher, so the whole bar scrolls consistently.
|
||||||
onScrolled: delta => {
|
onScrolled: delta => {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ Pill {
|
|||||||
|
|
||||||
onActivated: root.requestQuickSettings()
|
onActivated: root.requestQuickSettings()
|
||||||
|
|
||||||
|
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
|
||||||
|
onSecondaryActivated: ShellState.openSettings("connectivity")
|
||||||
|
|
||||||
// ── Audio ───────────────────────────────────────────────────────────────
|
// ── Audio ───────────────────────────────────────────────────────────────
|
||||||
// Without a tracker, volume and muted silently read as zero/false.
|
// Without a tracker, volume and muted silently read as zero/false.
|
||||||
PwObjectTracker {
|
PwObjectTracker {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import qs.widgets
|
|||||||
Pill {
|
Pill {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// Right-click opens the settings that govern this widget. Which readouts
|
||||||
|
// appear in the bar, and how often they update.
|
||||||
|
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||||
|
|
||||||
interactive: false
|
interactive: false
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import qs.widgets
|
|||||||
Pill {
|
Pill {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
|
||||||
|
onSecondaryActivated: ShellState.openSettings("home")
|
||||||
|
|
||||||
interactive: false
|
interactive: false
|
||||||
visible: Weather.available
|
visible: Weather.available
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property string expandedPicker: ""
|
||||||
|
|
||||||
title: "Appearance"
|
title: "Appearance"
|
||||||
lede: "Drag anything below. The preview above is your real geometry, to scale."
|
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||||
|
|
||||||
header: Component {
|
header: Component {
|
||||||
Column {
|
Column {
|
||||||
@@ -48,9 +50,16 @@ SettingsPage {
|
|||||||
? Wallpaper.lastError
|
? Wallpaper.lastError
|
||||||
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||||
|
|
||||||
|
WallpaperControls {
|
||||||
|
id: wallpaperControls
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
WallpaperPicker {
|
WallpaperPicker {
|
||||||
id: wallpapers
|
id: wallpapers
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
mode: wallpaperControls.mode
|
||||||
|
selectedOutput: wallpaperControls.selectedOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
@@ -73,6 +82,24 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Lock screen"
|
||||||
|
subtitle: LockScreen.lastError !== ""
|
||||||
|
? LockScreen.lastError
|
||||||
|
: "A representative preview of the screen shown before authentication."
|
||||||
|
|
||||||
|
LockScreenPreview {
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceRow { setting: "lockBackgroundMode" }
|
||||||
|
SliderRow { setting: "lockBlurLevel"; zeroLabel: "Off" }
|
||||||
|
ToggleRow { setting: "lockShowClock" }
|
||||||
|
ToggleRow { setting: "lockShowDate" }
|
||||||
|
ToggleRow { setting: "lockShowUser" }
|
||||||
|
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Colour scheme"
|
title: "Colour scheme"
|
||||||
subtitle: ColorScheme.lastError !== ""
|
subtitle: ColorScheme.lastError !== ""
|
||||||
@@ -83,7 +110,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Typography"
|
title: "Shell typography"
|
||||||
subtitle: Fonts.lastError !== ""
|
subtitle: Fonts.lastError !== ""
|
||||||
? Fonts.lastError
|
? Fonts.lastError
|
||||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
||||||
@@ -123,6 +150,137 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Application typography"
|
||||||
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
|
? DesktopStyle.lastError
|
||||||
|
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Application font"
|
||||||
|
detail: DesktopStyle.applicationFont
|
||||||
|
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "application-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.interfaceFonts
|
||||||
|
current: DesktopStyle.applicationFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setApplicationFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "applicationFontSize" }
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Document font"
|
||||||
|
detail: DesktopStyle.documentFont
|
||||||
|
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "document-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.interfaceFonts
|
||||||
|
current: DesktopStyle.documentFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setDocumentFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "documentFontSize" }
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Monospace font"
|
||||||
|
detail: DesktopStyle.monospaceFont
|
||||||
|
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "monospace-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.monospaceFonts
|
||||||
|
current: DesktopStyle.monospaceFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setMonospaceFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "monospaceFontSize" }
|
||||||
|
ChoiceRow { setting: "fontHinting" }
|
||||||
|
ChoiceRow { setting: "fontAntialiasing"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Icons & pointer"
|
||||||
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
|
? DesktopStyle.lastError
|
||||||
|
: "Installed themes only. The pointer updates in applications and Hyprland together."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Application icons"
|
||||||
|
detail: DesktopStyle.iconTheme
|
||||||
|
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
|
||||||
|
enabled: DesktopStyle.catalogLoaded
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
visible: root.expandedPicker === "icon-theme"
|
||||||
|
width: parent.width
|
||||||
|
items: DesktopStyle.iconThemes
|
||||||
|
current: DesktopStyle.iconTheme
|
||||||
|
placeholder: "Search icon themes"
|
||||||
|
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
|
||||||
|
onPicked: value => {
|
||||||
|
if (DesktopStyle.setIconTheme(value))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Pointer theme"
|
||||||
|
detail: DesktopStyle.cursorTheme
|
||||||
|
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
|
||||||
|
enabled: DesktopStyle.catalogLoaded
|
||||||
|
divider: false
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
visible: root.expandedPicker === "cursor-theme"
|
||||||
|
width: parent.width
|
||||||
|
items: DesktopStyle.cursorThemes
|
||||||
|
current: DesktopStyle.cursorTheme
|
||||||
|
placeholder: "Search pointer themes"
|
||||||
|
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
|
||||||
|
onPicked: value => {
|
||||||
|
if (DesktopStyle.setCursorTheme(value))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Titlebars"
|
||||||
|
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||||
|
|
||||||
|
ChoiceRow { setting: "titlebarButtonSide" }
|
||||||
|
ToggleRow { setting: "titlebarMaximizeButton" }
|
||||||
|
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Windows"
|
title: "Windows"
|
||||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||||
@@ -131,7 +289,10 @@ SettingsPage {
|
|||||||
SliderRow { setting: "gapsIn" }
|
SliderRow { setting: "gapsIn" }
|
||||||
SliderRow { setting: "gapsOut" }
|
SliderRow { setting: "gapsOut" }
|
||||||
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
||||||
SliderRow { setting: "inactiveOpacity"; divider: false }
|
SliderRow { setting: "roundingPower" }
|
||||||
|
SliderRow { setting: "inactiveOpacity" }
|
||||||
|
SliderRow { setting: "activeOpacity" }
|
||||||
|
SliderRow { setting: "fullscreenOpacity"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -143,6 +304,8 @@ SettingsPage {
|
|||||||
SliderRow { setting: "blurPasses" }
|
SliderRow { setting: "blurPasses" }
|
||||||
ToggleRow { setting: "shadowEnabled" }
|
ToggleRow { setting: "shadowEnabled" }
|
||||||
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
||||||
|
SliderRow { setting: "shadowRenderPower" }
|
||||||
|
ToggleRow { setting: "shadowSharp" }
|
||||||
ToggleRow { setting: "glowEnabled" }
|
ToggleRow { setting: "glowEnabled" }
|
||||||
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
||||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
@@ -162,7 +325,10 @@ SettingsPage {
|
|||||||
|
|
||||||
ToggleRow { setting: "showCpu" }
|
ToggleRow { setting: "showCpu" }
|
||||||
ToggleRow { setting: "showMemory" }
|
ToggleRow { setting: "showMemory" }
|
||||||
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
ToggleRow { setting: "showGpu"; divider: true }
|
||||||
|
// Refresh interval was on the Home page, which split one concept across
|
||||||
|
// two pages -- what the vitals show here, how often they update there.
|
||||||
|
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||||
|
|
||||||
// Only worth asking when there is a choice to make.
|
// Only worth asking when there is a choice to make.
|
||||||
ChoiceGrid {
|
ChoiceGrid {
|
||||||
@@ -182,11 +348,4 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Theme"
|
|
||||||
subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
|
||||||
|
|
||||||
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
|
||||||
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var applications: AudioDevices.applications
|
||||||
|
property bool pipewireReady: Pipewire.ready
|
||||||
|
|
||||||
|
readonly property int rowCount: applicationRepeater.count
|
||||||
|
readonly property string statusText: {
|
||||||
|
if (!root.pipewireReady)
|
||||||
|
return "PipeWire is unavailable";
|
||||||
|
if (root.applications.length === 0)
|
||||||
|
return "Applications playing sound will appear here";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: applicationRepeater
|
||||||
|
model: root.applications
|
||||||
|
|
||||||
|
ApplicationVolumeRow {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: root.width
|
||||||
|
application: modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.statusText !== ""
|
||||||
|
text: root.statusText
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
topPadding: 18
|
||||||
|
bottomPadding: 18
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.modules.quicksettings
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var application
|
||||||
|
|
||||||
|
readonly property real volume: AudioDevices.applicationVolume(root.application)
|
||||||
|
readonly property bool muted: AudioDevices.applicationMuted(root.application)
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
implicitHeight: 78
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: Theme.alpha(Theme.fg, 0.025)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
|
||||||
|
PwObjectTracker {
|
||||||
|
objects: root.application?.nodes ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemedIcon {
|
||||||
|
id: applicationIcon
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 11
|
||||||
|
size: 20
|
||||||
|
icon: root.application?.icon ?? "audio-x-generic-symbolic"
|
||||||
|
iconFallback: "audio-x-generic-symbolic"
|
||||||
|
tint: Theme.fg
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: applicationIcon.right
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.application?.label ?? "Unknown application"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: (root.application?.nodes?.length ?? 0) > 1
|
||||||
|
text: `${root.application?.nodes?.length ?? 0} audio streams`
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: muteButton
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 6
|
||||||
|
size: 30
|
||||||
|
iconSize: 17
|
||||||
|
icon: root.muted || root.volume <= 0.001
|
||||||
|
? "audio-volume-muted-symbolic"
|
||||||
|
: "audio-volume-high-symbolic"
|
||||||
|
iconFallback: "audio-volume-high-symbolic"
|
||||||
|
onClicked: AudioDevices.setApplicationMuted(root.application, !root.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
anchors.left: muteButton.right
|
||||||
|
anchors.leftMargin: 7
|
||||||
|
anchors.right: volumeText.left
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: muteButton.verticalCenter
|
||||||
|
value: root.muted ? 0 : root.volume
|
||||||
|
onMoved: value => AudioDevices.setApplicationVolume(root.application, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: volumeText
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: muteButton.verticalCenter
|
||||||
|
width: 38
|
||||||
|
text: Math.round(root.volume * 100) + "%"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ SettingsPage {
|
|||||||
lede: "Choose what opens your files and links, and what starts with your session."
|
lede: "Choose what opens your files and links, and what starts with your session."
|
||||||
|
|
||||||
property string expandedRole: ""
|
property string expandedRole: ""
|
||||||
|
property bool addingAutostart: false
|
||||||
readonly property var applications: DesktopEntries.applications.values
|
readonly property var applications: DesktopEntries.applications.values
|
||||||
readonly property var roles: [
|
readonly property var roles: [
|
||||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
||||||
@@ -178,7 +179,28 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "User autostart"
|
title: "User autostart"
|
||||||
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
|
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Add an application"
|
||||||
|
detail: root.addingAutostart
|
||||||
|
? "Search the applications installed on this machine"
|
||||||
|
: "Start another installed application when you sign in"
|
||||||
|
action: root.addingAutostart ? "Close" : "Choose"
|
||||||
|
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
|
||||||
|
enabled: !DefaultApps.busy
|
||||||
|
onTriggered: root.addingAutostart = !root.addingAutostart
|
||||||
|
}
|
||||||
|
|
||||||
|
AutostartAppPicker {
|
||||||
|
visible: root.addingAutostart
|
||||||
|
width: parent.width
|
||||||
|
existing: DefaultApps.autostartEntries.map(entry => entry.id)
|
||||||
|
onPicked: id => {
|
||||||
|
DefaultApps.addAutostart(id);
|
||||||
|
root.addingAutostart = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ SettingRow {
|
|||||||
|
|
||||||
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
||||||
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
|
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
|
||||||
readonly property bool available: root.node?.audio
|
readonly property bool available: !!root.node?.audio
|
||||||
&& root.leftIndex >= 0 && root.rightIndex >= 0
|
&& root.leftIndex >= 0 && root.rightIndex >= 0
|
||||||
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Adds an installed application to the user's freedesktop autostart directory.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.config
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var existing
|
||||||
|
signal picked(string id)
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
function desktopId(entry: var): string {
|
||||||
|
const id = String(entry?.id ?? "");
|
||||||
|
return id.endsWith(".desktop") ? id : id + ".desktop";
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var matches: {
|
||||||
|
const needle = search.text.trim().toLowerCase();
|
||||||
|
if (needle === "")
|
||||||
|
return [];
|
||||||
|
const out = [];
|
||||||
|
for (const entry of DesktopEntries.applications.values) {
|
||||||
|
const desktopId = root.desktopId(entry);
|
||||||
|
if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0)
|
||||||
|
continue;
|
||||||
|
const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase();
|
||||||
|
if (haystack.indexOf(needle) >= 0)
|
||||||
|
out.push(entry);
|
||||||
|
if (out.length >= 8)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: search
|
||||||
|
width: parent.width
|
||||||
|
placeholder: "Search installed applications"
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.matches
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: candidate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: String(candidate.modelData.name || root.desktopId(candidate.modelData))
|
||||||
|
detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData))
|
||||||
|
divider: candidate.index < root.matches.length - 1
|
||||||
|
controlWidth: 86
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Add"
|
||||||
|
onClicked: {
|
||||||
|
root.picked(root.desktopId(candidate.modelData));
|
||||||
|
search.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: search.text.trim() !== "" && root.matches.length === 0
|
||||||
|
label: "No matching applications"
|
||||||
|
detail: "Only installed desktop applications can start with the session"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,45 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GNOME's Multitasking panel, in Hyprland's terms.
|
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||||
|
// Only meaningful when the layout above is Master and stack. Hidden
|
||||||
|
// otherwise, because a card of settings that do nothing under the layout
|
||||||
|
// you are actually running is worse than not offering the layout at all.
|
||||||
|
SettingsCard {
|
||||||
|
visible: DesktopPreferences.get("windowLayout") === "master"
|
||||||
|
title: "Master and stack"
|
||||||
|
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
|
||||||
|
|
||||||
|
SliderRow { setting: "masterFactor" }
|
||||||
|
ChoiceRow { setting: "masterOrientation" }
|
||||||
|
ChoiceRow { setting: "masterNewStatus" }
|
||||||
|
ToggleRow { setting: "masterNewOnTop"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Window edges"
|
||||||
|
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
|
||||||
|
|
||||||
|
ToggleRow { setting: "resizeOnBorder" }
|
||||||
|
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
|
||||||
|
ToggleRow { setting: "hoverIconOnBorder" }
|
||||||
|
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
|
||||||
|
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
|
||||||
|
ToggleRow { setting: "snapRespectGaps"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland's own interruptions. Panama turns all four off, which is a
|
||||||
|
// defensible default and was not previously a decision anyone could
|
||||||
|
// reverse without editing looks.lua.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Hyprland notices"
|
||||||
|
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
|
||||||
|
|
||||||
|
ToggleRow { setting: "hyprlandLogo" }
|
||||||
|
ToggleRow { setting: "hyprlandSplash" }
|
||||||
|
ToggleRow { setting: "hyprlandUpdateNews" }
|
||||||
|
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Workspaces & focus"
|
title: "Workspaces & focus"
|
||||||
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||||
@@ -74,8 +113,7 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "workspaceBackAndForth" }
|
ToggleRow { setting: "workspaceBackAndForth" }
|
||||||
ToggleRow { setting: "allowWorkspaceCycles" }
|
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||||
ToggleRow { setting: "focusOnActivate" }
|
ToggleRow { setting: "focusOnActivate" }
|
||||||
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
|
||||||
ChoiceRow { setting: "followMouse"; divider: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.widgets
|
||||||
|
import "../../services/DisplayLayout.js" as DisplayLayout
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var displayService
|
||||||
|
property string selectedOutput: ""
|
||||||
|
property var draftLayout: []
|
||||||
|
property bool interactionEnabled: true
|
||||||
|
|
||||||
|
signal selectionRequested(string output)
|
||||||
|
|
||||||
|
implicitHeight: content.implicitHeight
|
||||||
|
readonly property var canvasData: DisplayLayout.canvasRects(
|
||||||
|
root.draftLayout, canvas.width, canvas.height, 18)
|
||||||
|
|
||||||
|
function copied(layout): var {
|
||||||
|
return (layout || []).map(record => Object.assign({}, record));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDraft(): void {
|
||||||
|
root.draftLayout = root.copied(root.displayService.currentLayout());
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDraftPosition(output: string, x: real, y: real, snapToEdges: bool): bool {
|
||||||
|
const next = root.copied(root.draftLayout);
|
||||||
|
const record = next.find(candidate => candidate.name === output);
|
||||||
|
if (!record)
|
||||||
|
return false;
|
||||||
|
record.x = Math.round(x);
|
||||||
|
record.y = Math.round(y);
|
||||||
|
root.draftLayout = snapToEdges ? DisplayLayout.snap(next, output, 16) : next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudge(output: string, dx: int, dy: int): bool {
|
||||||
|
const record = root.draftLayout.find(candidate => candidate.name === output);
|
||||||
|
return !!record && root.setDraftPosition(output, record.x + dx, record.y + dy, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDraft(): bool {
|
||||||
|
return root.displayService.applyLayout(root.copied(root.draftLayout));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePrimary(output: string): bool {
|
||||||
|
const next = root.copied(root.draftLayout);
|
||||||
|
if (!next.some(record => record.name === output))
|
||||||
|
return false;
|
||||||
|
for (const record of next)
|
||||||
|
record.primary = record.name === output;
|
||||||
|
root.draftLayout = DisplayLayout.normalize(next);
|
||||||
|
return root.applyDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasSnapshot(): var {
|
||||||
|
return {
|
||||||
|
bounds: root.canvasData.bounds,
|
||||||
|
scale: root.canvasData.scale,
|
||||||
|
rects: root.canvasData.rects.map(record => Object.assign({}, record))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.resetDraft()
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root.displayService
|
||||||
|
ignoreUnknownSignals: true
|
||||||
|
function onMonitorsChanged(): void {
|
||||||
|
if (!root.displayService.awaitingConfirmation)
|
||||||
|
root.resetDraft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
width: parent.width
|
||||||
|
spacing: 11
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: canvas
|
||||||
|
width: parent.width
|
||||||
|
height: root.width >= 620 ? 232 : 190
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: Theme.alpha(Theme.bgDark, 0.76)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
// A restrained coordinate field makes the topology feel like a
|
||||||
|
// precision instrument without turning it into a technical graph.
|
||||||
|
Repeater {
|
||||||
|
model: 4
|
||||||
|
Rectangle {
|
||||||
|
required property int index
|
||||||
|
y: (index + 1) * canvas.height / 5
|
||||||
|
width: canvas.width
|
||||||
|
height: 1
|
||||||
|
color: Theme.alpha(Theme.fg, 0.025)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.canvasData.rects
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: tile
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
readonly property bool selected: root.selectedOutput === modelData.name
|
||||||
|
readonly property var draft: root.draftLayout.find(
|
||||||
|
record => record.name === modelData.name)
|
||||||
|
|
||||||
|
x: modelData.x
|
||||||
|
y: modelData.y
|
||||||
|
width: Math.max(64, modelData.width)
|
||||||
|
height: Math.max(48, modelData.height)
|
||||||
|
radius: 11
|
||||||
|
color: tile.selected
|
||||||
|
? Theme.alpha(Theme.bgHighlight, 0.92)
|
||||||
|
: Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78)
|
||||||
|
border.width: tile.selected || activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus
|
||||||
|
? Theme.accentSecondary
|
||||||
|
: (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14))
|
||||||
|
opacity: root.interactionEnabled ? 1 : 0.5
|
||||||
|
activeFocusOnTab: root.interactionEnabled
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: "Move " + tile.modelData.name
|
||||||
|
Accessible.description: tile.draft && tile.draft.primary
|
||||||
|
? "Primary display. Drag or use the arrow keys to move it."
|
||||||
|
: "Drag or use the arrow keys to move this display."
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 2
|
||||||
|
radius: parent.radius - 2
|
||||||
|
visible: tile.selected
|
||||||
|
opacity: 0.24
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0; color: Theme.accent }
|
||||||
|
GradientStop { position: 1; color: Theme.accentSecondary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 11
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: tile.modelData.name
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: tile.draft
|
||||||
|
? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}`
|
||||||
|
: ""
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: 7
|
||||||
|
width: primaryText.implicitWidth + 12
|
||||||
|
height: 20
|
||||||
|
radius: Theme.pillRadius
|
||||||
|
visible: tile.draft && tile.draft.primary
|
||||||
|
color: Theme.alpha(Theme.accent, 0.2)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: primaryText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Primary"
|
||||||
|
color: Theme.accentAlt
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hover
|
||||||
|
enabled: root.interactionEnabled
|
||||||
|
cursorShape: Qt.OpenHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.interactionEnabled
|
||||||
|
onTapped: {
|
||||||
|
root.selectionRequested(tile.modelData.name);
|
||||||
|
tile.forceActiveFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DragHandler {
|
||||||
|
id: drag
|
||||||
|
target: null
|
||||||
|
enabled: root.interactionEnabled
|
||||||
|
property real initialX: 0
|
||||||
|
property real initialY: 0
|
||||||
|
property bool moved: false
|
||||||
|
|
||||||
|
onActiveChanged: {
|
||||||
|
if (active) {
|
||||||
|
const record = root.draftLayout.find(
|
||||||
|
candidate => candidate.name === tile.modelData.name);
|
||||||
|
initialX = record ? record.x : 0;
|
||||||
|
initialY = record ? record.y : 0;
|
||||||
|
moved = false;
|
||||||
|
root.selectionRequested(tile.modelData.name);
|
||||||
|
tile.forceActiveFocus();
|
||||||
|
} else if (moved) {
|
||||||
|
const record = root.draftLayout.find(
|
||||||
|
candidate => candidate.name === tile.modelData.name);
|
||||||
|
if (record) {
|
||||||
|
root.setDraftPosition(tile.modelData.name, record.x, record.y, true);
|
||||||
|
root.applyDraft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onTranslationChanged: {
|
||||||
|
if (!active || root.canvasData.scale <= 0)
|
||||||
|
return;
|
||||||
|
moved = true;
|
||||||
|
root.setDraftPosition(
|
||||||
|
tile.modelData.name,
|
||||||
|
initialX + translation.x / root.canvasData.scale,
|
||||||
|
initialY + translation.y / root.canvasData.scale,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
if (!root.interactionEnabled)
|
||||||
|
return;
|
||||||
|
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
|
||||||
|
let handled = true;
|
||||||
|
if (event.key === Qt.Key_Left)
|
||||||
|
root.nudge(tile.modelData.name, -step, 0);
|
||||||
|
else if (event.key === Qt.Key_Right)
|
||||||
|
root.nudge(tile.modelData.name, step, 0);
|
||||||
|
else if (event.key === Qt.Key_Up)
|
||||||
|
root.nudge(tile.modelData.name, 0, -step);
|
||||||
|
else if (event.key === Qt.Key_Down)
|
||||||
|
root.nudge(tile.modelData.name, 0, step);
|
||||||
|
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
|
||||||
|
root.applyDraft();
|
||||||
|
else if (event.key === Qt.Key_Escape)
|
||||||
|
root.resetDraft();
|
||||||
|
else
|
||||||
|
handled = false;
|
||||||
|
event.accepted = handled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: Math.max(0, parent.width - identifyButton.width
|
||||||
|
- primaryButton.width - applyButton.width - 24)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.width >= 600
|
||||||
|
? "Drag to arrange · arrows move 10 px · Shift moves 100 px"
|
||||||
|
: "Drag or use the arrow keys"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: identifyButton
|
||||||
|
text: "Identify"
|
||||||
|
enabled: root.interactionEnabled
|
||||||
|
onClicked: root.displayService.identify()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: primaryButton
|
||||||
|
text: "Make primary"
|
||||||
|
enabled: root.interactionEnabled && root.selectedOutput !== ""
|
||||||
|
&& !root.draftLayout.find(record =>
|
||||||
|
record.name === root.selectedOutput)?.primary
|
||||||
|
onClicked: root.makePrimary(root.selectedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: applyButton
|
||||||
|
text: "Apply"
|
||||||
|
enabled: root.interactionEnabled
|
||||||
|
onClicked: root.applyDraft()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: Quickshell.screens
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: win
|
||||||
|
|
||||||
|
property var modelData: null
|
||||||
|
readonly property string connector: win.modelData?.name ?? "Display"
|
||||||
|
readonly property int number: Math.max(1,
|
||||||
|
Displays.monitors.findIndex(monitor => monitor.name === win.connector) + 1)
|
||||||
|
readonly property string description: Displays.monitorNamed(win.connector)?.description ?? "Connected display"
|
||||||
|
|
||||||
|
screen: win.modelData
|
||||||
|
visible: Displays.identifying
|
||||||
|
implicitWidth: 260
|
||||||
|
implicitHeight: 172
|
||||||
|
color: "transparent"
|
||||||
|
exclusiveZone: 0
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
mask: Region {}
|
||||||
|
|
||||||
|
WlrLayershell.namespace: "qs-display-identify"
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.popoverRadius
|
||||||
|
color: Theme.alpha(Theme.bgPopover, 0.96)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.14)
|
||||||
|
|
||||||
|
PrismEdge {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
inset: parent.radius
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - 32
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: String(win.number)
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 68
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: win.connector
|
||||||
|
color: Theme.accentAlt
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: win.description
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,20 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Displays.monitors.length > 1
|
||||||
|
title: "Arrange displays"
|
||||||
|
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
|
||||||
|
|
||||||
|
DisplayArrangement {
|
||||||
|
width: parent.width
|
||||||
|
displayService: Displays
|
||||||
|
selectedOutput: root.selectedOutput
|
||||||
|
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
onSelectionRequested: output => root.selectedOutput = output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: Displays.monitors.length > 1
|
visible: Displays.monitors.length > 1
|
||||||
title: "Connected display"
|
title: "Connected display"
|
||||||
|
|||||||
@@ -117,12 +117,6 @@ SettingsPage {
|
|||||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "System vitals"
|
|
||||||
subtitle: "Processor, memory, and graphics activity in the bar"
|
|
||||||
SliderRow { setting: "vitalsIntervalMs"; divider: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
id: summaryCards
|
id: summaryCards
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string backgroundMode: DesktopPreferences.get("lockBackgroundMode")
|
||||||
|
property int blurLevel: DesktopPreferences.get("lockBlurLevel")
|
||||||
|
property bool showClock: DesktopPreferences.get("lockShowClock")
|
||||||
|
property bool showDate: DesktopPreferences.get("lockShowDate")
|
||||||
|
property bool showUser: DesktopPreferences.get("lockShowUser")
|
||||||
|
property bool fadeOnEmpty: DesktopPreferences.get("lockFadeOnEmpty")
|
||||||
|
property bool use24Hour: DesktopPreferences.get("use24Hour")
|
||||||
|
property string wallpaperPath: Wallpaper.active !== ""
|
||||||
|
? Wallpaper.active
|
||||||
|
: (Wallpaper.configured !== "" ? Wallpaper.configured : Wallpaper.shippedPath)
|
||||||
|
|
||||||
|
readonly property string previewMode: root.backgroundMode
|
||||||
|
readonly property real blurStrength: Math.max(0, Math.min(1, root.blurLevel / 5))
|
||||||
|
readonly property bool wallpaperVisible: wallpaper.visible
|
||||||
|
readonly property bool clockVisible: clockLabel.visible
|
||||||
|
readonly property bool dateVisible: dateLabel.visible
|
||||||
|
readonly property bool userVisible: userLabel.visible
|
||||||
|
readonly property bool passwordVisible: passwordField.visible
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
implicitHeight: 230
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
clip: true
|
||||||
|
color: Theme.bg
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.10)
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: wallpaper
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: root.backgroundMode === "wallpaper"
|
||||||
|
source: visible && root.wallpaperPath !== "" ? root.wallpaperPath : ""
|
||||||
|
asynchronous: true
|
||||||
|
cache: true
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
sourceSize.width: 960
|
||||||
|
sourceSize.height: 540
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screenshot mode stays representative instead of taking a real desktop
|
||||||
|
// capture inside Settings. The layered window silhouettes communicate the
|
||||||
|
// selected softness without a live ShaderEffect or GPU repaint loop.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: root.backgroundMode === "screenshot"
|
||||||
|
color: Theme.bgDark
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.10
|
||||||
|
y: parent.height * 0.12
|
||||||
|
width: parent.width * 0.45
|
||||||
|
height: parent.height * 0.66
|
||||||
|
radius: 14 + root.blurStrength * 10
|
||||||
|
color: Theme.alpha(Theme.bgHighlight, 0.52 - root.blurStrength * 0.18)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.14)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.48
|
||||||
|
y: parent.height * 0.24
|
||||||
|
width: parent.width * 0.40
|
||||||
|
height: parent.height * 0.57
|
||||||
|
radius: 14 + root.blurStrength * 10
|
||||||
|
color: Theme.alpha(Theme.bgPanel, 0.64 - root.blurStrength * 0.20)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.accentSecondary, 0.13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: root.backgroundMode === "wallpaper"
|
||||||
|
? Theme.alpha(Theme.bg, 0.42)
|
||||||
|
: Theme.alpha(Theme.bg, 0.12 + root.blurStrength * 0.16)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: clockLabel
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 26
|
||||||
|
visible: root.showClock
|
||||||
|
text: Qt.formatDateTime(clock.date, root.use24Hour ? "HH:mm" : "h:mm")
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 42
|
||||||
|
font.weight: Font.Light
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: dateLabel
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: clockLabel.visible ? clockLabel.bottom : parent.top
|
||||||
|
anchors.topMargin: clockLabel.visible ? 1 : 35
|
||||||
|
visible: root.showDate
|
||||||
|
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: passwordField
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: root.showUser ? 48 : 30
|
||||||
|
visible: !root.fadeOnEmpty
|
||||||
|
width: Math.min(270, parent.width * 0.48)
|
||||||
|
height: 36
|
||||||
|
radius: height / 2
|
||||||
|
color: Theme.alpha(Theme.bgPanel, 0.84)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.78)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Password"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.italic: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: userLabel
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 19
|
||||||
|
visible: root.showUser
|
||||||
|
text: Quickshell.env("USER") || "User"
|
||||||
|
color: Theme.alpha(Theme.fg, 0.90)
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemClock {
|
||||||
|
id: clock
|
||||||
|
precision: SystemClock.Minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,12 @@ SettingsPage {
|
|||||||
ChoiceRow { setting: "accelProfile" }
|
ChoiceRow { setting: "accelProfile" }
|
||||||
ToggleRow { setting: "naturalScroll" }
|
ToggleRow { setting: "naturalScroll" }
|
||||||
SliderRow { setting: "scrollFactor" }
|
SliderRow { setting: "scrollFactor" }
|
||||||
ToggleRow { setting: "leftHanded"; divider: false }
|
ToggleRow { setting: "leftHanded" }
|
||||||
|
ToggleRow {
|
||||||
|
setting: "middleClickPaste"
|
||||||
|
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
|||||||
@@ -59,6 +59,47 @@ If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
|
|||||||
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
|
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
|
||||||
file.
|
file.
|
||||||
|
|
||||||
|
## Setting ownership
|
||||||
|
|
||||||
|
Every preference has **one primary page**, derived from its schema `group` and
|
||||||
|
the route in `services/SettingsSearch.qml`. Search results always open that
|
||||||
|
owner. A control may appear on a second page only when the same adaptation is
|
||||||
|
part of another established mental model; otherwise use a labelled handoff to
|
||||||
|
the owner instead of duplicating it.
|
||||||
|
|
||||||
|
### Intentional mirrors
|
||||||
|
|
||||||
|
| Setting | Primary page | Mirror | Why the mirror earns its place |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
|
||||||
|
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
|
||||||
|
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
|
||||||
|
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
|
||||||
|
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
|
||||||
|
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
|
||||||
|
|
||||||
|
Lock-screen visuals belong only to **Appearance**: background source, blur,
|
||||||
|
clock, date, user name, and password-field presentation. **Power** owns when
|
||||||
|
the session locks, while **Privacy** keeps only the established timing mirrors
|
||||||
|
above. Visual controls must not be copied onto either page.
|
||||||
|
|
||||||
|
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
|
||||||
|
Those values form one safety transaction: every connected output
|
||||||
|
is applied, verified, confirmed, or restored together. Other pages may link to
|
||||||
|
Displays, but must never expose a second geometry control or persist a partial
|
||||||
|
layout.
|
||||||
|
|
||||||
|
Mirrors must remain the same schema-backed control, never a second preference
|
||||||
|
or a copied default. Additions to this table require a concrete discoverability
|
||||||
|
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
|
||||||
|
|
||||||
|
Window border colour follows the same ownership rule. The inactive border is a
|
||||||
|
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
|
||||||
|
neutral contrast in light and dark modes. The focused Prism border is the
|
||||||
|
accent role 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.
|
||||||
|
|
||||||
## The rows
|
## The rows
|
||||||
|
|
||||||
| Component | For |
|
| Component | For |
|
||||||
@@ -125,6 +166,7 @@ file with `qs -p`, and discover its PID from the exact Config path in
|
|||||||
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
|
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
|
||||||
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
|
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
|
||||||
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
|
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
|
||||||
|
| `$XDG_STATE_HOME/panama/hyprlock.conf` | Generated lock-screen appearance |
|
||||||
|
|
||||||
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
|
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
|
||||||
skipped one would be worse than having no reset, because nothing would say so.
|
skipped one would be worse than having no reset, because nothing would say so.
|
||||||
|
|||||||
@@ -21,6 +21,25 @@ SettingsPage {
|
|||||||
// when nothing is being captured. Held here rather than per row so that
|
// when nothing is being captured. Held here rather than per row so that
|
||||||
// starting a new capture cancels any other.
|
// starting a new capture cancels any other.
|
||||||
property string capturingChord: ""
|
property string capturingChord: ""
|
||||||
|
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
|
||||||
|
|
||||||
|
function xkbOptions(): var {
|
||||||
|
return root.storedXkbOptions
|
||||||
|
.split(",")
|
||||||
|
.map(option => option.trim())
|
||||||
|
.filter(option => option !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentXkbOption(prefix: string): string {
|
||||||
|
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setXkbOption(prefix: string, option: string): void {
|
||||||
|
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
|
||||||
|
if (option !== "")
|
||||||
|
options.push(option);
|
||||||
|
SystemSettings.commitPreference("keyboardOptions", options.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
title: "Input & Shortcuts"
|
title: "Input & Shortcuts"
|
||||||
lede: "The Forge mental model, carried forward into native tiling."
|
lede: "The Forge mental model, carried forward into native tiling."
|
||||||
@@ -35,6 +54,52 @@ SettingsPage {
|
|||||||
// they are real controls.
|
// they are real controls.
|
||||||
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||||
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Caps Lock"
|
||||||
|
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
|
||||||
|
current: root.currentXkbOption("caps:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Standard" },
|
||||||
|
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
|
||||||
|
{ value: "caps:escape", label: "Escape" },
|
||||||
|
{ value: "caps:ctrl_modifier", label: "Control" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("caps:", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Compose key"
|
||||||
|
detail: "Type accented characters and symbols with memorable key sequences"
|
||||||
|
current: root.currentXkbOption("compose:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Off" },
|
||||||
|
{ value: "compose:ralt", label: "Right Alt" },
|
||||||
|
{ value: "compose:rwin", label: "Right Super" },
|
||||||
|
{ value: "compose:menu", label: "Menu" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("compose:", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Layout switching"
|
||||||
|
detail: "Used when Keyboard layout contains more than one comma-separated layout"
|
||||||
|
current: root.currentXkbOption("grp:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Off" },
|
||||||
|
{ value: "grp:win_space_toggle", label: "Super + Space" },
|
||||||
|
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
|
||||||
|
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
|
||||||
|
{ value: "grp:caps_toggle", label: "Caps Lock" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("grp:", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets preserve every option outside their own category. The raw
|
||||||
|
// value remains visible for less common xkeyboard-config features.
|
||||||
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||||
SliderRow { setting: "keyRepeatDelay" }
|
SliderRow { setting: "keyRepeatDelay" }
|
||||||
SliderRow { setting: "keyRepeatRate" }
|
SliderRow { setting: "keyRepeatRate" }
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Applications"
|
||||||
|
subtitle: "Control each application currently playing through PipeWire."
|
||||||
|
|
||||||
|
ApplicationMixer {
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Sound feedback"
|
title: "Sound feedback"
|
||||||
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
||||||
@@ -67,8 +76,8 @@ SettingsPage {
|
|||||||
title: "Advanced sound"
|
title: "Advanced sound"
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Application volumes and profiles"
|
label: "Device profiles"
|
||||||
detail: "Open Fedora's complete sound panel"
|
detail: "Open Fedora's complete device profile panel"
|
||||||
divider: false
|
divider: false
|
||||||
action: "Open panel"
|
action: "Open panel"
|
||||||
onTriggered: SystemSettings.openGnomePanel("sound")
|
onTriggered: SystemSettings.openGnomePanel("sound")
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string mode: DesktopPreferences.get("wallpaperMode")
|
||||||
|
property var outputs: Wallpaper.outputNames()
|
||||||
|
property string selectedOutput: root.outputs.length > 0 ? root.outputs[0] : ""
|
||||||
|
|
||||||
|
property var setModeAction: function(mode) { Wallpaper.setMode(mode); }
|
||||||
|
property var setIntervalAction: function(minutes) { Wallpaper.setIntervalMinutes(minutes); }
|
||||||
|
property var setShuffleAction: function(enabled) { Wallpaper.setShuffle(enabled); }
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Wallpaper mode"
|
||||||
|
detail: "Use one image, rotate a collection, or choose per display"
|
||||||
|
options: PreferenceSchema.spec("wallpaperMode").options
|
||||||
|
current: root.mode
|
||||||
|
onPicked: value => root.setModeAction(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
visible: root.mode === "per-monitor"
|
||||||
|
width: parent.width
|
||||||
|
label: "Display"
|
||||||
|
detail: "Choose which display the thumbnail grid assigns"
|
||||||
|
options: root.outputs.map(output => ({ value: output, label: output }))
|
||||||
|
current: root.selectedOutput
|
||||||
|
onPicked: value => root.selectedOutput = value
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: root.mode === "slideshow"
|
||||||
|
label: "Change background every"
|
||||||
|
detail: "Time between slideshow images"
|
||||||
|
controlWidth: 280
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: intervalText.left
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
value: (DesktopPreferences.get("wallpaperIntervalMinutes") - 5) / 1435
|
||||||
|
onMoved: ratio => {
|
||||||
|
const raw = 5 + ratio * 1435;
|
||||||
|
root.setIntervalAction(Math.max(5, Math.min(1440, Math.round(raw / 5) * 5)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: intervalText
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 62
|
||||||
|
text: DesktopPreferences.get("wallpaperIntervalMinutes") + " min"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: root.mode === "slideshow"
|
||||||
|
label: "Shuffle"
|
||||||
|
detail: "Show every selected image before repeating"
|
||||||
|
divider: false
|
||||||
|
controlWidth: 48
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: DesktopPreferences.get("wallpaperShuffle") === true
|
||||||
|
onToggled: enabled => root.setShuffleAction(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.mode === "slideshow"
|
||||||
|
&& (DesktopPreferences.get("wallpaperSlideshowPaths") ?? []).length === 0
|
||||||
|
text: "Select two or more images below to begin a slideshow."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
topPadding: 5
|
||||||
|
bottomPadding: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ Item {
|
|||||||
// unreachable without a long scroll past pictures. Two rows by default,
|
// unreachable without a long scroll past pictures. Two rows by default,
|
||||||
// all of them on request.
|
// all of them on request.
|
||||||
property bool expanded: false
|
property bool expanded: false
|
||||||
|
property string mode: DesktopPreferences.get("wallpaperMode")
|
||||||
|
property string selectedOutput: Wallpaper.outputNames()[0] ?? ""
|
||||||
|
property var activeByOutput: Wallpaper.activeByOutput
|
||||||
|
property var slideshowPaths: DesktopPreferences.get("wallpaperSlideshowPaths") ?? []
|
||||||
|
property var assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({})
|
||||||
|
property var setSingleAction: function(path) { Wallpaper.setSingle(path); }
|
||||||
|
property var toggleSlideshowAction: function(path) { Wallpaper.toggleSlideshowPath(path); }
|
||||||
|
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
|
||||||
readonly property int collapsedRows: 2
|
readonly property int collapsedRows: 2
|
||||||
|
|
||||||
readonly property var shown: root.expanded
|
readonly property var shown: root.expanded
|
||||||
@@ -35,6 +43,27 @@ Item {
|
|||||||
readonly property int columns: Math.max(2, Math.floor(width / 190))
|
readonly property int columns: Math.max(2, Math.floor(width / 190))
|
||||||
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
||||||
|
|
||||||
|
function isCurrent(path: string): bool {
|
||||||
|
return root.activeByOutput[root.selectedOutput] === path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selected(path: string): bool {
|
||||||
|
if (root.mode === "slideshow")
|
||||||
|
return root.slideshowPaths.includes(path);
|
||||||
|
if (root.mode === "per-monitor")
|
||||||
|
return root.assignments[root.selectedOutput] === path;
|
||||||
|
return root.isCurrent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(path: string): void {
|
||||||
|
if (root.mode === "slideshow")
|
||||||
|
root.toggleSlideshowAction(path);
|
||||||
|
else if (root.mode === "per-monitor")
|
||||||
|
root.setAssignmentAction(root.selectedOutput, path);
|
||||||
|
else
|
||||||
|
root.setSingleAction(path);
|
||||||
|
}
|
||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
id: grid
|
id: grid
|
||||||
|
|
||||||
@@ -50,7 +79,8 @@ Item {
|
|||||||
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
||||||
readonly property bool current: Wallpaper.active === tile.modelData
|
readonly property bool current: root.isCurrent(tile.modelData)
|
||||||
|
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
|
||||||
|
|
||||||
width: root.cellWidth
|
width: root.cellWidth
|
||||||
height: Math.round(root.cellWidth * 9 / 16)
|
height: Math.round(root.cellWidth * 9 / 16)
|
||||||
@@ -114,6 +144,28 @@ Item {
|
|||||||
border.color: Theme.accent
|
border.color: Theme.accent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: 8
|
||||||
|
width: 24
|
||||||
|
height: 24
|
||||||
|
radius: 12
|
||||||
|
visible: tile.member
|
||||||
|
color: Theme.alpha(Theme.bgDark, 0.82)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.16)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "✓"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
@@ -132,7 +184,9 @@ Item {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.margins: 7
|
anchors.margins: 7
|
||||||
text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData)
|
text: tile.current
|
||||||
|
? "Current wallpaper"
|
||||||
|
: (tile.member ? "In slideshow" : Wallpaper.titleFor(tile.modelData))
|
||||||
color: tile.current ? Theme.accent : Theme.fg
|
color: tile.current ? Theme.accent : Theme.fg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
@@ -144,7 +198,7 @@ Item {
|
|||||||
HoverHandler { id: hover }
|
HoverHandler { id: hover }
|
||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
onTapped: Wallpaper.set(tile.modelData)
|
onTapped: root.activate(tile.modelData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,22 +29,29 @@ ChoiceRow 1.0 ChoiceRow.qml
|
|||||||
ActionRow 1.0 ActionRow.qml
|
ActionRow 1.0 ActionRow.qml
|
||||||
TextRow 1.0 TextRow.qml
|
TextRow 1.0 TextRow.qml
|
||||||
DesktopPreview 1.0 DesktopPreview.qml
|
DesktopPreview 1.0 DesktopPreview.qml
|
||||||
|
LockScreenPreview 1.0 LockScreenPreview.qml
|
||||||
PowerPage 1.0 PowerPage.qml
|
PowerPage 1.0 PowerPage.qml
|
||||||
DateTimePage 1.0 DateTimePage.qml
|
DateTimePage 1.0 DateTimePage.qml
|
||||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||||
|
WallpaperControls 1.0 WallpaperControls.qml
|
||||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||||
|
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||||
DockAppPicker 1.0 DockAppPicker.qml
|
DockAppPicker 1.0 DockAppPicker.qml
|
||||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||||
ChoiceGrid 1.0 ChoiceGrid.qml
|
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
|
DisplayArrangement 1.0 DisplayArrangement.qml
|
||||||
|
DisplayIdentify 1.0 DisplayIdentify.qml
|
||||||
WifiPanel 1.0 WifiPanel.qml
|
WifiPanel 1.0 WifiPanel.qml
|
||||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||||
PasswordField 1.0 PasswordField.qml
|
PasswordField 1.0 PasswordField.qml
|
||||||
AudioBalance 1.0 AudioBalance.qml
|
AudioBalance 1.0 AudioBalance.qml
|
||||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||||
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||||
|
ApplicationMixer 1.0 ApplicationMixer.qml
|
||||||
|
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
|
||||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||||
LocationPicker 1.0 LocationPicker.qml
|
LocationPicker 1.0 LocationPicker.qml
|
||||||
FontPicker 1.0 FontPicker.qml
|
FontPicker 1.0 FontPicker.qml
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
|
|||||||
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
|
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
|
||||||
|
|
||||||
|
|
||||||
def discovered_desktop_ids() -> set[str]:
|
def discovered_desktop_files() -> dict[str, Path]:
|
||||||
desktop_ids: set[str] = set()
|
desktop_files: dict[str, Path] = {}
|
||||||
for root in xdg_data_roots():
|
for root in xdg_data_roots():
|
||||||
applications = root / "applications"
|
applications = root / "applications"
|
||||||
if not applications.is_dir():
|
if not applications.is_dir():
|
||||||
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
relative = path.relative_to(applications)
|
relative = path.relative_to(applications)
|
||||||
desktop_ids.add("-".join(relative.parts))
|
desktop_files.setdefault("-".join(relative.parts), path)
|
||||||
return desktop_ids
|
return desktop_files
|
||||||
|
|
||||||
|
|
||||||
|
def discovered_desktop_ids() -> set[str]:
|
||||||
|
return set(discovered_desktop_files())
|
||||||
|
|
||||||
|
|
||||||
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
|
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
|
||||||
@@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None:
|
|||||||
run(command)
|
run(command)
|
||||||
|
|
||||||
|
|
||||||
def update_hidden(path: Path, *, hidden: bool) -> None:
|
def with_hidden(original: str, *, hidden: bool) -> str:
|
||||||
try:
|
|
||||||
original = path.read_text(encoding="utf-8")
|
|
||||||
except (OSError, UnicodeError) as error:
|
|
||||||
raise BoundaryError("That autostart entry could not be read.") from error
|
|
||||||
|
|
||||||
lines = original.splitlines()
|
lines = original.splitlines()
|
||||||
output: list[str] = []
|
output: list[str] = []
|
||||||
section = ""
|
section = ""
|
||||||
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
|
|||||||
raise BoundaryError("That autostart entry is not a desktop file.")
|
raise BoundaryError("That autostart entry is not a desktop file.")
|
||||||
if not wrote_hidden:
|
if not wrote_hidden:
|
||||||
output.append(f"Hidden={'true' if hidden else 'false'}")
|
output.append(f"Hidden={'true' if hidden else 'false'}")
|
||||||
|
return "\n".join(output) + "\n"
|
||||||
|
|
||||||
mode = path.stat().st_mode
|
|
||||||
|
def write_atomic(path: Path, text: str, *, mode: int) -> None:
|
||||||
|
temporary_path: Path | None = None
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||||
) as temporary:
|
) as temporary:
|
||||||
temporary.write("\n".join(output) + "\n")
|
temporary.write(text)
|
||||||
temporary.flush()
|
temporary.flush()
|
||||||
os.fsync(temporary.fileno())
|
os.fsync(temporary.fileno())
|
||||||
temporary_path = Path(temporary.name)
|
temporary_path = Path(temporary.name)
|
||||||
temporary_path.chmod(mode)
|
temporary_path.chmod(mode)
|
||||||
os.replace(temporary_path, path)
|
os.replace(temporary_path, path)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
if "temporary_path" in locals():
|
if temporary_path is not None:
|
||||||
temporary_path.unlink(missing_ok=True)
|
temporary_path.unlink(missing_ok=True)
|
||||||
raise BoundaryError("That autostart entry could not be updated.") from error
|
raise BoundaryError("That autostart entry could not be updated.") from error
|
||||||
|
|
||||||
|
|
||||||
|
def update_hidden(path: Path, *, hidden: bool) -> None:
|
||||||
|
try:
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise BoundaryError("That autostart entry could not be read.") from error
|
||||||
|
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
def add_autostart(desktop_id: str) -> None:
|
||||||
|
desktop_files = discovered_desktop_files()
|
||||||
|
require_desktop_id(desktop_id, discovered=set(desktop_files))
|
||||||
|
source = desktop_files[desktop_id]
|
||||||
|
directory = autostart_directory()
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as error:
|
||||||
|
raise BoundaryError("The user autostart directory could not be created.") from error
|
||||||
|
|
||||||
|
target = directory / desktop_id
|
||||||
|
if target.is_symlink():
|
||||||
|
raise BoundaryError("That autostart entry is not available.")
|
||||||
|
if target.exists():
|
||||||
|
if not target.is_file():
|
||||||
|
raise BoundaryError("That autostart entry is not available.")
|
||||||
|
update_hidden(target, hidden=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
original = source.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise BoundaryError("That application could not be read.") from error
|
||||||
|
write_atomic(target, with_hidden(original, hidden=False), mode=0o644)
|
||||||
|
|
||||||
|
|
||||||
def set_autostart(desktop_id: str, enabled_text: str) -> None:
|
def set_autostart(desktop_id: str, enabled_text: str) -> None:
|
||||||
if enabled_text not in {"true", "false"}:
|
if enabled_text not in {"true", "false"}:
|
||||||
raise BoundaryError("Autostart state must be true or false.")
|
raise BoundaryError("Autostart state must be true or false.")
|
||||||
@@ -260,10 +298,12 @@ def main(arguments: list[str]) -> int:
|
|||||||
set_default(arguments[1], arguments[2])
|
set_default(arguments[1], arguments[2])
|
||||||
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
||||||
set_autostart(arguments[1], arguments[2])
|
set_autostart(arguments[1], arguments[2])
|
||||||
|
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
||||||
|
add_autostart(arguments[1])
|
||||||
else:
|
else:
|
||||||
raise BoundaryError(
|
raise BoundaryError(
|
||||||
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
||||||
"set-autostart DESKTOP_ID true|false"
|
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
|
||||||
)
|
)
|
||||||
except BoundaryError as error:
|
except BoundaryError as error:
|
||||||
print(str(error), file=sys.stderr)
|
print(str(error), file=sys.stderr)
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Report installed cursor and icon themes from the standard XDG roots.
|
||||||
|
|
||||||
|
This helper is intentionally read-only and argument-free. Theme paths come
|
||||||
|
only from XDG_DATA_HOME and XDG_DATA_DIRS; directory symlinks are not followed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def icon_roots() -> list[Path]:
|
||||||
|
home = Path(os.environ.get("HOME") or "/nonexistent")
|
||||||
|
data_home = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
|
||||||
|
data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
|
||||||
|
|
||||||
|
roots = [data_home / "icons"]
|
||||||
|
roots.extend(Path(directory) / "icons" for directory in data_dirs.split(":") if directory)
|
||||||
|
# XDG paths are required to be absolute. Ignoring malformed relative
|
||||||
|
# entries also prevents the helper's working directory becoming an
|
||||||
|
# accidental caller-controlled search root.
|
||||||
|
return [root for root in roots if root.is_absolute()]
|
||||||
|
|
||||||
|
|
||||||
|
def is_real_directory(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
return stat.S_ISDIR(path.lstat().st_mode)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_real_file(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
return stat.S_ISREG(path.lstat().st_mode)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def has_icon_directories(index_path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
with index_path.open(encoding="utf-8", errors="replace") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if line.startswith(("#", ";")) or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
if key.strip() == "Directories":
|
||||||
|
return bool(value.strip())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def catalog() -> dict[str, list[str]]:
|
||||||
|
cursor_themes: set[str] = set()
|
||||||
|
icon_themes: set[str] = set()
|
||||||
|
|
||||||
|
for root in icon_roots():
|
||||||
|
if not is_real_directory(root):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries = list(os.scandir(root))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_dir(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
theme = Path(entry.path)
|
||||||
|
if is_real_directory(theme / "cursors"):
|
||||||
|
cursor_themes.add(entry.name)
|
||||||
|
|
||||||
|
index_path = theme / "index.theme"
|
||||||
|
if is_real_file(index_path) and has_icon_directories(index_path):
|
||||||
|
icon_themes.add(entry.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cursorThemes": sorted(cursor_themes, key=str.casefold),
|
||||||
|
"iconThemes": sorted(icon_themes, key=str.casefold),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 1:
|
||||||
|
print("panama-desktop-style takes no arguments", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(json.dumps(catalog(), ensure_ascii=False, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -102,7 +102,7 @@ class RepairResult:
|
|||||||
|
|
||||||
CHECK_ORDER = (
|
CHECK_ORDER = (
|
||||||
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
|
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
|
||||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire",
|
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
|
||||||
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||||
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
||||||
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
||||||
@@ -130,7 +130,7 @@ RUNTIME_LINK_TARGETS = (
|
|||||||
("vicinae", Path("config/dot/vicinae")),
|
("vicinae", Path("config/dot/vicinae")),
|
||||||
)
|
)
|
||||||
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
|
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
|
||||||
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
|
PROCESS_NAMES = ("vicinae", "hyprpaper", "hypridle")
|
||||||
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
||||||
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||||
PROBE_ENVIRONMENT_KEYS = (
|
PROBE_ENVIRONMENT_KEYS = (
|
||||||
@@ -159,6 +159,7 @@ CHECK_TITLES = {
|
|||||||
"desktop.portals": "Desktop portals",
|
"desktop.portals": "Desktop portals",
|
||||||
"desktop.hyprpaper": "Hyprpaper",
|
"desktop.hyprpaper": "Hyprpaper",
|
||||||
"desktop.hypridle": "Hypridle",
|
"desktop.hypridle": "Hypridle",
|
||||||
|
"desktop.hyprlock": "Lock screen",
|
||||||
"desktop.vicinae": "Vicinae",
|
"desktop.vicinae": "Vicinae",
|
||||||
"input.pipewire": "PipeWire",
|
"input.pipewire": "PipeWire",
|
||||||
"input.clipboard": "Clipboard",
|
"input.clipboard": "Clipboard",
|
||||||
@@ -320,6 +321,41 @@ def check_portals(config: DoctorConfig) -> Check:
|
|||||||
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
|
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
|
||||||
|
|
||||||
|
|
||||||
|
def check_hyprlock(config: DoctorConfig) -> Check:
|
||||||
|
helper = config.root / "config/dot/quickshell/scripts/panama-lock"
|
||||||
|
tracked_fallback = config.config_home / "hypr/hyprlock.conf"
|
||||||
|
generated_config = config.state_home / "panama/hyprlock.conf"
|
||||||
|
# The doctor seals PATH for every probe. Invoke the authored helper with a
|
||||||
|
# fixed system-only PATH so its bash shebang and jq dependency remain
|
||||||
|
# available without inheriting arbitrary parent executables.
|
||||||
|
result = run_command(("/usr/bin/env", "PATH=/usr/bin:/bin", str(helper), "status"), config)
|
||||||
|
|
||||||
|
if result.state != "ok":
|
||||||
|
if tracked_fallback.is_file():
|
||||||
|
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is unavailable; the tracked fallback remains available.")
|
||||||
|
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = json.loads(result.stdout)
|
||||||
|
generated = state["generated"]
|
||||||
|
path = state["path"]
|
||||||
|
fallback = state["fallback"]
|
||||||
|
error = state["error"]
|
||||||
|
if not isinstance(generated, bool) or not isinstance(path, str) \
|
||||||
|
or not isinstance(fallback, bool) or not isinstance(error, str):
|
||||||
|
raise ValueError
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||||
|
if tracked_fallback.is_file():
|
||||||
|
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is invalid; the tracked fallback remains available.")
|
||||||
|
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||||
|
|
||||||
|
if generated and not fallback and generated_config.is_file():
|
||||||
|
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "ok", "Managed lock-screen configuration is available.")
|
||||||
|
if tracked_fallback.is_file():
|
||||||
|
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "The tracked lock-screen fallback is in use.")
|
||||||
|
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
|
||||||
|
|
||||||
|
|
||||||
def check_brightness(config: DoctorConfig) -> Check:
|
def check_brightness(config: DoctorConfig) -> Check:
|
||||||
result = run_command(("panama-brightness", "list"), config)
|
result = run_command(("panama-brightness", "list"), config)
|
||||||
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
||||||
@@ -436,7 +472,21 @@ def executable_check(check_id: str, title: str, executable: str, config: DoctorC
|
|||||||
|
|
||||||
|
|
||||||
def check_processes(config: DoctorConfig) -> Check:
|
def check_processes(config: DoctorConfig) -> Check:
|
||||||
counts: list[int] = []
|
# `qs` is both the long-running shell and every short-lived IPC client.
|
||||||
|
# Counting it with pgrep races the other parallel health probes and reports
|
||||||
|
# duplicates whenever one of them happens to call `qs ipc`. The instance
|
||||||
|
# list is the authoritative view and contains only actual shells.
|
||||||
|
quickshell = run_command(("qs", "list"), config)
|
||||||
|
if quickshell.state == "ok":
|
||||||
|
quickshell_count = sum(
|
||||||
|
line.startswith("Instance ") for line in quickshell.stdout.splitlines()
|
||||||
|
)
|
||||||
|
elif quickshell.state == "failed":
|
||||||
|
quickshell_count = 0
|
||||||
|
else:
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
|
||||||
|
|
||||||
|
counts: list[int] = [quickshell_count]
|
||||||
for name in PROCESS_NAMES:
|
for name in PROCESS_NAMES:
|
||||||
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
||||||
if result.state == "ok":
|
if result.state == "ok":
|
||||||
@@ -500,7 +550,7 @@ def unavailable_versions() -> list[dict[str, str]]:
|
|||||||
def collect_checks(config: DoctorConfig) -> list[Check]:
|
def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||||
probes: dict[str, Callable[[], Check]] = {
|
probes: dict[str, Callable[[], Check]] = {
|
||||||
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
||||||
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||||
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
||||||
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
||||||
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ generate() {
|
|||||||
printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n'
|
printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n'
|
||||||
|
|
||||||
printf 'general {\n'
|
printf 'general {\n'
|
||||||
printf ' lock_cmd = pidof hyprlock || hyprlock\n'
|
printf ' lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run\n'
|
||||||
if [[ "$lock_on_sleep" == "true" ]]; then
|
if [[ "$lock_on_sleep" == "true" ]]; then
|
||||||
printf ' before_sleep_cmd = loginctl lock-session\n'
|
printf ' before_sleep_cmd = loginctl lock-session\n'
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+346
@@ -0,0 +1,346 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Generates Panama's hyprlock configuration into the state directory. The
|
||||||
|
# tracked config is never rewritten 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'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)'
|
||||||
|
accent_color='rgba(46, 125, 233, 1.0)'
|
||||||
|
accent_ring_color='rgba(46, 125, 233, 0.9)'
|
||||||
|
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)'
|
||||||
|
accent_color='rgba(130, 170, 255, 1.0)'
|
||||||
|
accent_ring_color='rgba(130, 170, 255, 0.9)'
|
||||||
|
error_color='rgba(255, 117, 127, 1.0)'
|
||||||
|
field_color='rgba(46, 47, 61, 0.85)'
|
||||||
|
dim_hex='828bb8'
|
||||||
|
error_hex='ff757f'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -31,6 +31,59 @@ case "$scheme" in
|
|||||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# ── 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_accent="46, 125, 233" # #2e7de9
|
||||||
|
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_accent="130, 170, 255" # #82aaff
|
||||||
|
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
|
||||||
|
|
||||||
|
status_hyprlock="skipped"
|
||||||
|
if [[ -r "$lock_template" ]]; then
|
||||||
|
# Written atomically: a lock triggered mid-write would otherwise read a
|
||||||
|
# truncated config and fall back to hyprlock's own defaults, which is a
|
||||||
|
# bright grey screen with none of this desktop's identity.
|
||||||
|
if sed -e "s/@FG@/$lock_fg/g" \
|
||||||
|
-e "s/@MUTED@/$lock_muted/g" \
|
||||||
|
-e "s/@ACCENT@/$lock_accent/g" \
|
||||||
|
-e "s/@ERROR@/$lock_error/g" \
|
||||||
|
-e "s/@BG@/$lock_bg/g" \
|
||||||
|
-e "s/@FIELD@/$lock_field/g" \
|
||||||
|
-e "s/@MUTED_HEX@/$lock_muted_hex/g" \
|
||||||
|
-e "s/@ERROR_HEX@/$lock_error_hex/g" \
|
||||||
|
"$lock_template" >"$lock_dir/hyprlock.conf.tmp" 2>/dev/null \
|
||||||
|
&& mv "$lock_dir/hyprlock.conf.tmp" "$lock_dir/hyprlock.conf" 2>/dev/null; then
|
||||||
|
status_hyprlock="written"
|
||||||
|
else
|
||||||
|
rm -f "$lock_dir/hyprlock.conf.tmp"
|
||||||
|
status_hyprlock="failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ── tmux ─────────────────────────────────────────────────────────────────────
|
# ── tmux ─────────────────────────────────────────────────────────────────────
|
||||||
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
|
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
|
||||||
# machine state rather than configuration. Running servers are re-sourced so an
|
# machine state rather than configuration. Running servers are re-sourced so an
|
||||||
@@ -146,4 +199,5 @@ jq -cn \
|
|||||||
--arg gtk "$status_gtk" \
|
--arg gtk "$status_gtk" \
|
||||||
--arg btop "$status_btop" \
|
--arg btop "$status_btop" \
|
||||||
--arg tmux "$status_tmux" \
|
--arg tmux "$status_tmux" \
|
||||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
|
--arg hyprlock "$status_hyprlock" \
|
||||||
|
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
|
||||||
|
|||||||
@@ -25,25 +25,14 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property string cursorTheme: ""
|
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0
|
readonly property bool busy: runner.running || root.pending.length > 0
|
||||||
|
|
||||||
|
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||||
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
||||||
readonly property real textScale: DesktopPreferences.get("textScale")
|
readonly property real textScale: DesktopPreferences.get("textScale")
|
||||||
|
|
||||||
Process {
|
|
||||||
id: themeQuery
|
|
||||||
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
// gsettings quotes strings: 'oreo_blue_cursors'
|
|
||||||
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A short queue, because applying one setting takes several commands and
|
// A short queue, because applying one setting takes several commands and
|
||||||
// Process runs one at a time.
|
// Process runs one at a time.
|
||||||
property var pending: []
|
property var pending: []
|
||||||
@@ -71,7 +60,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
themeQuery.running = true;
|
|
||||||
settle.restart();
|
settle.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +90,6 @@ Singleton {
|
|||||||
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
||||||
];
|
];
|
||||||
// setcursor needs a theme name; skip it rather than guess if gsettings
|
|
||||||
// has not answered yet. The next change will catch up.
|
|
||||||
if (root.cursorTheme !== "")
|
|
||||||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||||
root.enqueue(commands);
|
root.enqueue(commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import Quickshell
|
|||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
|
import "AudioStreams.js" as AudioStreams
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
@@ -18,6 +20,13 @@ Singleton {
|
|||||||
!node.isStream
|
!node.isStream
|
||||||
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
|
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
|
||||||
|
|
||||||
|
readonly property var playbackStreams: Pipewire.nodes.values.filter(node =>
|
||||||
|
node.ready && node.audio
|
||||||
|
&& (node.type & PwNodeType.AudioOutStream) === PwNodeType.AudioOutStream)
|
||||||
|
|
||||||
|
readonly property var applications: AudioStreams.group(
|
||||||
|
root.playbackStreams, PwNodeType.AudioOutStream)
|
||||||
|
|
||||||
function nodes(output: bool): var {
|
function nodes(output: bool): var {
|
||||||
return output ? root.outputs : root.inputs;
|
return output ? root.outputs : root.inputs;
|
||||||
}
|
}
|
||||||
@@ -40,4 +49,20 @@ Singleton {
|
|||||||
return "Unknown device";
|
return "Unknown device";
|
||||||
return node.description || node.nickname || node.name || "Unknown device";
|
return node.description || node.nickname || node.name || "Unknown device";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applicationVolume(application: var): real {
|
||||||
|
return AudioStreams.volume(application);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applicationMuted(application: var): bool {
|
||||||
|
return AudioStreams.muted(application);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApplicationVolume(application: var, value: real): bool {
|
||||||
|
return AudioStreams.setVolume(application, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApplicationMuted(application: var, muted: bool): bool {
|
||||||
|
return AudioStreams.setMuted(application, muted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
function property(node, key) {
|
||||||
|
const value = node && node.properties ? node.properties[key] : "";
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupKey(node) {
|
||||||
|
return property(node, "application.id")
|
||||||
|
|| property(node, "application.process.binary")
|
||||||
|
|| property(node, "application.name")
|
||||||
|
|| `node:${node.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(node) {
|
||||||
|
return property(node, "application.name")
|
||||||
|
|| String(node.description || "").trim()
|
||||||
|
|| property(node, "media.name")
|
||||||
|
|| "Unknown application";
|
||||||
|
}
|
||||||
|
|
||||||
|
function icon(node) {
|
||||||
|
return property(node, "application.icon_name")
|
||||||
|
|| "audio-x-generic-symbolic";
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(nodes, audioOutStreamFlag) {
|
||||||
|
const groups = [];
|
||||||
|
const byKey = {};
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
if (!node || node.ready !== true || !node.audio
|
||||||
|
|| (node.type & audioOutStreamFlag) !== audioOutStreamFlag)
|
||||||
|
continue;
|
||||||
|
const key = groupKey(node);
|
||||||
|
if (!byKey[key]) {
|
||||||
|
byKey[key] = { key, label: label(node), icon: icon(node), nodes: [] };
|
||||||
|
groups.push(byKey[key]);
|
||||||
|
}
|
||||||
|
byKey[key].nodes.push(node);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioNodes(application) {
|
||||||
|
return (application && application.nodes || []).filter(node => node && node.audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
function volume(application) {
|
||||||
|
const nodes = audioNodes(application);
|
||||||
|
return nodes.length === 0 ? 0
|
||||||
|
: nodes.reduce((sum, node) => sum + node.audio.volume, 0) / nodes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function muted(application) {
|
||||||
|
const nodes = audioNodes(application);
|
||||||
|
return nodes.length > 0 && nodes.every(node => node.audio.muted === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(application, value) {
|
||||||
|
const next = Math.max(0, Math.min(1, Number(value)));
|
||||||
|
if (!Number.isFinite(next)) return false;
|
||||||
|
const nodes = audioNodes(application);
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.audio.muted = false;
|
||||||
|
node.audio.volume = next;
|
||||||
|
}
|
||||||
|
return nodes.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMuted(application, mutedValue) {
|
||||||
|
const nodes = audioNodes(application);
|
||||||
|
for (const node of nodes) node.audio.muted = mutedValue === true;
|
||||||
|
return nodes.length > 0;
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ Singleton {
|
|||||||
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||||
|
|
||||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||||
|
// A neutral contrast role, not an accent. The focused Prism border belongs
|
||||||
|
// to the visual theme and must remain untouched when this role changes.
|
||||||
|
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
||||||
|
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
||||||
|
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
// Applied one command at a time: Process runs a single command, and several
|
// Applied one command at a time: Process runs a single command, and several
|
||||||
@@ -99,12 +104,10 @@ Singleton {
|
|||||||
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
||||||
];
|
];
|
||||||
|
|
||||||
// Unfocused window borders. The focused border is the prism gradient and
|
// Unfocused window borders need scheme-relative contrast. The focused
|
||||||
// is already scheme-independent; the inactive one is a flat neutral that
|
// Prism border is deliberately owned by the accent/theme layer.
|
||||||
// would be invisible against the opposite background.
|
|
||||||
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
|
|
||||||
commands.push(["hyprctl", "eval",
|
commands.push(["hyprctl", "eval",
|
||||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||||
|
|
||||||
// Applications that predate org.freedesktop.appearance and carry their
|
// Applications that predate org.freedesktop.appearance and carry their
|
||||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||||
|
|||||||
@@ -98,5 +98,16 @@ Singleton {
|
|||||||
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addAutostart(desktopId: string): void {
|
||||||
|
if (root.busy)
|
||||||
|
return;
|
||||||
|
if (!root.knownDesktopId(desktopId)) {
|
||||||
|
root.lastError = "Choose an installed application.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
mutationProcess.exec([root.helper, "add-autostart", desktopId]);
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: root.refresh()
|
Component.onCompleted: root.refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Application-facing desktop style.
|
||||||
|
//
|
||||||
|
// Panama owns the durable choices; gsettings is an output boundary for GTK
|
||||||
|
// and applications that follow GNOME's desktop schemas. Commands are arrays,
|
||||||
|
// values are validated before storage, and no user text is ever sent through a
|
||||||
|
// shell. Hyprland's pointer setting stays live through Accessibility.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style"
|
||||||
|
|
||||||
|
// SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
|
||||||
|
// a separate allow-list so a caller cannot smuggle a display label into a
|
||||||
|
// stored theme name.
|
||||||
|
property var cursorThemes: []
|
||||||
|
property var iconThemes: []
|
||||||
|
property var cursorThemeNames: []
|
||||||
|
property var iconThemeNames: []
|
||||||
|
property bool catalogLoaded: false
|
||||||
|
property bool scanning: false
|
||||||
|
property bool startupApplied: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
property var pending: []
|
||||||
|
readonly property bool busy: root.scanning || catalogProcess.running
|
||||||
|
|| runner.running || root.pending.length > 0
|
||||||
|
readonly property int preferenceRevision: DesktopPreferences.revision
|
||||||
|
|
||||||
|
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||||
|
readonly property string iconTheme: DesktopPreferences.get("iconTheme")
|
||||||
|
readonly property string applicationFont: DesktopPreferences.get("applicationFont")
|
||||||
|
readonly property string documentFont: DesktopPreferences.get("documentFont")
|
||||||
|
readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: catalogProcess
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.acceptCatalog(this.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.scanning = false;
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.catalogLoaded = false;
|
||||||
|
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: runner
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "One desktop style setting could not be applied.";
|
||||||
|
root.drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.ensureStarted();
|
||||||
|
// Accessing the singleton here keeps its existing hyprctl setcursor
|
||||||
|
// path alive for cursor-theme changes as well as cursor-size changes.
|
||||||
|
Accessibility.applyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: startupApply
|
||||||
|
interval: 1200
|
||||||
|
onTriggered: {
|
||||||
|
root.startupApplied = true;
|
||||||
|
root.applyAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: DesktopPreferences
|
||||||
|
function onRevisionChanged(): void { applyCoalesce.restart(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: applyCoalesce
|
||||||
|
interval: 180
|
||||||
|
onTriggered: root.applyAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStarted(): void {
|
||||||
|
if (!root.catalogLoaded && !root.scanning)
|
||||||
|
root.refreshCatalog();
|
||||||
|
if (!root.startupApplied && !startupApply.running)
|
||||||
|
startupApply.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCatalog(): void {
|
||||||
|
if (catalogProcess.running)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
catalogProcess.exec([root.helperPath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptCatalog(text: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
|
||||||
|
throw new Error("invalid catalog shape");
|
||||||
|
|
||||||
|
const cursors = parsed.cursorThemes.filter(name =>
|
||||||
|
typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
|
||||||
|
const icons = parsed.iconThemes.filter(name =>
|
||||||
|
typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
|
||||||
|
root.cursorThemeNames = cursors;
|
||||||
|
root.iconThemeNames = icons;
|
||||||
|
root.cursorThemes = cursors.map(name => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
detail: "Pointer theme"
|
||||||
|
}));
|
||||||
|
root.iconThemes = icons.map(name => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
detail: "Application icon theme"
|
||||||
|
}));
|
||||||
|
root.catalogLoaded = true;
|
||||||
|
root.lastError = "";
|
||||||
|
} catch (error) {
|
||||||
|
root.cursorThemes = [];
|
||||||
|
root.iconThemes = [];
|
||||||
|
root.cursorThemeNames = [];
|
||||||
|
root.iconThemeNames = [];
|
||||||
|
root.catalogLoaded = false;
|
||||||
|
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||||
|
}
|
||||||
|
root.scanning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain(): void {
|
||||||
|
if (runner.running || root.pending.length === 0)
|
||||||
|
return;
|
||||||
|
const next = root.pending[0];
|
||||||
|
root.pending = root.pending.slice(1);
|
||||||
|
runner.exec(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(commands: var): void {
|
||||||
|
// A fresh revision supersedes commands that have not started yet. The
|
||||||
|
// currently running command is allowed to finish, then the newest full
|
||||||
|
// state is replayed in a deterministic order.
|
||||||
|
root.pending = commands;
|
||||||
|
root.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
|
||||||
|
// quote, backslash, and control character, and the schema patterns further
|
||||||
|
// constrain stored font/theme names. Arguments still travel directly to
|
||||||
|
// gsettings rather than through a shell.
|
||||||
|
function gvariant(value: var): string {
|
||||||
|
if (typeof value === "boolean")
|
||||||
|
return value ? "true" : "false";
|
||||||
|
if (typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
return JSON.stringify(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontName(familyKey: string, sizeKey: string): string {
|
||||||
|
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonLayout(): string {
|
||||||
|
const side = DesktopPreferences.get("titlebarButtonSide");
|
||||||
|
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
|
||||||
|
|
||||||
|
// Tokens are fixed. Only their side and whether maximize is present
|
||||||
|
// vary, so preference data can never become command syntax.
|
||||||
|
if (side === "left")
|
||||||
|
return (maximize ? "close,maximize" : "close") + ":appmenu";
|
||||||
|
return "appmenu:" + (maximize ? "maximize,close" : "close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setting(schema: string, key: string, value: var): var {
|
||||||
|
return ["gsettings", "set", schema, key, root.gvariant(value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAll(): void {
|
||||||
|
root.lastError = "";
|
||||||
|
root.enqueue([
|
||||||
|
root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
|
||||||
|
root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-name",
|
||||||
|
root.fontName("applicationFont", "applicationFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "document-font-name",
|
||||||
|
root.fontName("documentFont", "documentFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "monospace-font-name",
|
||||||
|
root.fontName("monospaceFont", "monospaceFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-hinting",
|
||||||
|
DesktopPreferences.get("fontHinting")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-antialiasing",
|
||||||
|
DesktopPreferences.get("fontAntialiasing")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
|
||||||
|
DesktopPreferences.get("middleClickPaste")),
|
||||||
|
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||||
|
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
||||||
|
DesktopPreferences.get("titlebarDoubleClick"))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
|
||||||
|
if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
|
||||||
|
root.lastError = `That ${kind} theme is not installed.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!DesktopPreferences.set(key, value)) {
|
||||||
|
root.lastError = `That ${kind} theme name could not be saved.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCursorTheme(value: string): bool {
|
||||||
|
return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIconTheme(value: string): bool {
|
||||||
|
return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeFont(key: string, family: string, allowed: var, kind: string): bool {
|
||||||
|
if (allowed.indexOf(family) < 0) {
|
||||||
|
root.lastError = `That ${kind} font is not installed.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!DesktopPreferences.set(key, family)) {
|
||||||
|
root.lastError = `That ${kind} font name could not be saved.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApplicationFont(family: string): bool {
|
||||||
|
return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDocumentFont(family: string): bool {
|
||||||
|
return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMonospaceFont(family: string): bool {
|
||||||
|
return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
function logicalSize(record) {
|
||||||
|
if (!record)
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
|
||||||
|
const width = Number(record.width);
|
||||||
|
const height = Number(record.height);
|
||||||
|
const scale = Number(record.scale);
|
||||||
|
const transform = Number(record.transform);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height)
|
||||||
|
|| !Number.isFinite(scale) || scale <= 0)
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
|
||||||
|
const rotated = transform === 1 || transform === 3;
|
||||||
|
return {
|
||||||
|
width: (rotated ? height : width) / scale,
|
||||||
|
height: (rotated ? width : height) / scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCoordinate(value) {
|
||||||
|
return Number.isFinite(value) && Number.isInteger(value)
|
||||||
|
&& value >= -100000 && value <= 100000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(layout) {
|
||||||
|
if (!Array.isArray(layout) || layout.length === 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const names = {};
|
||||||
|
let primaryCount = 0;
|
||||||
|
for (const record of layout) {
|
||||||
|
if (!record || typeof record.name !== "string"
|
||||||
|
|| !/^[A-Za-z0-9_.-]+$/.test(record.name)
|
||||||
|
|| names[record.name])
|
||||||
|
return false;
|
||||||
|
names[record.name] = true;
|
||||||
|
|
||||||
|
if (!Number.isFinite(record.width) || record.width <= 0
|
||||||
|
|| !Number.isFinite(record.height) || record.height <= 0
|
||||||
|
|| !Number.isFinite(record.scale) || record.scale <= 0
|
||||||
|
|| !Number.isInteger(record.transform)
|
||||||
|
|| record.transform < 0 || record.transform > 3
|
||||||
|
|| !validCoordinate(record.x) || !validCoordinate(record.y)
|
||||||
|
|| typeof record.primary !== "boolean")
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const size = logicalSize(record);
|
||||||
|
if (!Number.isFinite(size.width) || size.width <= 0
|
||||||
|
|| !Number.isFinite(size.height) || size.height <= 0)
|
||||||
|
return false;
|
||||||
|
if (record.primary)
|
||||||
|
primaryCount += 1;
|
||||||
|
}
|
||||||
|
return primaryCount === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneLayout(layout) {
|
||||||
|
return (layout || []).map(record => Object.assign({}, record));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(layout) {
|
||||||
|
const result = cloneLayout(layout);
|
||||||
|
const primary = result.find(record => record.primary === true);
|
||||||
|
if (!primary)
|
||||||
|
return result;
|
||||||
|
const anchorX = primary.x;
|
||||||
|
const anchorY = primary.y;
|
||||||
|
for (const record of result) {
|
||||||
|
record.x -= anchorX;
|
||||||
|
record.y -= anchorY;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bounds(layout) {
|
||||||
|
if (!Array.isArray(layout) || layout.length === 0)
|
||||||
|
return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
|
let left = Infinity;
|
||||||
|
let top = Infinity;
|
||||||
|
let right = -Infinity;
|
||||||
|
let bottom = -Infinity;
|
||||||
|
for (const record of layout) {
|
||||||
|
const size = logicalSize(record);
|
||||||
|
left = Math.min(left, record.x);
|
||||||
|
top = Math.min(top, record.y);
|
||||||
|
right = Math.max(right, record.x + size.width);
|
||||||
|
bottom = Math.max(bottom, record.y + size.height);
|
||||||
|
}
|
||||||
|
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap(layout, movingName, threshold) {
|
||||||
|
const result = cloneLayout(layout);
|
||||||
|
const moving = result.find(record => record.name === movingName);
|
||||||
|
if (!moving)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16;
|
||||||
|
const movingSize = logicalSize(moving);
|
||||||
|
const movingXEdges = [moving.x, moving.x + movingSize.width];
|
||||||
|
const movingYEdges = [moving.y, moving.y + movingSize.height];
|
||||||
|
const stationary = result
|
||||||
|
.filter(record => record.name !== movingName)
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
|
let bestX = null;
|
||||||
|
let bestY = null;
|
||||||
|
for (const record of stationary) {
|
||||||
|
const size = logicalSize(record);
|
||||||
|
const xEdges = [record.x, record.x + size.width];
|
||||||
|
const yEdges = [record.y, record.y + size.height];
|
||||||
|
for (const source of movingXEdges) {
|
||||||
|
for (const target of xEdges) {
|
||||||
|
const delta = target - source;
|
||||||
|
const distance = Math.abs(delta);
|
||||||
|
if (distance <= limit && (bestX === null || distance < bestX.distance))
|
||||||
|
bestX = { delta, distance };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const source of movingYEdges) {
|
||||||
|
for (const target of yEdges) {
|
||||||
|
const delta = target - source;
|
||||||
|
const distance = Math.abs(delta);
|
||||||
|
if (distance <= limit && (bestY === null || distance < bestY.distance))
|
||||||
|
bestY = { delta, distance };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestX !== null)
|
||||||
|
moving.x += bestX.delta;
|
||||||
|
if (bestY !== null)
|
||||||
|
moving.y += bestY.delta;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasRects(layout, canvasWidth, canvasHeight, padding) {
|
||||||
|
const desktopBounds = bounds(layout);
|
||||||
|
const inset = Math.max(0, Number(padding) || 0);
|
||||||
|
const availableWidth = Math.max(0, Number(canvasWidth) - inset * 2);
|
||||||
|
const availableHeight = Math.max(0, Number(canvasHeight) - inset * 2);
|
||||||
|
const scale = desktopBounds.width > 0 && desktopBounds.height > 0
|
||||||
|
? Math.min(availableWidth / desktopBounds.width,
|
||||||
|
availableHeight / desktopBounds.height)
|
||||||
|
: 0;
|
||||||
|
const contentWidth = desktopBounds.width * scale;
|
||||||
|
const contentHeight = desktopBounds.height * scale;
|
||||||
|
const originX = inset + (availableWidth - contentWidth) / 2;
|
||||||
|
const originY = inset + (availableHeight - contentHeight) / 2;
|
||||||
|
return {
|
||||||
|
bounds: desktopBounds,
|
||||||
|
scale,
|
||||||
|
rects: (layout || []).map(record => {
|
||||||
|
const size = logicalSize(record);
|
||||||
|
return {
|
||||||
|
name: record.name,
|
||||||
|
x: originX + (record.x - desktopBounds.x) * scale,
|
||||||
|
y: originY + (record.y - desktopBounds.y) * scale,
|
||||||
|
width: size.width * scale,
|
||||||
|
height: size.height * scale,
|
||||||
|
primary: record.primary === true
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import Quickshell
|
|||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import "DisplayLayout.js" as DisplayLayout
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
@@ -31,25 +32,25 @@ Singleton {
|
|||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
// Set while a change is applied but not yet confirmed.
|
// Set while a change is applied but not yet confirmed.
|
||||||
property string pendingOutput: ""
|
property var pendingPreviousLayout: null
|
||||||
property var pendingPrevious: null
|
property var pendingRequestedLayout: null
|
||||||
property var pendingRequested: null
|
|
||||||
property bool pendingVerified: false
|
property bool pendingVerified: false
|
||||||
property bool revertQueued: false
|
property bool revertQueued: false
|
||||||
property var revertExpected: null
|
property var revertExpectedLayout: null
|
||||||
property string revertReason: ""
|
property string revertReason: ""
|
||||||
property bool revertVerificationActive: false
|
property bool revertVerificationActive: false
|
||||||
property int operationGeneration: 0
|
property int operationGeneration: 0
|
||||||
property int revertGeneration: -1
|
property int revertGeneration: -1
|
||||||
property bool externalChangeBlocked: false
|
property bool externalChangeBlocked: false
|
||||||
property int secondsLeft: 0
|
property int secondsLeft: 0
|
||||||
|
property bool identifying: false
|
||||||
|
|
||||||
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
|
||||||
readonly property bool canConfirm: root.awaitingConfirmation
|
readonly property bool canConfirm: root.awaitingConfirmation
|
||||||
&& root.pendingVerified
|
&& root.pendingVerified
|
||||||
&& !root.busy
|
&& !root.busy
|
||||||
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
||||||
|| root.revertExpected !== null
|
|| root.revertExpectedLayout !== null
|
||||||
|
|
||||||
readonly property int confirmSeconds: 15
|
readonly property int confirmSeconds: 15
|
||||||
|
|
||||||
@@ -123,9 +124,24 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function identify(): void {
|
||||||
|
root.identifying = true;
|
||||||
|
identifyTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
function parse(text: string, generation: int): void {
|
function parse(text: string, generation: int): void {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(text);
|
const raw = JSON.parse(text);
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
const persisted = stored && typeof stored === "object" ? stored : {};
|
||||||
|
const persistedPrimaries = raw.filter(monitor => {
|
||||||
|
const entry = persisted[monitor.name ?? ""];
|
||||||
|
return root.isPersistedLayoutEntry(entry) && entry.primary === true;
|
||||||
|
});
|
||||||
|
const origin = raw.find(monitor => monitor.x === 0 && monitor.y === 0);
|
||||||
|
const primaryName = persistedPrimaries.length === 1
|
||||||
|
? persistedPrimaries[0].name
|
||||||
|
: (origin?.name ?? raw[0]?.name ?? "");
|
||||||
root.monitors = raw.map(monitor => {
|
root.monitors = raw.map(monitor => {
|
||||||
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
||||||
const width = monitor.width ?? 0;
|
const width = monitor.width ?? 0;
|
||||||
@@ -144,31 +160,35 @@ Singleton {
|
|||||||
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
||||||
scale: monitor.scale ?? 1,
|
scale: monitor.scale ?? 1,
|
||||||
transform: monitor.transform ?? 0,
|
transform: monitor.transform ?? 0,
|
||||||
|
x: Number.isInteger(monitor.x) ? monitor.x : 0,
|
||||||
|
y: Number.isInteger(monitor.y) ? monitor.y : 0,
|
||||||
|
primary: monitor.name === primaryName,
|
||||||
currentFormat: monitor.currentFormat ?? "",
|
currentFormat: monitor.currentFormat ?? "",
|
||||||
colorPreset: monitor.colorManagementPreset ?? "",
|
colorPreset: monitor.colorManagementPreset ?? "",
|
||||||
vrr: monitor.vrr === true,
|
vrr: monitor.vrr === true,
|
||||||
modes: modes
|
modes: modes
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (root.awaitingConfirmation && root.pendingRequested
|
if (root.awaitingConfirmation && root.pendingRequestedLayout
|
||||||
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
&& generation === root.operationGeneration
|
||||||
|
&& root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
|
||||||
root.pendingVerified = true;
|
root.pendingVerified = true;
|
||||||
verifyTimer.stop();
|
verifyTimer.stop();
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
} else if (root.revertVerificationActive
|
} else if (root.revertVerificationActive
|
||||||
&& generation === root.revertGeneration
|
&& generation === root.revertGeneration
|
||||||
&& root.revertExpected
|
&& root.revertExpectedLayout
|
||||||
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
&& root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
|
||||||
revertVerifyTimer.stop();
|
revertVerifyTimer.stop();
|
||||||
root.revertVerificationActive = false;
|
root.revertVerificationActive = false;
|
||||||
root.revertGeneration = -1;
|
root.revertGeneration = -1;
|
||||||
root.revertExpected = null;
|
root.revertExpectedLayout = null;
|
||||||
if (root.revertReason === "")
|
if (root.revertReason === "")
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
else
|
else
|
||||||
root.lastError = root.revertReason;
|
root.lastError = root.revertReason;
|
||||||
root.revertReason = "";
|
root.revertReason = "";
|
||||||
} else if (!root.awaitingConfirmation && !root.revertExpected && (
|
} else if (!root.awaitingConfirmation && !root.revertExpectedLayout && (
|
||||||
root.lastError === "Could not read the connected displays."
|
root.lastError === "Could not read the connected displays."
|
||||||
|| root.lastError === "The display list could not be read.")) {
|
|| root.lastError === "The display list could not be read.")) {
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
@@ -216,6 +236,17 @@ Singleton {
|
|||||||
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPersistedLayoutEntry(entry: var): bool {
|
||||||
|
return !!entry && typeof entry === "object"
|
||||||
|
&& root.modeParts(entry.mode) !== null
|
||||||
|
&& Number.isFinite(entry.scale) && entry.scale > 0
|
||||||
|
&& Number.isInteger(entry.transform)
|
||||||
|
&& entry.transform >= 0 && entry.transform <= 3
|
||||||
|
&& Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000
|
||||||
|
&& Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 100000
|
||||||
|
&& typeof entry.primary === "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
function modeParts(mode: string): var {
|
function modeParts(mode: string): var {
|
||||||
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||||
if (!match)
|
if (!match)
|
||||||
@@ -250,16 +281,41 @@ Singleton {
|
|||||||
choices[0]);
|
choices[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesRequest(monitor: var, requested: var): bool {
|
function currentLayout(): var {
|
||||||
if (!monitor || !requested || monitor.name !== requested.output)
|
return root.monitors.map(monitor => ({
|
||||||
|
name: monitor.name,
|
||||||
|
width: monitor.width,
|
||||||
|
height: monitor.height,
|
||||||
|
refreshRate: monitor.refreshRate,
|
||||||
|
mode: monitor.mode,
|
||||||
|
scale: monitor.scale,
|
||||||
|
transform: monitor.transform,
|
||||||
|
x: monitor.x,
|
||||||
|
y: monitor.y,
|
||||||
|
primary: monitor.primary === true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesLayout(monitors: var, layout: var): bool {
|
||||||
|
if (!Array.isArray(monitors) || !Array.isArray(layout)
|
||||||
|
|| monitors.length !== layout.length)
|
||||||
return false;
|
return false;
|
||||||
|
const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (let index = 0; index < expected.length; index++) {
|
||||||
|
const requested = expected[index];
|
||||||
|
const monitor = actual[index];
|
||||||
const parts = root.modeParts(requested.mode);
|
const parts = root.modeParts(requested.mode);
|
||||||
return !!parts
|
if (!parts || monitor.name !== requested.name
|
||||||
&& monitor.width === parts.width
|
|| monitor.width !== parts.width
|
||||||
&& monitor.height === parts.height
|
|| monitor.height !== parts.height
|
||||||
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
|| Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
|
||||||
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
|| Math.abs(monitor.scale - requested.scale) >= 0.001
|
||||||
&& monitor.transform === requested.transform;
|
|| monitor.transform !== requested.transform
|
||||||
|
|| monitor.x !== requested.x || monitor.y !== requested.y)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeIsCurrent(monitor: var, candidate: var): bool {
|
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||||
@@ -269,10 +325,48 @@ Singleton {
|
|||||||
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
function validRequestedLayout(layout: var): bool {
|
||||||
// settings file is only written by confirm().
|
if (!DisplayLayout.validate(layout) || layout.length !== root.monitors.length)
|
||||||
|
return false;
|
||||||
|
const currentNames = root.monitors.map(monitor => monitor.name).sort();
|
||||||
|
const requestedNames = layout.map(record => record.name).sort();
|
||||||
|
if (JSON.stringify(currentNames) !== JSON.stringify(requestedNames))
|
||||||
|
return false;
|
||||||
|
return layout.every(record => {
|
||||||
|
const monitor = root.monitorNamed(record.name);
|
||||||
|
const parts = root.modeParts(record.mode);
|
||||||
|
return !!monitor && !!parts
|
||||||
|
&& record.width === parts.width && record.height === parts.height
|
||||||
|
&& monitor.modes.some(candidate => candidate.mode === record.mode)
|
||||||
|
&& root.isScaleClean(record.mode, record.scale)
|
||||||
|
&& root.transforms.some(candidate => candidate.value === record.transform);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-field controls remain callers of the complete-layout transaction.
|
||||||
|
// Their edit is cloned into the current layout so every output's position
|
||||||
|
// participates in apply, verification, and rollback.
|
||||||
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||||
if (root.externalChangeBlocked) {
|
const layout = root.currentLayout();
|
||||||
|
const record = layout.find(candidate => candidate.name === output);
|
||||||
|
const parts = root.modeParts(mode);
|
||||||
|
if (!record || !parts) {
|
||||||
|
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
record.mode = mode;
|
||||||
|
record.width = parts.width;
|
||||||
|
record.height = parts.height;
|
||||||
|
record.refreshRate = parts.refresh;
|
||||||
|
record.scale = scale;
|
||||||
|
record.transform = transform;
|
||||||
|
return root.applyLayout(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||||
|
// complete connected layout is only written by confirm().
|
||||||
|
function applyLayout(layout: var, protectedOperation: bool): bool {
|
||||||
|
if (root.externalChangeBlocked && protectedOperation !== true) {
|
||||||
root.lastError = "Wait for Settings to finish restoring before changing a display.";
|
root.lastError = "Wait for Settings to finish restoring before changing a display.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -284,58 +378,53 @@ Singleton {
|
|||||||
root.lastError = "Finish the current display change first.";
|
root.lastError = "Finish the current display change first.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const monitor = root.monitorNamed(output);
|
const normalized = DisplayLayout.normalize(layout);
|
||||||
if (!monitor) {
|
if (!root.validRequestedLayout(normalized)) {
|
||||||
root.lastError = "That display is not connected.";
|
root.lastError = "That complete display layout is not valid for the connected displays.";
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
|
||||||
root.lastError = "That display does not offer that mode.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!root.isScaleClean(mode, scale)) {
|
|
||||||
root.lastError = "That scale does not divide this resolution cleanly.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
|
||||||
root.lastError = "That rotation is not one Panama offers.";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
root.pendingPrevious = {
|
root.pendingPreviousLayout = root.currentLayout();
|
||||||
output: output,
|
|
||||||
mode: monitor.mode,
|
|
||||||
scale: monitor.scale,
|
|
||||||
transform: monitor.transform
|
|
||||||
};
|
|
||||||
root.operationGeneration++;
|
root.operationGeneration++;
|
||||||
root.pendingRequested = {
|
root.pendingRequestedLayout = normalized;
|
||||||
output: output,
|
|
||||||
mode: mode,
|
|
||||||
scale: scale,
|
|
||||||
transform: transform
|
|
||||||
};
|
|
||||||
root.pendingOutput = output;
|
|
||||||
root.pendingVerified = false;
|
root.pendingVerified = false;
|
||||||
root.revertQueued = false;
|
root.revertQueued = false;
|
||||||
root.secondsLeft = root.confirmSeconds;
|
root.secondsLeft = root.confirmSeconds;
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
countdown.restart();
|
countdown.restart();
|
||||||
|
|
||||||
root.push(output, mode, scale, transform);
|
root.pushLayout(normalized, applyRun);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function push(output: string, mode: string, scale: real, transform: int): void {
|
// Settings restore holds the external-change lock while it proves a
|
||||||
// Values are validated above and the output name comes from the
|
// snapshot. This narrow entry point authorizes that one transaction while
|
||||||
// compositor's own list, so nothing user-authored reaches the payload.
|
// keeping every user-facing control blocked until restore settles.
|
||||||
applyRun.exec(["hyprctl", "eval",
|
function applyProtectedLayout(layout: var): bool {
|
||||||
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
return root.applyLayout(layout, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePrimary(output: string): bool {
|
||||||
|
const layout = root.currentLayout();
|
||||||
|
if (!layout.some(record => record.name === output)) {
|
||||||
|
root.lastError = "That display is not connected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const record of layout)
|
||||||
|
record.primary = record.name === output;
|
||||||
|
return root.applyLayout(DisplayLayout.normalize(layout));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLayout(layout: var, runner: var): void {
|
||||||
|
const payload = layout.map(record =>
|
||||||
|
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
|
||||||
|
).join("; ");
|
||||||
|
runner.exec(["hyprctl", "eval", payload]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirm(): bool {
|
function confirm(): bool {
|
||||||
if (!root.canConfirm || !root.matchesRequest(
|
if (!root.canConfirm
|
||||||
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
|| !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
|
||||||
if (root.awaitingConfirmation)
|
if (root.awaitingConfirmation)
|
||||||
root.lastError = "Wait for the display to finish applying before keeping it.";
|
root.lastError = "Wait for the display to finish applying before keeping it.";
|
||||||
return false;
|
return false;
|
||||||
@@ -343,11 +432,16 @@ Singleton {
|
|||||||
|
|
||||||
const stored = DesktopPreferences.get("displays");
|
const stored = DesktopPreferences.get("displays");
|
||||||
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||||
next[root.pendingOutput] = {
|
for (const record of root.pendingRequestedLayout) {
|
||||||
mode: root.pendingRequested.mode,
|
next[record.name] = {
|
||||||
scale: root.pendingRequested.scale,
|
mode: record.mode,
|
||||||
transform: root.pendingRequested.transform
|
scale: record.scale,
|
||||||
|
transform: record.transform,
|
||||||
|
x: record.x,
|
||||||
|
y: record.y,
|
||||||
|
primary: record.primary
|
||||||
};
|
};
|
||||||
|
}
|
||||||
if (!DesktopPreferences.set("displays", next)) {
|
if (!DesktopPreferences.set("displays", next)) {
|
||||||
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||||
return false;
|
return false;
|
||||||
@@ -361,9 +455,8 @@ Singleton {
|
|||||||
function clearPending(): void {
|
function clearPending(): void {
|
||||||
countdown.stop();
|
countdown.stop();
|
||||||
verifyTimer.stop();
|
verifyTimer.stop();
|
||||||
root.pendingOutput = "";
|
root.pendingPreviousLayout = null;
|
||||||
root.pendingPrevious = null;
|
root.pendingRequestedLayout = null;
|
||||||
root.pendingRequested = null;
|
|
||||||
root.pendingVerified = false;
|
root.pendingVerified = false;
|
||||||
root.revertQueued = false;
|
root.revertQueued = false;
|
||||||
root.secondsLeft = 0;
|
root.secondsLeft = 0;
|
||||||
@@ -390,16 +483,25 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function performRevert(): void {
|
function performRevert(): void {
|
||||||
const previous = root.pendingPrevious;
|
const connected = {};
|
||||||
|
for (const monitor of root.monitors)
|
||||||
|
connected[monitor.name] = true;
|
||||||
|
const previous = (root.pendingPreviousLayout || [])
|
||||||
|
.filter(record => connected[record.name])
|
||||||
|
.map(record => Object.assign({}, record));
|
||||||
|
if (previous.length > 0 && !previous.some(record => record.primary)) {
|
||||||
|
const origin = previous.find(record => record.x === 0 && record.y === 0);
|
||||||
|
(origin || previous[0]).primary = true;
|
||||||
|
}
|
||||||
root.operationGeneration++;
|
root.operationGeneration++;
|
||||||
root.revertGeneration = root.operationGeneration;
|
root.revertGeneration = root.operationGeneration;
|
||||||
root.revertExpected = previous;
|
root.revertExpectedLayout = previous.length > 0 ? previous : null;
|
||||||
root.revertVerificationActive = false;
|
root.revertVerificationActive = false;
|
||||||
root.clearPending();
|
root.clearPending();
|
||||||
if (previous) {
|
if (previous.length > 0)
|
||||||
revertRun.exec(["hyprctl", "eval",
|
root.pushLayout(previous, revertRun);
|
||||||
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
else
|
||||||
}
|
root.lastError = root.revertReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clears any stored override for an output so it returns to the value
|
// Clears any stored override for an output so it returns to the value
|
||||||
@@ -418,6 +520,26 @@ Singleton {
|
|||||||
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verificationTimedOut(): void {
|
||||||
|
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertVerificationTimedOut(): void {
|
||||||
|
revertVerifyTimer.stop();
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.revertGeneration = -1;
|
||||||
|
root.revertExpectedLayout = null;
|
||||||
|
root.revertReason = "";
|
||||||
|
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: identifyTimer
|
||||||
|
interval: 3000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.identifying = false
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: verifyTimer
|
id: verifyTimer
|
||||||
property int attempts: 0
|
property int attempts: 0
|
||||||
@@ -427,7 +549,7 @@ Singleton {
|
|||||||
onTriggered: {
|
onTriggered: {
|
||||||
ticks++;
|
ticks++;
|
||||||
if (ticks > 50) {
|
if (ticks > 50) {
|
||||||
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
root.verificationTimedOut();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (root.refresh())
|
if (root.refresh())
|
||||||
@@ -444,12 +566,7 @@ Singleton {
|
|||||||
onTriggered: {
|
onTriggered: {
|
||||||
ticks++;
|
ticks++;
|
||||||
if (ticks > 50) {
|
if (ticks > 50) {
|
||||||
stop();
|
root.revertVerificationTimedOut();
|
||||||
root.revertVerificationActive = false;
|
|
||||||
root.revertGeneration = -1;
|
|
||||||
root.revertExpected = null;
|
|
||||||
root.revertReason = "";
|
|
||||||
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (root.refresh())
|
if (root.refresh())
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,20 +41,28 @@ Singleton {
|
|||||||
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
||||||
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
||||||
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
||||||
|
property var readLiveDisplayLayout: function() { return Displays.currentLayout(); }
|
||||||
|
property var applyDisplayLayout: function(layout) { return Displays.applyProtectedLayout(layout); }
|
||||||
|
property var displayCanConfirm: function() { return Displays.canConfirm; }
|
||||||
|
property var confirmDisplayLayout: function() { return Displays.confirm(); }
|
||||||
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
||||||
|
property var applyIdle: function() { IdleLock.apply(); }
|
||||||
|
property var idleBusy: function() { return IdleLock.busy; }
|
||||||
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
||||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||||
property var systemBusy: function() { return SystemSettings.busy; }
|
property var systemBusy: function() { return SystemSettings.busy; }
|
||||||
property var currentWallpaper: function() {
|
property var applyWallpaperPolicy: function() { Wallpaper.applyCurrentPolicy(false); }
|
||||||
return String(DesktopPreferences.get("wallpaperPath") ?? "");
|
property var wallpaperBusy: function() { return Wallpaper.busy; }
|
||||||
}
|
property var regenerateLock: function() { LockScreen.regenerate(); }
|
||||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
property var lockBusy: function() { return LockScreen.busy; }
|
||||||
property var reloadShell: function() { Quickshell.reload(false); }
|
property var reloadShell: function() { Quickshell.reload(false); }
|
||||||
property var protectedDisplays: ({})
|
property var protectedDisplays: ({})
|
||||||
|
property var protectedDisplayLayout: []
|
||||||
|
property var pendingRestoredLayout: null
|
||||||
|
|
||||||
readonly property bool busy: listQuery.running || actionRun.running
|
readonly property bool busy: listQuery.running || actionRun.running
|
||||||
|| applyRestoredState.running || settleReload.running
|
|| settleDisplayRestore.running || applyRestoredState.running || settleReload.running
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: listQuery
|
id: listQuery
|
||||||
@@ -89,18 +97,21 @@ Singleton {
|
|||||||
if (actionRun.restoring) {
|
if (actionRun.restoring) {
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
root.protectedDisplays = ({});
|
root.protectedDisplays = ({});
|
||||||
|
root.protectedDisplayLayout = [];
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||||
if (actionRun.restoring) {
|
if (actionRun.restoring) {
|
||||||
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
|
const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
|
||||||
root.lastError = homeReloaded
|
if (restoreAccepted)
|
||||||
? ""
|
root.lastError = "";
|
||||||
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
else if (root.lastError === "")
|
||||||
if (!homeReloaded) {
|
root.lastError = "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||||
|
if (!restoreAccepted) {
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
root.protectedDisplays = ({});
|
root.protectedDisplays = ({});
|
||||||
|
root.protectedDisplayLayout = [];
|
||||||
}
|
}
|
||||||
} else
|
} else
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
@@ -108,6 +119,28 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: settleDisplayRestore
|
||||||
|
property int attempts: 0
|
||||||
|
interval: 100
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
attempts++;
|
||||||
|
if (root.displayCanConfirm()) {
|
||||||
|
stop();
|
||||||
|
if (!root.confirmDisplayLayout()) {
|
||||||
|
root.failDisplayRestore("The restored display layout could not be confirmed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.pendingRestoredLayout = null;
|
||||||
|
root.beginRestoredStateReplay();
|
||||||
|
} else if (!root.displayBusy() || attempts >= 180) {
|
||||||
|
stop();
|
||||||
|
root.failDisplayRestore("The restored display layout could not be verified.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: applyRestoredState
|
id: applyRestoredState
|
||||||
interval: 80
|
interval: 80
|
||||||
@@ -116,9 +149,11 @@ Singleton {
|
|||||||
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
||||||
// These services also own state outside QML and need an explicit
|
// These services also own state outside QML and need an explicit
|
||||||
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
||||||
root.applyCompositor();
|
root.applyIdle();
|
||||||
|
root.regenerateLock();
|
||||||
|
root.applyWallpaperPolicy();
|
||||||
root.reloadKeybinds();
|
root.reloadKeybinds();
|
||||||
root.applyWallpaper(root.currentWallpaper());
|
root.applyCompositor();
|
||||||
|
|
||||||
settleReload.attempts = 0;
|
settleReload.attempts = 0;
|
||||||
settleReload.restart();
|
settleReload.restart();
|
||||||
@@ -135,10 +170,12 @@ Singleton {
|
|||||||
// Let the current instances finish their external writes before a
|
// Let the current instances finish their external writes before a
|
||||||
// soft reload replaces them. The cap keeps a failed external tool
|
// soft reload replaces them. The cap keeps a failed external tool
|
||||||
// from leaving restored Home state stale indefinitely.
|
// from leaving restored Home state stale indefinitely.
|
||||||
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
if ((!root.idleBusy() && !root.keybindsReloading() && !root.systemBusy()
|
||||||
|
&& !root.wallpaperBusy() && !root.lockBusy()) || attempts >= 30) {
|
||||||
stop();
|
stop();
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
root.protectedDisplays = ({});
|
root.protectedDisplays = ({});
|
||||||
|
root.protectedDisplayLayout = [];
|
||||||
root.reloadShell();
|
root.reloadShell();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,11 +214,76 @@ Singleton {
|
|||||||
if (!root.reloadHomeState(text))
|
if (!root.reloadHomeState(text))
|
||||||
return false;
|
return false;
|
||||||
root.reloadDesktop();
|
root.reloadDesktop();
|
||||||
if (!root.protectDisplays(root.protectedDisplays))
|
const restoredLayout = root.layoutFromStoredDisplays(root.readDisplays());
|
||||||
return false;
|
if (restoredLayout === null || root.layoutsEqual(
|
||||||
applyRestoredState.restart();
|
restoredLayout, root.protectedDisplayLayout)) {
|
||||||
|
root.beginRestoredStateReplay();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
root.pendingRestoredLayout = restoredLayout;
|
||||||
|
if (!root.applyDisplayLayout(restoredLayout))
|
||||||
|
return root.failDisplayRestore("The restored display layout was rejected.");
|
||||||
|
settleDisplayRestore.attempts = 0;
|
||||||
|
settleDisplayRestore.restart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginRestoredStateReplay(): void {
|
||||||
|
applyRestoredState.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutFromStoredDisplays(stored: var): var {
|
||||||
|
if (!stored || typeof stored !== "object")
|
||||||
|
return null;
|
||||||
|
const current = root.readLiveDisplayLayout();
|
||||||
|
if (!Array.isArray(current) || current.length === 0)
|
||||||
|
return null;
|
||||||
|
const layout = [];
|
||||||
|
for (const live of current) {
|
||||||
|
const entry = stored[live.name];
|
||||||
|
const match = String(entry?.mode ?? "").match(
|
||||||
|
/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!entry || !match || !Number.isFinite(entry.scale) || entry.scale <= 0
|
||||||
|
|| !Number.isInteger(entry.transform)
|
||||||
|
|| entry.transform < 0 || entry.transform > 3
|
||||||
|
|| !Number.isInteger(entry.x) || !Number.isInteger(entry.y)
|
||||||
|
|| typeof entry.primary !== "boolean")
|
||||||
|
return null;
|
||||||
|
layout.push(Object.assign({}, live, {
|
||||||
|
width: Number(match[1]),
|
||||||
|
height: Number(match[2]),
|
||||||
|
refreshRate: Number(match[3]),
|
||||||
|
mode: entry.mode,
|
||||||
|
scale: entry.scale,
|
||||||
|
transform: entry.transform,
|
||||||
|
x: entry.x,
|
||||||
|
y: entry.y,
|
||||||
|
primary: entry.primary
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return layout.filter(record => record.primary).length === 1 ? layout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutsEqual(left: var, right: var): bool {
|
||||||
|
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
|
||||||
|
return false;
|
||||||
|
const fields = ["name", "mode", "scale", "transform", "x", "y", "primary"];
|
||||||
|
const a = Array.from(left).sort((x, y) => x.name.localeCompare(y.name));
|
||||||
|
const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name));
|
||||||
|
return a.every((record, index) => fields.every(
|
||||||
|
field => record[field] === b[index][field]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function failDisplayRestore(message: string): bool {
|
||||||
|
root.pendingRestoredLayout = null;
|
||||||
|
if (!root.protectDisplays(root.protectedDisplays))
|
||||||
|
message += " The original display preference also could not be restored.";
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
root.protectedDisplays = ({});
|
||||||
|
root.protectedDisplayLayout = [];
|
||||||
|
root.lastError = message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Restore output carries the canonical Home state. Reconstructing through
|
// Restore output carries the canonical Home state. Reconstructing through
|
||||||
// these methods keeps validation and persistence inside HomePreferences;
|
// these methods keeps validation and persistence inside HomePreferences;
|
||||||
@@ -250,6 +352,8 @@ Singleton {
|
|||||||
const currentDisplays = root.readDisplays();
|
const currentDisplays = root.readDisplays();
|
||||||
root.protectedDisplays = JSON.parse(JSON.stringify(
|
root.protectedDisplays = JSON.parse(JSON.stringify(
|
||||||
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||||
|
root.protectedDisplayLayout = JSON.parse(JSON.stringify(
|
||||||
|
root.readLiveDisplayLayout() ?? []));
|
||||||
root.setDisplayBlocked(true);
|
root.setDisplayBlocked(true);
|
||||||
actionRun.restoring = true;
|
actionRun.restoring = true;
|
||||||
actionRun.exec([root.helperPath, "restore", name]);
|
actionRun.exec([root.helperPath, "restore", name]);
|
||||||
|
|||||||
@@ -17,15 +17,24 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// SettingsSearch is part of the always-constructed sidebar. Touching the
|
||||||
|
// desktop-style service here gives application preferences their startup
|
||||||
|
// replay even when Appearance is not the page that opens first.
|
||||||
|
Component.onCompleted: DesktopStyle.ensureStarted()
|
||||||
|
|
||||||
// Which page shows the settings in a given schema group. A group with no
|
// Which page shows the settings in a given schema group. A group with no
|
||||||
// entry here still appears in results and routes to Home rather than being
|
// entry here still appears in results and routes to Home rather than being
|
||||||
// dropped, so adding a group can never make a setting unreachable.
|
// dropped, so adding a group can never make a setting unreachable.
|
||||||
readonly property var groupPages: ({
|
readonly property var groupPages: ({
|
||||||
"clock": "appearance",
|
"clock": "appearance",
|
||||||
"vitals": "appearance",
|
"vitals": "appearance",
|
||||||
|
"typography": "appearance",
|
||||||
|
"themes": "appearance",
|
||||||
|
"titlebar": "appearance",
|
||||||
"windows": "appearance",
|
"windows": "appearance",
|
||||||
"effects": "appearance",
|
"effects": "appearance",
|
||||||
"wallpaper": "appearance",
|
"wallpaper": "appearance",
|
||||||
|
"lockAppearance": "appearance",
|
||||||
"dock": "desktop",
|
"dock": "desktop",
|
||||||
"focus": "desktop",
|
"focus": "desktop",
|
||||||
"display": "displays",
|
"display": "displays",
|
||||||
@@ -36,7 +45,10 @@ Singleton {
|
|||||||
"pointer": "mouse",
|
"pointer": "mouse",
|
||||||
"touchpad": "mouse",
|
"touchpad": "mouse",
|
||||||
"multitasking": "desktop",
|
"multitasking": "desktop",
|
||||||
"weather": "appearance",
|
"edges": "desktop",
|
||||||
|
"master": "desktop",
|
||||||
|
"notices": "desktop",
|
||||||
|
"weather": "home",
|
||||||
"notifications": "notifications",
|
"notifications": "notifications",
|
||||||
"capture": "screen-intelligence"
|
"capture": "screen-intelligence"
|
||||||
})
|
})
|
||||||
@@ -54,7 +66,13 @@ Singleton {
|
|||||||
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
||||||
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
|
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
|
||||||
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
|
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
|
||||||
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" }
|
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
|
||||||
|
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid colour", page: "appearance" },
|
||||||
|
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" },
|
||||||
|
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
|
||||||
|
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
|
||||||
|
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
|
||||||
|
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }
|
||||||
]
|
]
|
||||||
|
|
||||||
function pageFor(group: string): string {
|
function pageFor(group: string): string {
|
||||||
@@ -82,7 +100,8 @@ Singleton {
|
|||||||
for (const entry of PreferenceSchema.entries) {
|
for (const entry of PreferenceSchema.entries) {
|
||||||
if (entry.internal)
|
if (entry.internal)
|
||||||
continue;
|
continue;
|
||||||
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group}`.toLowerCase();
|
const optionLabels = (entry.options ?? []).map(option => option.label).join(" ");
|
||||||
|
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`.toLowerCase();
|
||||||
if (haystack.indexOf(needle) >= 0)
|
if (haystack.indexOf(needle) >= 0)
|
||||||
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
|
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
|
||||||
}
|
}
|
||||||
@@ -99,8 +118,16 @@ Singleton {
|
|||||||
|
|
||||||
// Exact prefix matches first: typing "blur" should put "Blur" above
|
// Exact prefix matches first: typing "blur" should put "Blur" above
|
||||||
// "Blur radius", and both above a setting that merely mentions blur in
|
// "Blur radius", and both above a setting that merely mentions blur in
|
||||||
// its explanation.
|
// its explanation. An exact enum option also leads: "slideshow" is a
|
||||||
|
// mode choice, so Wallpaper mode belongs above the interval row that
|
||||||
|
// merely explains it.
|
||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
|
const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label);
|
||||||
|
const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label);
|
||||||
|
const ao = (aSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
|
||||||
|
const bo = (bSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
|
||||||
|
if (ao !== bo)
|
||||||
|
return ao ? -1 : 1;
|
||||||
const al = a.label.toLowerCase();
|
const al = a.label.toLowerCase();
|
||||||
const bl = b.label.toLowerCase();
|
const bl = b.label.toLowerCase();
|
||||||
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
|
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ Singleton {
|
|||||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||||
|
property var regenerateLock: function() { LockScreen.regenerate(); }
|
||||||
|
property var lockBusy: function() { return LockScreen.busy; }
|
||||||
|
|
||||||
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||||
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
||||||
@@ -273,6 +275,15 @@ Singleton {
|
|||||||
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
||||||
// prefs.getInt so both sides agree.
|
// prefs.getInt so both sides agree.
|
||||||
function hyprValue(entry: var, value: var): var {
|
function hyprValue(entry: var, value: var): var {
|
||||||
|
// Some options are phrased as a negative by the compositor -- the four
|
||||||
|
// Hyprland notices are all `disable_x` -- while the setting reads as
|
||||||
|
// "show x", because a switch labelled "Disable splash text" that must
|
||||||
|
// be ON to hide something is a small cruelty. `invert` bridges the two,
|
||||||
|
// in exactly one place, so nothing downstream has to remember which
|
||||||
|
// options are backwards.
|
||||||
|
if (entry.hypr.invert === true && typeof value === "boolean")
|
||||||
|
value = !value;
|
||||||
|
|
||||||
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
||||||
return value ? 1 : 0;
|
return value ? 1 : 0;
|
||||||
return value;
|
return value;
|
||||||
@@ -300,6 +311,24 @@ Singleton {
|
|||||||
return value ? "true" : "false";
|
return value ? "true" : "false";
|
||||||
if (typeof value === "number")
|
if (typeof value === "number")
|
||||||
return String(value);
|
return String(value);
|
||||||
|
|
||||||
|
// A gradient is the one setting whose Lua form is not a scalar. The
|
||||||
|
// stubs declare it as `string|{colors:string[], angle?:number}`, and
|
||||||
|
// the string form only ever carries ONE stop -- writing
|
||||||
|
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
|
||||||
|
// the previous value, which is how a two-stop write looks like it
|
||||||
|
// worked and did nothing. Multi-stop must be the table form.
|
||||||
|
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
||||||
|
const stops = value.colors
|
||||||
|
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
|
||||||
|
.join(", ");
|
||||||
|
const angle = Number(value.angle);
|
||||||
|
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A vec2 reaches Lua as a two-element table.
|
||||||
|
if (Array.isArray(value) && value.length === 2)
|
||||||
|
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
|
||||||
// Strings only reach here after the schema's pattern check; quoting is
|
// Strings only reach here after the schema's pattern check; quoting is
|
||||||
// belt-and-braces rather than the primary defence.
|
// belt-and-braces rather than the primary defence.
|
||||||
return `"${String(value).replace(/["\\]/g, "")}"`;
|
return `"${String(value).replace(/["\\]/g, "")}"`;
|
||||||
@@ -309,7 +338,15 @@ Singleton {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
for (const name in node) {
|
for (const name in node) {
|
||||||
const child = node[name];
|
const child = node[name];
|
||||||
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`);
|
// A leaf arrives pre-serialised as a string; anything else is
|
||||||
|
// either a nested section or a structured value (gradient, vec2)
|
||||||
|
// that serialiseValue knows how to render.
|
||||||
|
const rendered = typeof child === "string"
|
||||||
|
? child
|
||||||
|
: (Array.isArray(child) || (child && child.colors !== undefined)
|
||||||
|
? root.serialiseValue(child)
|
||||||
|
: root.serialiseTable(child));
|
||||||
|
parts.push(`${name} = ${rendered}`);
|
||||||
}
|
}
|
||||||
return `{ ${parts.join(", ")} }`;
|
return `{ ${parts.join(", ")} }`;
|
||||||
}
|
}
|
||||||
@@ -353,6 +390,59 @@ Singleton {
|
|||||||
root.drainQueue();
|
root.drainQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gradients are written in one notation and read back in another, so they
|
||||||
|
// cannot be compared directly the way every other type can.
|
||||||
|
//
|
||||||
|
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
|
||||||
|
// read: "993b4261 45deg"
|
||||||
|
//
|
||||||
|
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
|
||||||
|
// always appended even when it was never given. Comparing the raw strings
|
||||||
|
// reports every gradient write as rejected, which is what would have
|
||||||
|
// happened had this been added with readAs: "str".
|
||||||
|
function gradientMatches(expected: var, observed: string): bool {
|
||||||
|
if (typeof observed !== "string")
|
||||||
|
return false;
|
||||||
|
return root.normaliseGradient(expected) === root.normaliseGradient(observed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
|
||||||
|
function normaliseGradient(value: var): string {
|
||||||
|
const stops = [];
|
||||||
|
let angle = 0;
|
||||||
|
|
||||||
|
const readStop = function (text: string): void {
|
||||||
|
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
|
||||||
|
if (rgba) {
|
||||||
|
let hex = rgba[1].toLowerCase();
|
||||||
|
// rgb() has no alpha; the compositor reports it as fully opaque.
|
||||||
|
if (hex.length === 6)
|
||||||
|
hex = hex + "ff";
|
||||||
|
// RRGGBBAA in, AARRGGBB out.
|
||||||
|
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
|
||||||
|
if (bare) {
|
||||||
|
stops.push(bare[1].toLowerCase());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
|
||||||
|
if (deg)
|
||||||
|
angle = Number(deg[1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
||||||
|
value.colors.forEach(readStop);
|
||||||
|
if (value.angle !== undefined && isFinite(Number(value.angle)))
|
||||||
|
angle = Number(value.angle);
|
||||||
|
} else {
|
||||||
|
String(value).trim().split(/\s+/).forEach(readStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops.join(" ") + " " + angle + "deg";
|
||||||
|
}
|
||||||
|
|
||||||
function matchesObserved(entry: var, value: var, answer: var): bool {
|
function matchesObserved(entry: var, value: var, answer: var): bool {
|
||||||
if (!answer)
|
if (!answer)
|
||||||
return false;
|
return false;
|
||||||
@@ -370,6 +460,12 @@ Singleton {
|
|||||||
case "css":
|
case "css":
|
||||||
// Gaps read back as a box, e.g. "10 10 10 10".
|
// Gaps read back as a box, e.g. "10 10 10 10".
|
||||||
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
|
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
|
||||||
|
case "gradient":
|
||||||
|
return root.gradientMatches(expected, answer.gradient);
|
||||||
|
case "vec2":
|
||||||
|
return Array.isArray(answer.vec2) && Array.isArray(expected)
|
||||||
|
&& Number(answer.vec2[0]) === Number(expected[0])
|
||||||
|
&& Number(answer.vec2[1]) === Number(expected[1]);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -413,16 +509,13 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentDisplays = root.readDisplays();
|
|
||||||
const protectedDisplays = JSON.parse(JSON.stringify(
|
|
||||||
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
|
||||||
root.setDisplayBlocked(true);
|
root.setDisplayBlocked(true);
|
||||||
DesktopPreferences.resetDesktopDefaults();
|
DesktopPreferences.resetDesktopDefaults();
|
||||||
if (!root.protectDisplays(protectedDisplays)) {
|
|
||||||
root.setDisplayBlocked(false);
|
// Do not apply geometry during a reset: doing so would need the same
|
||||||
root.lastError = "The current display setting could not be protected during reset.";
|
// visible confirmation transaction as the Displays page. Clearing the
|
||||||
return false;
|
// stored records is still important, though, so the next session uses
|
||||||
}
|
// Panama's shipped DP-2 placement and automatic placement elsewhere.
|
||||||
|
|
||||||
// Home accessories keep their own store (panama-home.json), so a reset
|
// Home accessories keep their own store (panama-home.json), so a reset
|
||||||
// that only cleared the schema store would silently leave a customised
|
// that only cleared the schema store would silently leave a customised
|
||||||
@@ -443,6 +536,7 @@ Singleton {
|
|||||||
root.applyPersistedDisplayPolicy();
|
root.applyPersistedDisplayPolicy();
|
||||||
root.reloadKeybinds();
|
root.reloadKeybinds();
|
||||||
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
||||||
|
root.regenerateLock();
|
||||||
resetRelease.attempts = 0;
|
resetRelease.attempts = 0;
|
||||||
resetRelease.restart();
|
resetRelease.restart();
|
||||||
}
|
}
|
||||||
@@ -455,7 +549,7 @@ Singleton {
|
|||||||
repeat: true
|
repeat: true
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
attempts++;
|
attempts++;
|
||||||
if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) {
|
if ((!root.keybindsReloading() && !root.busy && !root.lockBusy()) || attempts >= 50) {
|
||||||
stop();
|
stop();
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,55 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
// The desktop background.
|
// Verified wallpaper policy application. hyprpaper 0.8 applies one output per
|
||||||
//
|
// IPC call, so Panama queues every connected output and persists a policy only
|
||||||
// hyprpaper owns the actual painting; this owns choosing. Two things are worth
|
// after listactive confirms the complete map.
|
||||||
// knowing about hyprpaper 0.8:
|
|
||||||
//
|
|
||||||
// * Its IPC is much smaller than the documentation for older versions
|
|
||||||
// suggests. `wallpaper <output>,<path>` and `listactive` work; `preload`,
|
|
||||||
// `listloaded`, `unload`, and `reload` all answer "invalid hyprpaper
|
|
||||||
// request". So there is no preload step -- setting is a single call.
|
|
||||||
// * hyprpaper.conf lives in the Panama repo via the ~/.config/hypr symlink,
|
|
||||||
// so it cannot be rewritten at runtime without dirtying a tracked file.
|
|
||||||
// The chosen wallpaper therefore lives in the shared settings store like
|
|
||||||
// every other preference, and is re-applied when the shell starts.
|
|
||||||
//
|
|
||||||
// The argument is "<output>,<path>", so a path containing a comma would be
|
|
||||||
// parsed as a different request. The schema's pattern rejects those, and the
|
|
||||||
// value is passed as a single argv element rather than through a shell.
|
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
|
import "WallpaperPolicy.js" as WallpaperPolicy
|
||||||
import qs.config
|
import qs.config
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// Absolute paths of candidate images, newest first.
|
|
||||||
property var available: []
|
property var available: []
|
||||||
property string active: ""
|
property var activeByOutput: ({})
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
property bool scanning: false
|
property bool scanning: false
|
||||||
|
property var transaction: null
|
||||||
|
property bool startupRestoreEnabled: true
|
||||||
|
property var outputOverride: null
|
||||||
|
property var candidateOverride: null
|
||||||
|
property string slideshowPath: ""
|
||||||
|
property int slideshowIndex: -1
|
||||||
|
property var shuffleBag: []
|
||||||
|
property int slideshowIntervalOverrideMs: 0
|
||||||
|
property bool pendingHotplugReapply: false
|
||||||
|
|
||||||
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
||||||
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
||||||
|
readonly property string active: {
|
||||||
|
const outputs = root.outputNames();
|
||||||
|
if (outputs.length > 0 && root.activeByOutput[outputs[0]])
|
||||||
|
return root.activeByOutput[outputs[0]];
|
||||||
|
const paths = Object.values(root.activeByOutput);
|
||||||
|
return paths.length > 0 ? paths[0] : "";
|
||||||
|
}
|
||||||
|
readonly property bool busy: root.transaction !== null
|
||||||
|
|| applyProcess.running || verifyProcess.running
|
||||||
|
readonly property var slideshowCollection: WallpaperPolicy.validCollection(
|
||||||
|
DesktopPreferences.get("wallpaperSlideshowPaths") ?? [], root.candidates([]))
|
||||||
|
readonly property bool slideshowTimerRunning: slideshowTimer.running
|
||||||
|
readonly property string outputSignature: root.outputNames().slice().sort().join("|")
|
||||||
|
|
||||||
|
property var outputNames: function() {
|
||||||
|
if (Array.isArray(root.outputOverride))
|
||||||
|
return root.outputOverride.slice();
|
||||||
|
return Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||||
|
}
|
||||||
|
|
||||||
// Directories searched for wallpapers, in order. Screenshots are
|
|
||||||
// deliberately excluded: a folder of 300 screenshots is not a wallpaper
|
|
||||||
// picker, and including it made the grid useless on this machine.
|
|
||||||
readonly property var searchRoots: [
|
readonly property var searchRoots: [
|
||||||
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
||||||
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
||||||
@@ -47,20 +59,14 @@ Singleton {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: scan
|
id: scan
|
||||||
|
|
||||||
// -print0 would be safer against odd filenames, but the schema already
|
|
||||||
// rejects paths containing commas or newlines, and this list is only
|
|
||||||
// ever offered as candidates -- the value that gets stored is validated
|
|
||||||
// again on the way in.
|
|
||||||
command: ["bash", "-lc",
|
command: ["bash", "-lc",
|
||||||
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
||||||
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
||||||
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
||||||
|
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
const paths = this.text.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
root.available = this.text.split("\n")
|
||||||
root.available = paths;
|
.map(line => line.trim()).filter(line => line.length > 0);
|
||||||
root.scanning = false;
|
root.scanning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,40 +77,36 @@ Singleton {
|
|||||||
command: ["hyprctl", "hyprpaper", "listactive"]
|
command: ["hyprctl", "hyprpaper", "listactive"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
// "DP-2: /path/to/image.jpg", one line per output.
|
const parsed = root.parseActive(this.text);
|
||||||
const first = this.text.split("\n").find(line => line.indexOf(":") > 0);
|
if (parsed !== null)
|
||||||
root.active = first ? first.slice(first.indexOf(":") + 1).trim() : "";
|
root.activeByOutput = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyprpaper requires an explicit output name: the "<empty>,<path>" form that
|
|
||||||
// older versions accepted as "all outputs" is silently ignored by 0.8, so a
|
|
||||||
// wallpaper set that way appears to succeed and never changes. Outputs are
|
|
||||||
// therefore walked one at a time.
|
|
||||||
Process {
|
Process {
|
||||||
id: apply
|
id: applyProcess
|
||||||
|
|
||||||
property string requested: ""
|
|
||||||
property string storedValue: ""
|
|
||||||
property var remaining: []
|
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (root.transaction === null)
|
||||||
|
return;
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.lastError = "hyprpaper could not load that image.";
|
root.lastError = "Hyprpaper did not apply that background.";
|
||||||
apply.remaining = [];
|
root.transaction = null;
|
||||||
|
root.schedulePendingHotplug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (apply.remaining.length > 0) {
|
root.drainTransaction();
|
||||||
const next = apply.remaining[0];
|
|
||||||
apply.remaining = apply.remaining.slice(1);
|
|
||||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${next},${apply.requested}`]);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
root.lastError = "";
|
|
||||||
DesktopPreferences.set("wallpaperPath", apply.storedValue);
|
|
||||||
root.refreshActive();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: verifyProcess
|
||||||
|
property string outputText: ""
|
||||||
|
onStarted: outputText = ""
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: verifyProcess.outputText = this.text
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
@@ -113,20 +115,43 @@ Singleton {
|
|||||||
restore.restart();
|
restore.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyprpaper is started by the compositor's autostart, so it may not be
|
|
||||||
// listening yet when the shell comes up. Re-applying the stored choice
|
|
||||||
// after a short delay makes the wallpaper survive a reboot without needing
|
|
||||||
// hyprpaper.conf to know about it.
|
|
||||||
Timer {
|
Timer {
|
||||||
id: restore
|
id: restore
|
||||||
interval: 1500
|
interval: 1500
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
const stored = root.configured;
|
if (root.startupRestoreEnabled)
|
||||||
if (stored !== "" && stored !== root.active)
|
root.applyCurrentPolicy(false);
|
||||||
root.set(stored);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: slideshowTimer
|
||||||
|
interval: root.slideshowIntervalOverrideMs > 0
|
||||||
|
? root.slideshowIntervalOverrideMs
|
||||||
|
: DesktopPreferences.get("wallpaperIntervalMinutes") * 60000
|
||||||
|
repeat: true
|
||||||
|
running: DesktopPreferences.get("wallpaperMode") === "slideshow"
|
||||||
|
&& root.slideshowCollection.length >= 2 && !root.busy
|
||||||
|
onTriggered: root.advanceSlideshow()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: outputSettle
|
||||||
|
interval: 350
|
||||||
|
onTriggered: {
|
||||||
|
if (root.busy) {
|
||||||
|
root.pendingHotplugReapply = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.applyCurrentPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOutputSignatureChanged: {
|
||||||
|
if (Object.keys(root.activeByOutput).length > 0)
|
||||||
|
outputSettle.restart();
|
||||||
|
}
|
||||||
|
|
||||||
function rescan(): void {
|
function rescan(): void {
|
||||||
if (scan.running)
|
if (scan.running)
|
||||||
return;
|
return;
|
||||||
@@ -135,37 +160,268 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshActive(): void {
|
function refreshActive(): void {
|
||||||
if (!activeQuery.running)
|
if (!activeQuery.running && !root.busy)
|
||||||
activeQuery.running = true;
|
activeQuery.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies to every connected output. Returns false when the path is not one
|
function candidates(extra: var): var {
|
||||||
// the schema will accept, so a caller can report the refusal.
|
const result = [];
|
||||||
function set(path: string): bool {
|
const discovered = Array.isArray(root.candidateOverride)
|
||||||
const effectivePath = path === "" ? root.shippedPath : path;
|
? root.candidateOverride : root.available;
|
||||||
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
|
for (const path of discovered.concat([root.shippedPath, root.configured]).concat(extra || [])) {
|
||||||
root.lastError = "That file path cannot be used as a wallpaper.";
|
if (typeof path === "string" && /^\/[^,\n]+$/.test(path) && !result.includes(path))
|
||||||
|
result.push(path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPolicy(): var {
|
||||||
|
return {
|
||||||
|
mode: DesktopPreferences.get("wallpaperMode") ?? "single",
|
||||||
|
globalPath: root.configured === "" ? root.shippedPath : root.configured,
|
||||||
|
collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [],
|
||||||
|
intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30,
|
||||||
|
shuffle: DesktopPreferences.get("wallpaperShuffle") !== false,
|
||||||
|
assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({}),
|
||||||
|
slideshowPath: root.slideshowPath !== "" ? root.slideshowPath : root.active
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalisePolicy(policy: var): var {
|
||||||
|
if (!policy || typeof policy !== "object")
|
||||||
|
return null;
|
||||||
|
const mode = ["single", "slideshow", "per-monitor"].includes(policy.mode)
|
||||||
|
? policy.mode : "single";
|
||||||
|
const rawGlobal = policy.globalPath === "" ? root.shippedPath : policy.globalPath;
|
||||||
|
const candidatePaths = root.candidates([rawGlobal]
|
||||||
|
.concat(policy.collection || [])
|
||||||
|
.concat(Object.values(policy.assignments || {}))
|
||||||
|
.concat([policy.slideshowPath || ""]));
|
||||||
|
if (!WallpaperPolicy.validPath(rawGlobal, candidatePaths))
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
globalPath: rawGlobal,
|
||||||
|
storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal,
|
||||||
|
collection: WallpaperPolicy.validCollection(policy.collection || [], candidatePaths),
|
||||||
|
intervalMinutes: Math.max(5, Math.min(1440, Number(policy.intervalMinutes) || 30)),
|
||||||
|
shuffle: policy.shuffle !== false,
|
||||||
|
assignments: WallpaperPolicy.validAssignments(policy.assignments || {}, candidatePaths),
|
||||||
|
slideshowPath: WallpaperPolicy.validPath(policy.slideshowPath, candidatePaths)
|
||||||
|
? policy.slideshowPath : rawGlobal,
|
||||||
|
nextShuffleBag: Array.isArray(policy.nextShuffleBag)
|
||||||
|
? policy.nextShuffleBag.slice() : root.shuffleBag.slice(),
|
||||||
|
nextSlideshowIndex: Number.isInteger(policy.nextSlideshowIndex)
|
||||||
|
? policy.nextSlideshowIndex : root.slideshowIndex,
|
||||||
|
candidates: candidatePaths
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPolicy(policy: var, persist: bool, automatic: bool): bool {
|
||||||
|
if (root.busy)
|
||||||
|
return false;
|
||||||
|
const normalised = root.normalisePolicy(policy);
|
||||||
|
if (normalised === null) {
|
||||||
|
root.lastError = "That wallpaper policy is not valid.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (apply.running)
|
const outputs = root.outputNames();
|
||||||
return false;
|
|
||||||
|
|
||||||
apply.requested = effectivePath;
|
|
||||||
apply.storedValue = path;
|
|
||||||
|
|
||||||
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
|
||||||
if (outputs.length === 0) {
|
if (outputs.length === 0) {
|
||||||
root.lastError = "No display to set a wallpaper on.";
|
root.lastError = "No display to set a wallpaper on.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const expected = WallpaperPolicy.effectiveMap(
|
||||||
apply.remaining = outputs.slice(1);
|
normalised.mode, normalised.globalPath, normalised.slideshowPath,
|
||||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
|
normalised.assignments, outputs, normalised.candidates);
|
||||||
|
if (Object.keys(expected).length !== outputs.length
|
||||||
|
|| Object.values(expected).some(path => path === "")) {
|
||||||
|
root.lastError = "That wallpaper policy is not valid.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
root.transaction = {
|
||||||
|
expected,
|
||||||
|
remaining: outputs.slice(),
|
||||||
|
policy: normalised,
|
||||||
|
persist: persist === true,
|
||||||
|
automatic: automatic === true
|
||||||
|
};
|
||||||
|
root.drainTransaction();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The display name for a path: the file's own name, without extension,
|
function drainTransaction(): void {
|
||||||
// with separators turned into spaces.
|
if (root.transaction === null || applyProcess.running || verifyProcess.running)
|
||||||
|
return;
|
||||||
|
if (root.transaction.remaining.length === 0) {
|
||||||
|
verifyProcess.exec(["hyprctl", "hyprpaper", "listactive"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const output = root.transaction.remaining[0];
|
||||||
|
root.transaction.remaining = root.transaction.remaining.slice(1);
|
||||||
|
applyProcess.exec([
|
||||||
|
"hyprctl", "hyprpaper", "wallpaper",
|
||||||
|
`${output},${root.transaction.expected[output]}`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActive(text: string): var {
|
||||||
|
const result = {};
|
||||||
|
const lines = String(text).split("\n").map(line => line.trim()).filter(line => line !== "");
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^([A-Za-z0-9_.-]+): (\/[^,\n]+)$/);
|
||||||
|
if (!match || result[match[1]] !== undefined)
|
||||||
|
return null;
|
||||||
|
result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishVerification(exitCode: int): void {
|
||||||
|
if (root.transaction === null)
|
||||||
|
return;
|
||||||
|
const observed = exitCode === 0 ? root.parseActive(verifyProcess.outputText) : null;
|
||||||
|
const expected = root.transaction.expected;
|
||||||
|
const matches = observed !== null
|
||||||
|
&& Object.keys(observed).length === Object.keys(expected).length
|
||||||
|
&& Object.keys(expected).every(output => observed[output] === expected[output]);
|
||||||
|
if (!matches) {
|
||||||
|
root.lastError = "Hyprpaper did not confirm that background.";
|
||||||
|
root.transaction = null;
|
||||||
|
root.schedulePendingHotplug();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = root.transaction;
|
||||||
|
root.activeByOutput = observed;
|
||||||
|
root.transaction = null;
|
||||||
|
root.lastError = "";
|
||||||
|
if (completed.automatic) {
|
||||||
|
root.slideshowPath = completed.policy.slideshowPath;
|
||||||
|
root.shuffleBag = completed.policy.nextShuffleBag;
|
||||||
|
root.slideshowIndex = completed.policy.nextSlideshowIndex;
|
||||||
|
}
|
||||||
|
if (completed.persist)
|
||||||
|
root.persistPolicy(completed.policy);
|
||||||
|
root.schedulePendingHotplug();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPolicy(policy: var): void {
|
||||||
|
DesktopPreferences.set("wallpaperMode", policy.mode);
|
||||||
|
DesktopPreferences.set("wallpaperPath", policy.storedPath);
|
||||||
|
DesktopPreferences.set("wallpaperSlideshowPaths", policy.collection);
|
||||||
|
DesktopPreferences.set("wallpaperIntervalMinutes", policy.intervalMinutes);
|
||||||
|
DesktopPreferences.set("wallpaperShuffle", policy.shuffle);
|
||||||
|
DesktopPreferences.set("wallpaperPerMonitor", policy.assignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCurrentPolicy(persist: bool): bool {
|
||||||
|
return root.applyPolicy(root.currentPolicy(), persist === true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingHotplug(): void {
|
||||||
|
if (!root.pendingHotplugReapply)
|
||||||
|
return;
|
||||||
|
root.pendingHotplugReapply = false;
|
||||||
|
outputSettle.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceSlideshow(): bool {
|
||||||
|
if (root.busy)
|
||||||
|
return false;
|
||||||
|
const collection = root.slideshowCollection;
|
||||||
|
if (collection.length < 2)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const current = root.slideshowPath !== ""
|
||||||
|
? root.slideshowPath
|
||||||
|
: (root.active !== "" ? root.active : collection[0]);
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
policy.mode = "slideshow";
|
||||||
|
policy.collection = collection;
|
||||||
|
|
||||||
|
if (DesktopPreferences.get("wallpaperShuffle") !== false) {
|
||||||
|
const next = WallpaperPolicy.shuffledNext(
|
||||||
|
collection, root.shuffleBag, current, Math.random);
|
||||||
|
policy.slideshowPath = next.path;
|
||||||
|
policy.nextShuffleBag = next.bag;
|
||||||
|
policy.nextSlideshowIndex = collection.indexOf(next.path);
|
||||||
|
} else {
|
||||||
|
policy.slideshowPath = WallpaperPolicy.orderedNext(collection, current);
|
||||||
|
policy.nextShuffleBag = [];
|
||||||
|
policy.nextSlideshowIndex = collection.indexOf(policy.slideshowPath);
|
||||||
|
}
|
||||||
|
return root.applyPolicy(policy, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSingle(path: string): bool {
|
||||||
|
const effectivePath = path === "" ? root.shippedPath : path;
|
||||||
|
const allowed = root.candidates([]);
|
||||||
|
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
|
||||||
|
root.lastError = "That file path cannot be used as a wallpaper.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
policy.mode = "single";
|
||||||
|
policy.globalPath = effectivePath;
|
||||||
|
policy.slideshowPath = effectivePath;
|
||||||
|
return root.applyPolicy(policy, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(mode: string): bool {
|
||||||
|
if (!["single", "slideshow", "per-monitor"].includes(mode))
|
||||||
|
return false;
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
policy.mode = mode;
|
||||||
|
if (mode === "slideshow") {
|
||||||
|
const collection = WallpaperPolicy.validCollection(
|
||||||
|
policy.collection, root.candidates([]));
|
||||||
|
if (!collection.includes(policy.slideshowPath))
|
||||||
|
policy.slideshowPath = collection[0] || policy.globalPath;
|
||||||
|
}
|
||||||
|
return root.applyPolicy(policy, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAssignment(output: string, path: string): bool {
|
||||||
|
if (!root.outputNames().includes(output)
|
||||||
|
|| !WallpaperPolicy.validPath(path, root.candidates([])))
|
||||||
|
return false;
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
policy.mode = "per-monitor";
|
||||||
|
policy.assignments = Object.assign({}, policy.assignments);
|
||||||
|
policy.assignments[output] = path;
|
||||||
|
return root.applyPolicy(policy, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSlideshowPath(path: string): bool {
|
||||||
|
if (!WallpaperPolicy.validPath(path, root.candidates([])))
|
||||||
|
return false;
|
||||||
|
const policy = root.currentPolicy();
|
||||||
|
const collection = WallpaperPolicy.validCollection(policy.collection, root.candidates([]));
|
||||||
|
policy.collection = collection.includes(path)
|
||||||
|
? collection.filter(candidate => candidate !== path)
|
||||||
|
: collection.concat([path]);
|
||||||
|
policy.mode = "slideshow";
|
||||||
|
if (!policy.collection.includes(policy.slideshowPath))
|
||||||
|
policy.slideshowPath = policy.collection[0] || policy.globalPath;
|
||||||
|
return root.applyPolicy(policy, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIntervalMinutes(minutes: int): bool {
|
||||||
|
const value = PreferenceSchema.coerce("wallpaperIntervalMinutes", minutes);
|
||||||
|
return value !== undefined
|
||||||
|
&& DesktopPreferences.set("wallpaperIntervalMinutes", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShuffle(enabled: bool): bool {
|
||||||
|
return DesktopPreferences.set("wallpaperShuffle", enabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility boundary used by backup/reset and the existing picker.
|
||||||
|
function set(path: string): bool {
|
||||||
|
return root.setSingle(path);
|
||||||
|
}
|
||||||
|
|
||||||
function titleFor(path: string): string {
|
function titleFor(path: string): string {
|
||||||
const file = String(path).split("/").pop();
|
const file = String(path).split("/").pop();
|
||||||
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
function validPath(path, candidates) {
|
||||||
|
if (typeof path !== "string" || !/^\/[^,\n]+$/.test(path))
|
||||||
|
return false;
|
||||||
|
return (candidates || []).includes(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCollection(paths, candidates) {
|
||||||
|
const result = [];
|
||||||
|
const seen = {};
|
||||||
|
for (const path of paths || []) {
|
||||||
|
if (!validPath(path, candidates) || seen[path])
|
||||||
|
continue;
|
||||||
|
seen[path] = true;
|
||||||
|
result.push(path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validAssignments(assignments, candidates) {
|
||||||
|
const result = {};
|
||||||
|
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments))
|
||||||
|
return result;
|
||||||
|
for (const output of Object.keys(assignments)) {
|
||||||
|
if (!/^[A-Za-z0-9_.-]+$/.test(output))
|
||||||
|
continue;
|
||||||
|
const path = assignments[output];
|
||||||
|
if (validPath(path, candidates))
|
||||||
|
result[output] = path;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveMap(mode, globalPath, slideshowPath, assignments, outputs, candidates) {
|
||||||
|
const result = {};
|
||||||
|
const fallback = validPath(globalPath, candidates)
|
||||||
|
? globalPath
|
||||||
|
: ((candidates || [])[0] || "");
|
||||||
|
const selectedAssignments = validAssignments(assignments, candidates);
|
||||||
|
const slideshow = validPath(slideshowPath, candidates) ? slideshowPath : fallback;
|
||||||
|
for (const output of outputs || []) {
|
||||||
|
if (typeof output !== "string" || !/^[A-Za-z0-9_.-]+$/.test(output))
|
||||||
|
continue;
|
||||||
|
if (mode === "per-monitor")
|
||||||
|
result[output] = selectedAssignments[output] || fallback;
|
||||||
|
else if (mode === "slideshow")
|
||||||
|
result[output] = slideshow;
|
||||||
|
else
|
||||||
|
result[output] = fallback;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedNext(collection, current) {
|
||||||
|
const paths = collection || [];
|
||||||
|
if (paths.length === 0)
|
||||||
|
return "";
|
||||||
|
const index = paths.indexOf(current);
|
||||||
|
return paths[(index + 1 + paths.length) % paths.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffledBag(collection, random) {
|
||||||
|
const result = Array.from(collection || []);
|
||||||
|
const nextRandom = typeof random === "function" ? random : Math.random;
|
||||||
|
for (let index = result.length - 1; index > 0; index--) {
|
||||||
|
const swap = Math.floor(Math.max(0, Math.min(0.999999999, nextRandom())) * (index + 1));
|
||||||
|
const value = result[index];
|
||||||
|
result[index] = result[swap];
|
||||||
|
result[swap] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffledNext(collection, bag, current, random) {
|
||||||
|
const paths = Array.from(collection || []);
|
||||||
|
if (paths.length === 0)
|
||||||
|
return { path: "", bag: [] };
|
||||||
|
|
||||||
|
const remaining = [];
|
||||||
|
const seen = {};
|
||||||
|
for (const path of bag || []) {
|
||||||
|
if (!paths.includes(path) || seen[path])
|
||||||
|
continue;
|
||||||
|
seen[path] = true;
|
||||||
|
remaining.push(path);
|
||||||
|
}
|
||||||
|
if (remaining.length === 0)
|
||||||
|
remaining.push(...shuffledBag(paths, random));
|
||||||
|
|
||||||
|
if (remaining.length > 1 && remaining[0] === current) {
|
||||||
|
const replacement = remaining.findIndex(path => path !== current);
|
||||||
|
const value = remaining[0];
|
||||||
|
remaining[0] = remaining[replacement];
|
||||||
|
remaining[replacement] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: remaining[0], bag: remaining.slice(1) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Alt-Tab, with something on screen while you do it.
|
||||||
|
//
|
||||||
|
// Named WindowSwitcherState rather than WindowSwitcher: the overlay component
|
||||||
|
// in modules/switcher already owns that name, and a singleton sharing it is
|
||||||
|
// silently shadowed wherever both are imported -- the same failure that made an
|
||||||
|
// earlier Locale singleton resolve to QML's built-in type instead.
|
||||||
|
//
|
||||||
|
// Super+Tab already cycled windows; nothing was drawn, so you were choosing
|
||||||
|
// blind and could only confirm by arriving. This holds the selection while a
|
||||||
|
// switch is in progress and lets the overlay render it.
|
||||||
|
//
|
||||||
|
// MOST-RECENTLY-USED ORDER
|
||||||
|
//
|
||||||
|
// The list is ordered by when each window last had focus, not by when it was
|
||||||
|
// opened, because that is what makes the gesture useful: one Tab returns to the
|
||||||
|
// window you just came from, which is the overwhelmingly common case. Creation
|
||||||
|
// order would send you to whichever window happens to be first in Hyprland's
|
||||||
|
// list, which is arbitrary from the user's point of view.
|
||||||
|
//
|
||||||
|
// Hyprland does not report an MRU order, so it is tracked here: every time a
|
||||||
|
// toplevel becomes active it moves to the front. Addresses are used as the key
|
||||||
|
// because they are stable for a window's lifetime, where titles and app ids are
|
||||||
|
// not.
|
||||||
|
//
|
||||||
|
// HOW A SWITCH ENDS
|
||||||
|
//
|
||||||
|
// The compositor fires a bind on Super RELEASE, which commits. That is the only
|
||||||
|
// way to know the gesture is over -- there is no "modifier released" signal
|
||||||
|
// otherwise. It means close() runs on every Super release in the session, so it
|
||||||
|
// must be cheap and a no-op when nothing is open.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool open: false
|
||||||
|
property int index: 0
|
||||||
|
|
||||||
|
// Window addresses, most recently focused first.
|
||||||
|
property var recent: []
|
||||||
|
|
||||||
|
// The switch candidates, resolved fresh each time the gesture starts.
|
||||||
|
property var windows: []
|
||||||
|
|
||||||
|
readonly property var selected: (root.index >= 0 && root.index < root.windows.length)
|
||||||
|
? root.windows[root.index] : null
|
||||||
|
|
||||||
|
// Ordered by the MRU list, with anything unseen appended in Hyprland's own
|
||||||
|
// order so a brand new window is still reachable.
|
||||||
|
function orderedWindows(): var {
|
||||||
|
const all = (Hyprland.toplevels?.values ?? []).filter(t => t && t.wayland && t.wayland.appId);
|
||||||
|
const byAddress = {};
|
||||||
|
for (const toplevel of all)
|
||||||
|
byAddress[String(toplevel.address)] = toplevel;
|
||||||
|
|
||||||
|
const ordered = [];
|
||||||
|
for (const address of root.recent) {
|
||||||
|
const match = byAddress[address];
|
||||||
|
if (match) {
|
||||||
|
ordered.push(match);
|
||||||
|
delete byAddress[address];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const toplevel of all)
|
||||||
|
if (byAddress[String(toplevel.address)])
|
||||||
|
ordered.push(toplevel);
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts the gesture if it is not already running, then steps. The first
|
||||||
|
// Tab lands on the PREVIOUS window rather than the current one, which is
|
||||||
|
// what every other implementation of this gesture does.
|
||||||
|
function step(forward: bool): void {
|
||||||
|
if (!root.open) {
|
||||||
|
root.windows = root.orderedWindows();
|
||||||
|
if (root.windows.length < 2)
|
||||||
|
return;
|
||||||
|
root.open = true;
|
||||||
|
root.index = forward ? 1 : root.windows.length - 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.windows.length === 0)
|
||||||
|
return;
|
||||||
|
const count = root.windows.length;
|
||||||
|
root.index = forward
|
||||||
|
? (root.index + 1) % count
|
||||||
|
: (root.index - 1 + count) % count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs on every Super release in the session, so it does as little as
|
||||||
|
// possible when no switch is in progress.
|
||||||
|
function commit(): void {
|
||||||
|
if (!root.open)
|
||||||
|
return;
|
||||||
|
const target = root.selected;
|
||||||
|
root.open = false;
|
||||||
|
root.windows = [];
|
||||||
|
root.index = 0;
|
||||||
|
if (target && target.wayland)
|
||||||
|
target.wayland.activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
root.open = false;
|
||||||
|
root.windows = [];
|
||||||
|
root.index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus changes maintain the MRU order. This runs whether or not a switch
|
||||||
|
// is in progress, because ordinary clicking between windows is most of how
|
||||||
|
// the order is established.
|
||||||
|
Connections {
|
||||||
|
target: Hyprland
|
||||||
|
function onActiveToplevelChanged(): void {
|
||||||
|
const active = Hyprland.activeToplevel;
|
||||||
|
if (!active || !active.address)
|
||||||
|
return;
|
||||||
|
const address = String(active.address);
|
||||||
|
const next = [address];
|
||||||
|
for (const existing of root.recent)
|
||||||
|
if (existing !== address)
|
||||||
|
next.push(existing);
|
||||||
|
// Bounded: a session can accumulate a lot of closed addresses, and
|
||||||
|
// this list is only ever used to order what is currently open.
|
||||||
|
root.recent = next.slice(0, 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,21 @@ ShellRoot {
|
|||||||
property var homeFavorites: []
|
property var homeFavorites: []
|
||||||
property bool displayOperationBusy: false
|
property bool displayOperationBusy: false
|
||||||
property bool displayBlocked: false
|
property bool displayBlocked: false
|
||||||
property var displayGeneration: ({ "DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0 } })
|
property bool displayApplyAccepted: true
|
||||||
|
property bool displayConfirmationReady: false
|
||||||
|
property var originalDisplays: ({
|
||||||
|
"DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
|
||||||
|
"HDMI-A-1": { mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
|
||||||
|
})
|
||||||
|
property var restoredDisplays: ({
|
||||||
|
"DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0, x: -2560, y: 0, primary: false },
|
||||||
|
"HDMI-A-1": { mode: "2560x1440@60", scale: 1, transform: 0, x: 0, y: 0, primary: true }
|
||||||
|
})
|
||||||
|
property var displayGeneration: originalDisplays
|
||||||
|
property var liveLayout: [
|
||||||
|
{ name: "DP-2", width: 4500, height: 3000, refreshRate: 60, mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
|
||||||
|
{ name: "HDMI-A-1", width: 2560, height: 1440, refreshRate: 60, mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
|
||||||
|
]
|
||||||
|
|
||||||
function record(name: string): void {
|
function record(name: string): void {
|
||||||
const next = root.calls.slice();
|
const next = root.calls.slice();
|
||||||
@@ -45,7 +59,10 @@ ShellRoot {
|
|||||||
root.homeFavorites = root.homeFavorites.map(favorite =>
|
root.homeFavorites = root.homeFavorites.map(favorite =>
|
||||||
favorite.id === id ? { id: id, alias: alias } : favorite);
|
favorite.id === id ? { id: id, alias: alias } : favorite);
|
||||||
};
|
};
|
||||||
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
|
SettingsBackup.reloadDesktop = function() {
|
||||||
|
root.record("desktop.reload");
|
||||||
|
root.displayGeneration = JSON.parse(JSON.stringify(root.restoredDisplays));
|
||||||
|
};
|
||||||
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
|
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
|
||||||
SettingsBackup.protectDisplays = function(value) {
|
SettingsBackup.protectDisplays = function(value) {
|
||||||
root.record("display.protect:" + JSON.stringify(value));
|
root.record("display.protect:" + JSON.stringify(value));
|
||||||
@@ -53,16 +70,39 @@ ShellRoot {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
|
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
|
||||||
|
SettingsBackup.readLiveDisplayLayout = function() {
|
||||||
|
return root.liveLayout.map(record => Object.assign({}, record));
|
||||||
|
};
|
||||||
|
SettingsBackup.applyDisplayLayout = function(layout) {
|
||||||
|
root.record("display.apply:" + JSON.stringify(layout));
|
||||||
|
if (!root.displayApplyAccepted)
|
||||||
|
return false;
|
||||||
|
root.liveLayout = layout.map(record => Object.assign({}, record));
|
||||||
|
root.displayOperationBusy = true;
|
||||||
|
root.displayConfirmationReady = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
SettingsBackup.displayCanConfirm = function() { return root.displayConfirmationReady; };
|
||||||
|
SettingsBackup.confirmDisplayLayout = function() {
|
||||||
|
root.record("display.confirm");
|
||||||
|
root.displayOperationBusy = false;
|
||||||
|
root.displayConfirmationReady = false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
SettingsBackup.setDisplayBlocked = function(blocked) {
|
SettingsBackup.setDisplayBlocked = function(blocked) {
|
||||||
root.record("display.block:" + blocked);
|
root.record("display.block:" + blocked);
|
||||||
root.displayBlocked = blocked;
|
root.displayBlocked = blocked;
|
||||||
};
|
};
|
||||||
|
SettingsBackup.applyIdle = function() { root.record("idle.apply"); };
|
||||||
|
SettingsBackup.idleBusy = function() { return false; };
|
||||||
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
|
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
|
||||||
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
||||||
SettingsBackup.keybindsReloading = function() { return false; };
|
SettingsBackup.keybindsReloading = function() { return false; };
|
||||||
SettingsBackup.systemBusy = function() { return false; };
|
SettingsBackup.systemBusy = function() { return false; };
|
||||||
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
|
SettingsBackup.applyWallpaperPolicy = function() { root.record("wallpaper.apply-policy"); };
|
||||||
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
|
SettingsBackup.wallpaperBusy = function() { return false; };
|
||||||
|
SettingsBackup.regenerateLock = function() { root.record("lock.regenerate"); };
|
||||||
|
SettingsBackup.lockBusy = function() { return false; };
|
||||||
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
|
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +115,15 @@ ShellRoot {
|
|||||||
root.homeFavorites = [];
|
root.homeFavorites = [];
|
||||||
root.displayOperationBusy = false;
|
root.displayOperationBusy = false;
|
||||||
root.displayBlocked = false;
|
root.displayBlocked = false;
|
||||||
|
root.displayApplyAccepted = true;
|
||||||
|
root.displayConfirmationReady = false;
|
||||||
|
root.displayGeneration = JSON.parse(JSON.stringify(root.originalDisplays));
|
||||||
|
root.liveLayout = [
|
||||||
|
{ name: "DP-2", width: 4500, height: 3000, refreshRate: 60, mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
|
||||||
|
{ name: "HDMI-A-1", width: 2560, height: 1440, refreshRate: 60, mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
|
||||||
|
];
|
||||||
SettingsBackup.protectedDisplays = root.displayGeneration;
|
SettingsBackup.protectedDisplays = root.displayGeneration;
|
||||||
|
SettingsBackup.protectedDisplayLayout = root.liveLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(output: string): bool {
|
function apply(output: string): bool {
|
||||||
@@ -88,6 +136,11 @@ ShellRoot {
|
|||||||
return SettingsBackup.restore("settings-20260818-010203004.json");
|
return SettingsBackup.restore("settings-20260818-010203004.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDisplayFailure(output: string): bool {
|
||||||
|
root.displayApplyAccepted = false;
|
||||||
|
return SettingsBackup.handleRestoreOutput(output);
|
||||||
|
}
|
||||||
|
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
calls: root.calls,
|
calls: root.calls,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ ShellRoot {
|
|||||||
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
|
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
|
||||||
SystemSettings.keybindsReloading = function() { return false; };
|
SystemSettings.keybindsReloading = function() { return false; };
|
||||||
SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); };
|
SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); };
|
||||||
|
SystemSettings.regenerateLock = function() { root.recordReset("lock.regenerate"); };
|
||||||
|
SystemSettings.lockBusy = function() { return false; };
|
||||||
}
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.bar
|
import qs.modules.bar
|
||||||
import qs.modules.dock
|
import qs.modules.dock
|
||||||
|
import qs.modules.switcher
|
||||||
import qs.modules.overview
|
import qs.modules.overview
|
||||||
import qs.modules.quicksettings
|
import qs.modules.quicksettings
|
||||||
import qs.modules.notifications
|
import qs.modules.notifications
|
||||||
@@ -68,6 +69,13 @@ ShellRoot {
|
|||||||
Dock {}
|
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 {
|
Variants {
|
||||||
model: Quickshell.screens
|
model: Quickshell.screens
|
||||||
SignalGlass {}
|
SignalGlass {}
|
||||||
@@ -78,6 +86,8 @@ ShellRoot {
|
|||||||
Osd {}
|
Osd {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisplayIdentify {}
|
||||||
|
|
||||||
// ── Single-instance overlays ────────────────────────────────────────────
|
// ── Single-instance overlays ────────────────────────────────────────────
|
||||||
// These are always constructed but only *visible* when ShellState says so.
|
// These are always constructed but only *visible* when ShellState says so.
|
||||||
// They're cheap while hidden, and keeping them alive means opening the
|
// 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
|
// Every parameter AND the return type must be annotated, or Quickshell
|
||||||
// silently declines to register the function — it will not warn you.
|
// 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 {
|
IpcHandler {
|
||||||
target: "overview"
|
target: "overview"
|
||||||
function toggle(): void { ShellState.toggle("overview"); }
|
function toggle(): void { ShellState.toggle("overview"); }
|
||||||
@@ -165,6 +187,24 @@ ShellRoot {
|
|||||||
function repair(id: string): bool { return Health.repair(id, true); }
|
function repair(id: string): bool { return Health.repair(id, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health checks the wallpaper service through the same typed IPC boundary
|
||||||
|
// as capture and clipboard. This is deliberately read-only: choosing an
|
||||||
|
// image remains an explicit Settings action.
|
||||||
|
IpcHandler {
|
||||||
|
target: "wallpaper"
|
||||||
|
|
||||||
|
function refresh(): void { Wallpaper.refreshActive(); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
active: Wallpaper.active,
|
||||||
|
activeByOutput: Wallpaper.activeByOutput,
|
||||||
|
configured: Wallpaper.configured,
|
||||||
|
availableCount: Wallpaper.available.length,
|
||||||
|
lastError: Wallpaper.lastError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A small diagnostics surface doubles as a deterministic contract harness.
|
// A small diagnostics surface doubles as a deterministic contract harness.
|
||||||
// Real producers call StatusEvents.publish() directly; fixtures never run
|
// Real producers call StatusEvents.publish() directly; fixtures never run
|
||||||
// unless explicitly requested over IPC by the test suite.
|
// unless explicitly requested over IPC by the test suite.
|
||||||
|
|||||||
@@ -9,11 +9,55 @@ import qs.services
|
|||||||
import qs.modules.settings
|
import qs.modules.settings
|
||||||
|
|
||||||
ShellRoot {
|
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 {
|
SoundPage {
|
||||||
width: 760
|
width: 760
|
||||||
height: 900
|
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 {
|
PwObjectTracker {
|
||||||
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
|
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
|
||||||
}
|
}
|
||||||
@@ -26,8 +70,15 @@ ShellRoot {
|
|||||||
ready: Pipewire.ready,
|
ready: Pipewire.ready,
|
||||||
outputs: AudioDevices.outputs.length,
|
outputs: AudioDevices.outputs.length,
|
||||||
inputs: AudioDevices.inputs.length,
|
inputs: AudioDevices.inputs.length,
|
||||||
|
applications: AudioDevices.applications.length,
|
||||||
defaultOutput: AudioDevices.label(AudioDevices.current(true)),
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
# Phase 2: Close the Expectation Gaps
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 2 completes four ordinary desktop capabilities that are conspicuous when
|
||||||
|
they are absent: per-application volume, multi-monitor arrangement, lock-screen
|
||||||
|
appearance, and wallpaper automation. The goal is not to make Panama broader;
|
||||||
|
it is to make the parts people expect in their first week feel native, safe,
|
||||||
|
and complete.
|
||||||
|
|
||||||
|
The approved visual direction is **A — Continuity**. New controls use Panama's
|
||||||
|
existing two-pane Settings window, restrained cards, Prism selection marks, and
|
||||||
|
Tokyo Night Moon tokens. The monitor canvas and lock-screen preview are the only
|
||||||
|
new visual forms. There is no inspector sidebar, dashboard-within-a-dashboard,
|
||||||
|
or new navigation level.
|
||||||
|
|
||||||
|
## Product boundaries
|
||||||
|
|
||||||
|
Phase 2 includes:
|
||||||
|
|
||||||
|
- Live volume and mute controls grouped by playback application.
|
||||||
|
- Positioning connected displays, selecting Panama's primary display, and
|
||||||
|
preserving the existing mode, scale, rotation, confirmation, and rollback
|
||||||
|
behavior.
|
||||||
|
- Choosing the lock-screen background treatment and visibility of its clock,
|
||||||
|
date, user label, and idle password field.
|
||||||
|
- Single-image, slideshow, and per-monitor wallpaper modes, including an
|
||||||
|
explicit slideshow collection, shuffle, and interval.
|
||||||
|
|
||||||
|
It does not include audio routing between applications, equalizers, PipeWire
|
||||||
|
profiles, disabling displays, display mirroring, full-time HDR, animated
|
||||||
|
wallpapers, per-workspace wallpapers, a lock-screen plugin system, or arbitrary
|
||||||
|
hyprlock configuration. Those would expand four expectation-closing features
|
||||||
|
into four general-purpose configuration tools.
|
||||||
|
|
||||||
|
Phase 3 remains responsible for Control Center mirrors and contextual
|
||||||
|
"configure this" affordances. Phase 4 remains responsible for user accents and
|
||||||
|
named themes.
|
||||||
|
|
||||||
|
## Shared principles
|
||||||
|
|
||||||
|
Each area has one source of truth:
|
||||||
|
|
||||||
|
- PipeWire owns live stream volume. Panama does not persist ephemeral streams.
|
||||||
|
- `DesktopPreferences.displays` owns confirmed display layout overrides.
|
||||||
|
- Panama's schema owns lock appearance and generates the effective hyprlock
|
||||||
|
file under the user state directory.
|
||||||
|
- Panama's schema owns wallpaper policy; hyprpaper owns the pixels currently
|
||||||
|
displayed.
|
||||||
|
|
||||||
|
Settings components call typed service methods. They never construct shell
|
||||||
|
commands. External writes use argument arrays, validate user-authored values,
|
||||||
|
and verify observable state where the target exposes readback.
|
||||||
|
|
||||||
|
No feature adds a continuous animation or short polling loop. PipeWire and the
|
||||||
|
screen model are event-driven. Wallpaper rotation wakes only at its configured
|
||||||
|
interval. Lock configuration regenerates only after a relevant preference
|
||||||
|
change. Display reads happen when the page opens, after an operation, and when
|
||||||
|
the connected-screen set changes.
|
||||||
|
|
||||||
|
## Application volume
|
||||||
|
|
||||||
|
### State and grouping
|
||||||
|
|
||||||
|
`services/AudioDevices.qml` gains a playback-stream view derived from
|
||||||
|
`Pipewire.nodes.values`. A playback stream is a ready `PwNode` whose type
|
||||||
|
contains `PwNodeType.AudioOutStream`, has an audio interface, and is not a
|
||||||
|
physical device.
|
||||||
|
|
||||||
|
Streams are grouped into applications using the first non-empty stable identity
|
||||||
|
from PipeWire's properties:
|
||||||
|
|
||||||
|
1. `application.id`
|
||||||
|
2. `application.process.binary`
|
||||||
|
3. `application.name`
|
||||||
|
4. the node ID as a final per-stream fallback
|
||||||
|
|
||||||
|
The display label prefers `application.name`, then `node.description`,
|
||||||
|
`media.name`, and finally **Unknown application**. The icon prefers
|
||||||
|
`application.icon_name`; the delegate falls back to a generic audio-application
|
||||||
|
symbolic icon. Property values are presentation data only and never become
|
||||||
|
commands or file paths.
|
||||||
|
|
||||||
|
One application row may own several simultaneous streams. Its displayed volume
|
||||||
|
is the arithmetic mean of the tracked stream volumes. Moving the row writes the
|
||||||
|
same requested level to every stream in the group. The row is muted only when
|
||||||
|
every stream is muted; pressing mute applies one state to all streams. Moving a
|
||||||
|
slider always unmutes every stream, matching the existing device controls.
|
||||||
|
|
||||||
|
New streams appear and closed streams disappear through PipeWire's node model.
|
||||||
|
There is no saved per-application volume map: persisting a browser tab or media
|
||||||
|
session identity would restore stale state to unrelated future streams.
|
||||||
|
|
||||||
|
### Settings experience
|
||||||
|
|
||||||
|
Sound gains an **Applications** card beneath Output and Input. Each active
|
||||||
|
playback application has an icon, application name, optional stream-count or
|
||||||
|
media detail, mute button, slider, and percentage. Rows remain stable while a
|
||||||
|
stream's properties update.
|
||||||
|
|
||||||
|
When nothing is playing, the card remains visible with the quiet direction:
|
||||||
|
**Applications playing sound will appear here.** PipeWire discovery failure is
|
||||||
|
distinct and directs the user to System Health.
|
||||||
|
|
||||||
|
The existing Fedora handoff becomes **Device profiles**. Panama now owns
|
||||||
|
application volume, while Fedora's panel remains the advanced route for codec
|
||||||
|
and hardware profile selection.
|
||||||
|
|
||||||
|
### Failure behavior
|
||||||
|
|
||||||
|
- A node that disappears during a drag is ignored without affecting surviving
|
||||||
|
streams in the group.
|
||||||
|
- A stream without writable audio state remains visible as unavailable rather
|
||||||
|
than crashing the model.
|
||||||
|
- Mixed mute state is represented as unmuted; the next explicit mute action
|
||||||
|
makes the group consistent.
|
||||||
|
- No stream metadata is written to logs or the settings store.
|
||||||
|
|
||||||
|
## Multi-monitor arrangement
|
||||||
|
|
||||||
|
### Persisted layout
|
||||||
|
|
||||||
|
The existing `displays` JSON remains the single preference and keeps its
|
||||||
|
backward-compatible per-output shape. Confirmed entries gain three fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"DP-2": {
|
||||||
|
"mode": "4500x3000@60.00",
|
||||||
|
"scale": 1.5,
|
||||||
|
"transform": 0,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Old entries without `x`, `y`, or `primary` remain valid. They use automatic
|
||||||
|
placement until the first confirmed arrangement. Exactly one connected output
|
||||||
|
is primary in a confirmed multi-monitor layout. Single-monitor layouts make the
|
||||||
|
only output primary automatically.
|
||||||
|
|
||||||
|
Wayland has no universal primary-display protocol. **Primary** is therefore an
|
||||||
|
honest Panama role: it anchors the saved coordinate system at logical `0,0` and
|
||||||
|
is listed first in Panama's display and per-monitor wallpaper selectors.
|
||||||
|
Panama's current overlays continue following the focused monitor; this phase
|
||||||
|
does not silently move them. The role does not claim to force arbitrary
|
||||||
|
third-party Wayland applications to open on a particular output.
|
||||||
|
|
||||||
|
Positions are integer logical pixels after scale and rotation. Before apply,
|
||||||
|
the service normalizes every coordinate relative to the selected primary
|
||||||
|
display. Negative coordinates are allowed for displays physically left of or
|
||||||
|
above the primary.
|
||||||
|
|
||||||
|
### Apply, verify, and rollback
|
||||||
|
|
||||||
|
`services/Displays.qml` moves from a one-output pending record to a whole-layout
|
||||||
|
transaction. A request contains every connected output's mode, scale,
|
||||||
|
transform, position, and primary flag.
|
||||||
|
|
||||||
|
The service:
|
||||||
|
|
||||||
|
1. Validates every output, offered mode, clean scale, transform, integer
|
||||||
|
coordinate, and the single-primary invariant.
|
||||||
|
2. Captures the complete current connected layout.
|
||||||
|
3. Applies the complete requested layout through one generated, allow-listed
|
||||||
|
Hyprland Lua evaluation.
|
||||||
|
4. Reads all monitors back and enables **Keep** only when every field matches.
|
||||||
|
5. Reverts the complete captured layout after 15 seconds unless confirmed.
|
||||||
|
6. Persists only the verified requested layout when **Keep** is pressed.
|
||||||
|
7. Reads the reverted layout back and reports if restoration cannot be proven.
|
||||||
|
|
||||||
|
A disconnected output invalidates an in-flight transaction and triggers
|
||||||
|
rollback for every still-connected output. A newly connected output receives
|
||||||
|
automatic placement; the connected-screen event refreshes the page, but the
|
||||||
|
new geometry is not persisted until the user confirms a layout. Stored
|
||||||
|
overrides for disconnected outputs are retained for reconnect, but never
|
||||||
|
participate in a live transaction while absent.
|
||||||
|
|
||||||
|
`hypr/monitors.lua` validates and replays the extended entries at startup. The
|
||||||
|
shipped DP-2 bit depth and color-management policy remain authoritative and are
|
||||||
|
not moved into user preferences.
|
||||||
|
|
||||||
|
### Settings experience
|
||||||
|
|
||||||
|
Displays gains an **Arrangement** card above the selected-display controls when
|
||||||
|
more than one monitor is connected. The canvas scales the complete logical
|
||||||
|
desktop into its available area while preserving real aspect ratios. Monitor
|
||||||
|
tiles show the human display name and connector. The selected tile gets the
|
||||||
|
Prism outline; the primary tile also receives a restrained **Primary** label.
|
||||||
|
|
||||||
|
Dragging moves a tile and snaps nearby edges. The change remains a draft until
|
||||||
|
pointer release, when the normal 15-second confirmation transaction begins.
|
||||||
|
Focused tiles also support arrow-key movement, with Shift for larger steps, so
|
||||||
|
arrangement is not pointer-only. **Make primary** normalizes the draft around
|
||||||
|
that display and enters the same confirmation flow.
|
||||||
|
|
||||||
|
**Identify** briefly draws a static numbered overlay on every connected screen.
|
||||||
|
The overlay has no repeating animation and dismisses itself after three seconds.
|
||||||
|
|
||||||
|
The existing resolution, scale, rotation, DDC brightness, Night Light, and
|
||||||
|
gaming-policy cards remain. Display selection now follows selection in the
|
||||||
|
arrangement canvas but still has a compact textual selector for narrow layouts.
|
||||||
|
|
||||||
|
## Lock-screen appearance
|
||||||
|
|
||||||
|
### Generated configuration
|
||||||
|
|
||||||
|
The tracked `hyprlock.conf` remains the documented shipped fallback. A new
|
||||||
|
`scripts/panama-lock` helper generates the effective configuration at
|
||||||
|
`$XDG_STATE_HOME/panama/hyprlock.conf` and launches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hyprlock -c $XDG_STATE_HOME/panama/hyprlock.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper supports `generate`, `run`, and `status`. It reads only validated
|
||||||
|
schema values, writes through a temporary file followed by an atomic rename,
|
||||||
|
and never edits the repository symlink under `~/.config/hypr`.
|
||||||
|
|
||||||
|
Both the shipped and Panama-managed hypridle configurations use
|
||||||
|
`pidof hyprlock || panama-lock run` as `lock_cmd`. Existing `loginctl
|
||||||
|
lock-session` actions remain unchanged; hypridle receives the session lock
|
||||||
|
request and invokes the configured locker. If generation fails, `run` falls
|
||||||
|
back to the tracked `hyprlock.conf` rather than leaving the session unlocked.
|
||||||
|
|
||||||
|
Relevant preference changes coalesce into one regeneration. They never restart
|
||||||
|
or mutate a lock screen that is already active; the next lock uses the new
|
||||||
|
file.
|
||||||
|
|
||||||
|
### User-facing settings
|
||||||
|
|
||||||
|
Appearance owns a **Lock screen** card because these choices are visual. Power
|
||||||
|
continues to own blank, lock, and suspend timing, and Privacy continues to mirror
|
||||||
|
the two established security controls.
|
||||||
|
|
||||||
|
The card exposes:
|
||||||
|
|
||||||
|
- **Background**: blurred desktop, current wallpaper, or solid theme color.
|
||||||
|
- **Background blur**: Off through Strong, stored as a small integer level and
|
||||||
|
mapped by the generator to bounded blur passes and size.
|
||||||
|
- **Show clock**, **Show date**, and **Show user name**.
|
||||||
|
- **Hide password field until typing**, mapped to hyprlock's empty-field fade.
|
||||||
|
|
||||||
|
The clock follows the existing global 12/24-hour preference; there is no second
|
||||||
|
clock-format setting. Current-wallpaper mode resolves the effective wallpaper
|
||||||
|
for each monitor, with the shipped image as fallback. Solid mode uses the
|
||||||
|
current light/dark scheme's background role. User-authored markup, commands,
|
||||||
|
fonts, and arbitrary paths are not accepted.
|
||||||
|
|
||||||
|
An inline preview uses ordinary QML and the current Theme tokens. It shows the
|
||||||
|
chosen visibility and background treatment but does not start hyprlock or
|
||||||
|
capture the desktop. The preview is explicitly representative, not a second
|
||||||
|
renderer that promises pixel identity with hyprlock.
|
||||||
|
|
||||||
|
### Failure behavior
|
||||||
|
|
||||||
|
- Missing or malformed preferences use shipped defaults.
|
||||||
|
- An unavailable selected wallpaper falls back to the shipped image and reports
|
||||||
|
the fallback in Settings.
|
||||||
|
- Generation failure leaves the last valid generated file in place.
|
||||||
|
- `run` falling back to the tracked config is logged as a bounded diagnostic
|
||||||
|
status and remains visible in System Health.
|
||||||
|
- Lock authentication and PAM configuration are never made adjustable.
|
||||||
|
|
||||||
|
## Wallpaper modes
|
||||||
|
|
||||||
|
### Preference model
|
||||||
|
|
||||||
|
The existing `wallpaperPath` remains the single-image choice and migration
|
||||||
|
fallback. The schema adds:
|
||||||
|
|
||||||
|
- `wallpaperMode`: `single`, `slideshow`, or `per-monitor`.
|
||||||
|
- `wallpaperSlideshowPaths`: a validated JSON array of absolute image paths.
|
||||||
|
- `wallpaperIntervalMinutes`: an integer from 5 to 1,440.
|
||||||
|
- `wallpaperShuffle`: a boolean.
|
||||||
|
- `wallpaperPerMonitor`: a validated JSON object from connector name to image
|
||||||
|
path.
|
||||||
|
|
||||||
|
Path validation retains the existing absolute-path, no-comma, no-newline rule
|
||||||
|
because hyprpaper receives `output,path` as one IPC argument. The service also
|
||||||
|
requires a selected path to exist in its scanned image candidates before a UI
|
||||||
|
action stores it. Hand-edited missing paths are tolerated at load and skipped
|
||||||
|
with a visible warning.
|
||||||
|
|
||||||
|
### Runtime policy
|
||||||
|
|
||||||
|
`services/Wallpaper.qml` owns one effective path per connected output and
|
||||||
|
parses `hyprpaper listactive` into an output-to-path map.
|
||||||
|
|
||||||
|
- **Single** applies `wallpaperPath` to every output.
|
||||||
|
- **Per monitor** applies `wallpaperPerMonitor[output]`, falling back to
|
||||||
|
`wallpaperPath` when an output has no assignment.
|
||||||
|
- **Slideshow** rotates the selected collection on every output. Sequential
|
||||||
|
mode advances in collection order. Shuffle mode uses a shuffled in-memory bag
|
||||||
|
and does not repeat an image until every valid selected image has appeared.
|
||||||
|
|
||||||
|
The slideshow timer wakes only at the configured minute interval. It does not
|
||||||
|
rewrite `wallpaperPath` on every rotation. Its current item and shuffle bag are
|
||||||
|
runtime state; the policy and collection are the durable state.
|
||||||
|
|
||||||
|
Every manual policy change applies all connected outputs, reads `listactive`
|
||||||
|
back, and persists only after the expected output map matches. An automatic
|
||||||
|
slideshow transition keeps the previous policy on failure, reports the problem,
|
||||||
|
and retries at the next interval rather than entering a rapid retry loop.
|
||||||
|
|
||||||
|
When the connected-screen set changes, Wallpaper reapplies the current policy
|
||||||
|
to the new set after a short coalescing delay. There is no generic filesystem
|
||||||
|
watcher: Rescan remains explicit, and startup performs the existing bounded
|
||||||
|
scan.
|
||||||
|
|
||||||
|
### Settings experience
|
||||||
|
|
||||||
|
Appearance keeps the current thumbnail-first wallpaper card. A compact mode
|
||||||
|
control sits above the grid:
|
||||||
|
|
||||||
|
- In **Single**, tapping a tile immediately applies it everywhere.
|
||||||
|
- In **Slideshow**, tapping toggles membership in the collection. Interval and
|
||||||
|
shuffle controls appear beneath the mode row. The active image still receives
|
||||||
|
the Prism outline, while selected collection members receive a quieter
|
||||||
|
checkmark treatment.
|
||||||
|
- In **Per monitor**, a connected-display selector appears above the grid and
|
||||||
|
tapping assigns the tile to that output. Each output's current assignment is
|
||||||
|
named in the card summary.
|
||||||
|
|
||||||
|
Mode-specific controls disappear when irrelevant; the grid itself does not
|
||||||
|
change size or become a nested settings panel. Empty slideshow collections
|
||||||
|
explain how to select images and do not start a timer. A collection containing
|
||||||
|
one valid image behaves as a static background and says so.
|
||||||
|
|
||||||
|
## Search, reset, backup, and ownership
|
||||||
|
|
||||||
|
Search routes application volume to Sound, arrangement and primary display to
|
||||||
|
Displays, lock appearance to Appearance, and wallpaper automation to
|
||||||
|
Appearance. Lock timing continues to route to Power.
|
||||||
|
|
||||||
|
Schema-backed lock and wallpaper values participate automatically in reset and
|
||||||
|
snapshots. The extended `displays` value remains protected by the existing
|
||||||
|
display-restore transaction during snapshot restore. Restore order is:
|
||||||
|
|
||||||
|
1. Restore and validate preference files.
|
||||||
|
2. Apply the protected confirmed display layout.
|
||||||
|
3. Regenerate idle and lock configuration.
|
||||||
|
4. Apply wallpaper policy.
|
||||||
|
5. Reload the shell only after those operations settle or reach their bounded
|
||||||
|
failure state.
|
||||||
|
|
||||||
|
Reset returns to the shipped static wallpaper, blurred screenshot lock screen,
|
||||||
|
and shipped/automatic display layout. It never changes physical audio stream
|
||||||
|
volumes because those are not preferences.
|
||||||
|
|
||||||
|
The Settings ownership ledger is updated only for new search groups and links;
|
||||||
|
none of these controls creates a new cross-page mirror.
|
||||||
|
|
||||||
|
## Error presentation
|
||||||
|
|
||||||
|
Errors stay on the page and name the failed boundary: **PipeWire is
|
||||||
|
unavailable**, **The display layout could not be verified**, **The lock-screen
|
||||||
|
configuration could not be generated**, or **Hyprpaper did not apply that
|
||||||
|
background**. There is no generic "Something went wrong."
|
||||||
|
|
||||||
|
Previous valid state remains visible during refresh and apply. Busy controls
|
||||||
|
disable only the operation they conflict with. Display confirmation remains
|
||||||
|
pinned above the page because it is the only time-sensitive state in Phase 2.
|
||||||
|
|
||||||
|
## Verification strategy
|
||||||
|
|
||||||
|
Tests are written before each production slice and use real service behavior at
|
||||||
|
the narrowest safe boundary.
|
||||||
|
|
||||||
|
### Application audio
|
||||||
|
|
||||||
|
- Construct complete PipeWire-shaped stream fixtures and prove grouping,
|
||||||
|
labeling, average volume, mute normalization, and disappearing-node behavior.
|
||||||
|
- Render the real application mixer with multiple streams, missing metadata,
|
||||||
|
an empty stream list, and unavailable PipeWire state.
|
||||||
|
- Perform one final live read against current streams; it does not start audio
|
||||||
|
or alter any stream unless a disposable test stream is available.
|
||||||
|
|
||||||
|
### Displays
|
||||||
|
|
||||||
|
- Exercise layout normalization, edge snapping, validation, persistence, and
|
||||||
|
startup replay with literal multi-monitor fixtures.
|
||||||
|
- Prove confirmation remains disabled until every output matches.
|
||||||
|
- Prove timeout, explicit revert, apply failure, disconnect, and failed-revert
|
||||||
|
paths restore the complete original layout.
|
||||||
|
- Keep development tests static. Run the existing live single-monitor display
|
||||||
|
contract once at feature completion. Do not invent a second physical display
|
||||||
|
in the live compositor; multi-monitor behavior is tested through controlled
|
||||||
|
compositor fixtures.
|
||||||
|
|
||||||
|
### Lock screen
|
||||||
|
|
||||||
|
- Run `panama-lock generate` against isolated settings, state, and wallpaper
|
||||||
|
fixtures and compare parsed hyprlock values, not source-text fragments.
|
||||||
|
- Prove every background mode, visibility control, global clock format, invalid
|
||||||
|
input fallback, atomic replacement, last-good preservation, and run fallback.
|
||||||
|
- Validate generated configuration with hyprlock's config checker when the
|
||||||
|
installed version exposes one; otherwise run a parser-only disposable launch
|
||||||
|
that cannot acquire the live session lock.
|
||||||
|
- Never activate the live lock screen automatically during tests.
|
||||||
|
|
||||||
|
### Wallpaper
|
||||||
|
|
||||||
|
- Use a fake hyprpaper IPC boundary and real temporary image files to prove
|
||||||
|
output maps, verification-before-persist, fallback, collection validation,
|
||||||
|
sequential order, shuffle-without-repeat, and interval clamping.
|
||||||
|
- Prove automatic failures do not spin or rewrite policy.
|
||||||
|
- Perform one final live no-op readback against the already active wallpaper.
|
||||||
|
Do not cycle the user's desktop through test images.
|
||||||
|
|
||||||
|
### Consolidated completion gate
|
||||||
|
|
||||||
|
- Run all new contracts and the existing Settings, Sound, Displays, backup,
|
||||||
|
ownership, migration, schema, and Hyprland configuration contracts once.
|
||||||
|
- Construct every changed QML surface without warnings in isolated harnesses.
|
||||||
|
- Run `Hyprland --verify-config` once.
|
||||||
|
- Restart Quickshell once after merge, inspect the fresh log, and visually
|
||||||
|
review the four finished experiences. Avoid repeated windows, reloads, and
|
||||||
|
live display changes during development.
|
||||||
|
|
||||||
|
## Delivery order
|
||||||
|
|
||||||
|
The phase is implemented as four independently green slices on one feature
|
||||||
|
branch:
|
||||||
|
|
||||||
|
1. Application mixer — event-driven and lowest risk.
|
||||||
|
2. Generated lock appearance — establishes the safe state-file pattern.
|
||||||
|
3. Wallpaper policy — reuses that state discipline and extends existing IPC.
|
||||||
|
4. Display arrangement — highest-risk slice, implemented after the supporting
|
||||||
|
Settings patterns and contracts are settled.
|
||||||
|
|
||||||
|
Each slice receives a focused commit after its contracts pass. The branch is
|
||||||
|
merged only when the consolidated completion gate passes. The visual mock is a
|
||||||
|
design reference, not a production dependency and is not committed.
|
||||||
@@ -89,6 +89,38 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
|
|||||||
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
|
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# hyprlock.conf is generated from a template on every colour scheme change and
|
||||||
|
# is not committed. Seed it so the FIRST lock of a fresh install is themed --
|
||||||
|
# without it hyprlock falls back to its own defaults, which is a bare grey
|
||||||
|
# screen with none of this desktop's identity, and the first time anyone would
|
||||||
|
# find out is when they walked away from the machine.
|
||||||
|
HYPRLOCK_TEMPLATE="$PANAMA_DOT/hypr/hyprlock.conf.template"
|
||||||
|
HYPRLOCK_CONF="$PANAMA_DOT/hypr/hyprlock.conf"
|
||||||
|
if [ -e "$HYPRLOCK_CONF" ]; then
|
||||||
|
log "Keeping existing hyprlock config at $HYPRLOCK_CONF"
|
||||||
|
elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
|
||||||
|
lock_scheme="dark"
|
||||||
|
lock_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||||
|
if [ -r "$lock_prefs" ]; then
|
||||||
|
lock_stored="$(jq -r '.colorScheme // "dark"' "$lock_prefs" 2>/dev/null || echo dark)"
|
||||||
|
[ "$lock_stored" = "light" ] && lock_scheme="light"
|
||||||
|
fi
|
||||||
|
if [ "$lock_scheme" = "light" ]; then
|
||||||
|
sed -e "s/@FG@/55, 96, 191/g" -e "s/@MUTED@/97, 114, 176/g" \
|
||||||
|
-e "s/@ACCENT@/46, 125, 233/g" -e "s/@ERROR@/245, 42, 101/g" \
|
||||||
|
-e "s/@BG@/225, 226, 231/g" -e "s/@FIELD@/208, 213, 227/g" \
|
||||||
|
-e "s/@MUTED_HEX@/6172b0/g" -e "s/@ERROR_HEX@/f52a65/g" \
|
||||||
|
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
|
||||||
|
else
|
||||||
|
sed -e "s/@FG@/200, 211, 245/g" -e "s/@MUTED@/130, 139, 184/g" \
|
||||||
|
-e "s/@ACCENT@/130, 170, 255/g" -e "s/@ERROR@/255, 117, 127/g" \
|
||||||
|
-e "s/@BG@/34, 36, 54/g" -e "s/@FIELD@/46, 47, 61/g" \
|
||||||
|
-e "s/@MUTED_HEX@/828bb8/g" -e "s/@ERROR_HEX@/ff757f/g" \
|
||||||
|
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
|
||||||
|
fi
|
||||||
|
log "Seeded hyprlock $lock_scheme theme → $HYPRLOCK_CONF"
|
||||||
|
fi
|
||||||
|
|
||||||
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
||||||
# rewrites that file on exit -- so only the theme files are exposed, per file,
|
# rewrites that file on exit -- so only the theme files are exposed, per file,
|
||||||
# and the config itself is left to btop. panama-theme-apps edits the single
|
# and the config itself is left to btop. panama-theme-apps edits the single
|
||||||
|
|||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/audio-streams-harness.qml"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-application-volume-state.XXXXXX)"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
harness_pid=""
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'application volume contract: %s\n' "$1" >&2
|
||||||
|
[[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
instances_for_harness() {
|
||||||
|
qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||||
|
/^Instance / { pid = "" }
|
||||||
|
/^[[:space:]]*Process ID:/ { pid = $3 }
|
||||||
|
/^[[:space:]]*Config path:/ {
|
||||||
|
path = $0
|
||||||
|
sub(/^[[:space:]]*Config path: /, "", path)
|
||||||
|
if (path == expected && pid ~ /^[0-9]+$/) print pid
|
||||||
|
}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
|
||||||
|
kill "$harness_pid" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
kill -0 "$harness_pid" 2>/dev/null || break
|
||||||
|
sleep 0.05
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
||||||
|
else
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated harness did not launch'
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
harness_pid="$(instances_for_harness | head -1)"
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||||
|
&& qs_for_harness ipc show 2>/dev/null | rg -q '^target application-volume-test$'; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated harness process did not start'
|
||||||
|
qs_for_harness ipc show 2>/dev/null | rg -q '^target application-volume-test$' \
|
||||||
|
|| fail 'application-volume-test IPC target did not register'
|
||||||
|
|
||||||
|
summary="$(qs_for_harness ipc call application-volume-test summary)"
|
||||||
|
expected_groups='[
|
||||||
|
{"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}
|
||||||
|
]'
|
||||||
|
jq -e --argjson expected "$expected_groups" \
|
||||||
|
'.groups == $expected and (.chromiumVolume - 0.6 | fabs) < 0.000001 and .chromiumMuted == false' \
|
||||||
|
<<<"$summary" >/dev/null || fail "unexpected grouping summary: $summary"
|
||||||
|
|
||||||
|
volume_result="$(qs_for_harness ipc call application-volume-test mutateVolume)"
|
||||||
|
jq -e '.changed == true and .volumes == [0.7, 0.7] and .muted == [false, false]' \
|
||||||
|
<<<"$volume_result" >/dev/null || fail "volume mutation did not reach every stream: $volume_result"
|
||||||
|
|
||||||
|
mute_result="$(qs_for_harness ipc call application-volume-test mutateMute)"
|
||||||
|
jq -e '.changed == true and .muted == [true, true]' <<<"$mute_result" >/dev/null \
|
||||||
|
|| fail "mute mutation did not reach every stream: $mute_result"
|
||||||
|
|
||||||
|
service_summary="$(qs_for_harness ipc call application-volume-test serviceSummary)"
|
||||||
|
jq -e '.count >= 0 and .validTypes == true' <<<"$service_summary" >/dev/null \
|
||||||
|
|| fail "live service exposed invalid playback groups: $service_summary"
|
||||||
|
|
||||||
|
invalid_result="$(qs_for_harness ipc call application-volume-test invalidMutations)"
|
||||||
|
jq -e '.nullVolume == false and .emptyMute == false' <<<"$invalid_result" >/dev/null \
|
||||||
|
|| fail "service mutators accepted missing audio nodes: $invalid_result"
|
||||||
|
|
||||||
|
if rg -n 'ReferenceError|TypeError|Binding loop|Cannot assign|PwObjectTracker' "$shell_log"; then
|
||||||
|
fail 'isolated harness emitted a QML runtime error'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'application volume contract: PASS\n'
|
||||||
@@ -33,6 +33,9 @@ done
|
|||||||
assert_contains 'title: "Default applications"'
|
assert_contains 'title: "Default applications"'
|
||||||
assert_contains 'title: "User autostart"'
|
assert_contains 'title: "User autostart"'
|
||||||
assert_contains 'title: "Compositor autostart"'
|
assert_contains 'title: "Compositor autostart"'
|
||||||
|
assert_contains 'AutostartAppPicker {'
|
||||||
|
assert_contains 'DefaultApps.addAutostart('
|
||||||
|
assert_contains 'label: "Add an application"'
|
||||||
assert_contains 'categories'
|
assert_contains 'categories'
|
||||||
assert_contains 'genericName'
|
assert_contains 'genericName'
|
||||||
assert_contains '.sort('
|
assert_contains '.sort('
|
||||||
@@ -123,4 +126,14 @@ fi
|
|||||||
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|
||||||
|| fail 'default and autostart rows are not both whole-row activatable'
|
|| fail 'default and autostart rows are not both whole-row activatable'
|
||||||
|
|
||||||
|
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
|
||||||
|
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
|
||||||
|
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
|
||||||
|
rg -Fq 'required property var existing' "$picker" \
|
||||||
|
|| fail 'autostart picker cannot exclude existing entries'
|
||||||
|
rg -Fq 'signal picked(string id)' "$picker" \
|
||||||
|
|| fail 'autostart picker does not emit a validated desktop id'
|
||||||
|
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|
||||||
|
|| fail 'autostart picker is not registered in the Settings module'
|
||||||
|
|
||||||
printf 'applications settings contract: PASS\n'
|
printf 'applications settings contract: PASS\n'
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ assert_service_contains 'property string lastError'
|
|||||||
assert_service_contains 'function refresh(): void'
|
assert_service_contains 'function refresh(): void'
|
||||||
assert_service_contains 'function setDefault(role: string, desktopId: string): void'
|
assert_service_contains 'function setDefault(role: string, desktopId: string): void'
|
||||||
assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void'
|
assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void'
|
||||||
|
assert_service_contains 'function addAutostart(desktopId: string): void'
|
||||||
assert_service_contains 'DesktopEntries.applications.values'
|
assert_service_contains 'DesktopEntries.applications.values'
|
||||||
if rg --quiet 'command\s*:\s*"' "$service"; then
|
if rg --quiet 'command\s*:\s*"' "$service"; then
|
||||||
fail 'Process command must be an argument array'
|
fail 'Process command must be an argument array'
|
||||||
@@ -227,4 +228,27 @@ if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then
|
|||||||
fail 'read-only compositor entry was accepted for mutation'
|
fail 'read-only compositor entry was accepted for mutation'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
$helper add-autostart org.mozilla.firefox.desktop
|
||||||
|
firefox_autostart="$config_home/autostart/org.mozilla.firefox.desktop"
|
||||||
|
[[ -f "$firefox_autostart" && ! -L "$firefox_autostart" ]] \
|
||||||
|
|| fail 'adding an installed application did not create a regular user autostart entry'
|
||||||
|
rg --quiet '^Name=Firefox$' "$firefox_autostart" \
|
||||||
|
|| fail 'adding an application did not preserve its desktop entry'
|
||||||
|
rg --quiet '^Hidden=false$' "$firefox_autostart" \
|
||||||
|
|| fail 'a newly added application was not enabled'
|
||||||
|
[[ "$(rg --count '^Hidden=' "$firefox_autostart")" == "1" ]] \
|
||||||
|
|| fail 'adding an application wrote more than one Hidden key'
|
||||||
|
|
||||||
|
$helper set-autostart org.mozilla.firefox.desktop false
|
||||||
|
$helper add-autostart org.mozilla.firefox.desktop
|
||||||
|
rg --quiet '^Hidden=false$' "$firefox_autostart" \
|
||||||
|
|| fail 'adding an existing disabled application did not re-enable it'
|
||||||
|
|
||||||
|
if $helper add-autostart org.example.Missing.desktop >/dev/null 2>&1; then
|
||||||
|
fail 'an undiscovered application was accepted for autostart'
|
||||||
|
fi
|
||||||
|
if $helper add-autostart ../escape.desktop >/dev/null 2>&1; then
|
||||||
|
fail 'an unsafe desktop id was accepted for autostart'
|
||||||
|
fi
|
||||||
|
|
||||||
printf 'default apps contract: PASS\n'
|
printf 'default apps contract: PASS\n'
|
||||||
|
|||||||
Executable
+210
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Desktop style stays split across three boundaries that can all be checked
|
||||||
|
# without starting the shell: the read-only icon catalog, the preference/schema
|
||||||
|
# wiring, and the QML surfaces that consume it. The catalog runs only against
|
||||||
|
# disposable XDG roots so this contract never reads or changes the live desktop.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-desktop-style"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/DesktopStyle.qml"
|
||||||
|
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||||
|
appearance="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
|
||||||
|
mouse="$repo_dir/config/dot/quickshell/modules/settings/MousePage.qml"
|
||||||
|
accessibility="$repo_dir/config/dot/quickshell/services/Accessibility.qml"
|
||||||
|
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||||
|
env_lua="$repo_dir/config/dot/hypr/env.lua"
|
||||||
|
looks_lua="$repo_dir/config/dot/hypr/looks.lua"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'desktop style contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in "$helper" "$service" "$schema" "$appearance" "$mouse" \
|
||||||
|
"$accessibility" "$search" "$env_lua" "$looks_lua"; do
|
||||||
|
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||||
|
done
|
||||||
|
[[ -x "$helper" ]] || fail 'desktop-style catalog is not executable'
|
||||||
|
|
||||||
|
# ── Read-only catalog semantics ─────────────────────────────────────────────
|
||||||
|
fixture="$(mktemp -d /tmp/panama-desktop-style.XXXXXX)"
|
||||||
|
trap 'rm -rf "$fixture"' EXIT
|
||||||
|
|
||||||
|
home="$fixture/home"
|
||||||
|
data_home="$fixture/data-home"
|
||||||
|
data_one="$fixture/data-one"
|
||||||
|
data_two="$fixture/data-two"
|
||||||
|
mkdir -p "$home" \
|
||||||
|
"$data_home/icons/CursorOnly/cursors" \
|
||||||
|
"$data_one/icons/IconOnly/16x16/apps" \
|
||||||
|
"$data_one/icons/Both/cursors" \
|
||||||
|
"$data_one/icons/Both/scalable/apps" \
|
||||||
|
"$data_one/icons/EmptyDirectories" \
|
||||||
|
"$data_two/icons/Both/cursors" \
|
||||||
|
"$data_two/icons/Both/scalable/apps" \
|
||||||
|
"$data_two/icons/NoIndex/16x16/apps"
|
||||||
|
|
||||||
|
cat >"$data_home/icons/CursorOnly/index.theme" <<'EOF'
|
||||||
|
[Icon Theme]
|
||||||
|
Name=Cursor only
|
||||||
|
Directories=
|
||||||
|
EOF
|
||||||
|
cat >"$data_one/icons/IconOnly/index.theme" <<'EOF'
|
||||||
|
[Icon Theme]
|
||||||
|
Name=Icon only
|
||||||
|
Directories=16x16/apps
|
||||||
|
EOF
|
||||||
|
cat >"$data_one/icons/Both/index.theme" <<'EOF'
|
||||||
|
[Icon Theme]
|
||||||
|
Name=Both
|
||||||
|
Directories=scalable/apps
|
||||||
|
EOF
|
||||||
|
cat >"$data_one/icons/EmptyDirectories/index.theme" <<'EOF'
|
||||||
|
[Icon Theme]
|
||||||
|
Name=Not an icon catalog entry
|
||||||
|
Directories=
|
||||||
|
EOF
|
||||||
|
cp "$data_one/icons/Both/index.theme" "$data_two/icons/Both/index.theme"
|
||||||
|
|
||||||
|
catalog="$(
|
||||||
|
HOME="$home" \
|
||||||
|
XDG_DATA_HOME="$data_home" \
|
||||||
|
XDG_DATA_DIRS="$data_one:$data_two" \
|
||||||
|
"$helper"
|
||||||
|
)" || fail 'catalog helper failed against disposable XDG roots'
|
||||||
|
|
||||||
|
jq -e '
|
||||||
|
type == "object"
|
||||||
|
and (keys | sort) == ["cursorThemes", "iconThemes"]
|
||||||
|
and .cursorThemes == ["Both", "CursorOnly"]
|
||||||
|
and .iconThemes == ["Both", "IconOnly"]
|
||||||
|
and (all(.cursorThemes[]; type == "string" and length > 0))
|
||||||
|
and (all(.iconThemes[]; type == "string" and length > 0))
|
||||||
|
' <<<"$catalog" >/dev/null \
|
||||||
|
|| fail "catalog JSON shape or theme classification is wrong: $catalog"
|
||||||
|
|
||||||
|
if HOME="$home" XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_one:$data_two" \
|
||||||
|
"$helper" "$fixture/not-an-xdg-root" >/dev/null 2>&1; then
|
||||||
|
fail 'catalog accepted a caller-supplied path'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Schema, compositor replay, service, and UI wiring ───────────────────────
|
||||||
|
python3 - "$schema" <<'PY' || fail 'desktop-style schema entries are missing or malformed'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"cursorTheme": ("string", '"oreo_blue_cursors"', "themes"),
|
||||||
|
"iconTheme": ("string", '"Adwaita"', "themes"),
|
||||||
|
"applicationFont": ("string", '"Adwaita Sans"', "typography"),
|
||||||
|
"applicationFontSize": ("int", "11", "typography"),
|
||||||
|
"documentFont": ("string", '"Adwaita Sans"', "typography"),
|
||||||
|
"documentFontSize": ("int", "12", "typography"),
|
||||||
|
"monospaceFont": ("string", '"VictorMono Nerd Font"', "typography"),
|
||||||
|
"monospaceFontSize": ("int", "10", "typography"),
|
||||||
|
"fontHinting": ("enum", '"slight"', "typography"),
|
||||||
|
"fontAntialiasing": ("enum", '"rgba"', "typography"),
|
||||||
|
"titlebarButtonSide": ("enum", '"right"', "titlebar"),
|
||||||
|
"titlebarMaximizeButton": ("bool", "false", "titlebar"),
|
||||||
|
"titlebarDoubleClick": ("enum", '"toggle-maximize"', "titlebar"),
|
||||||
|
"middleClickPaste": ("bool", "true", "pointer"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def block_for(key: str) -> str:
|
||||||
|
match = re.search(r"\{\s*\n\s*key:\s*\"" + re.escape(key) + r"\".*?\n\s{8}\}", text, re.S)
|
||||||
|
if not match:
|
||||||
|
raise SystemExit(f"missing {key}")
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
for key, (kind, default, group) in expected.items():
|
||||||
|
block = block_for(key)
|
||||||
|
if not re.search(rf'type:\s*"{re.escape(kind)}"', block):
|
||||||
|
raise SystemExit(f"{key} has wrong type")
|
||||||
|
if not re.search(rf'def:\s*{re.escape(default)}', block):
|
||||||
|
raise SystemExit(f"{key} has wrong default")
|
||||||
|
if not re.search(rf'group:\s*"{re.escape(group)}"', block):
|
||||||
|
raise SystemExit(f"{key} has wrong group")
|
||||||
|
|
||||||
|
enum_values = {
|
||||||
|
"fontHinting": ["none", "slight", "medium", "full"],
|
||||||
|
"fontAntialiasing": ["none", "grayscale", "rgba"],
|
||||||
|
"titlebarButtonSide": ["left", "right"],
|
||||||
|
"titlebarDoubleClick": ["toggle-maximize", "none"],
|
||||||
|
}
|
||||||
|
for key, values in enum_values.items():
|
||||||
|
block = block_for(key)
|
||||||
|
actual = re.findall(r'value:\s*"([^"]+)"', block)
|
||||||
|
if actual != values:
|
||||||
|
raise SystemExit(f"{key} options are {actual}, expected {values}")
|
||||||
|
|
||||||
|
middle = block_for("middleClickPaste")
|
||||||
|
for fragment in (
|
||||||
|
'path: ["misc", "middle_click_paste"]',
|
||||||
|
'option: "misc:middle_click_paste"',
|
||||||
|
'readAs: "bool"',
|
||||||
|
):
|
||||||
|
if fragment not in middle:
|
||||||
|
raise SystemExit(f"middleClickPaste is missing {fragment}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
rg -q 'prefs\.get\("cursorTheme", "oreo_blue_cursors"\)' "$env_lua" \
|
||||||
|
|| fail 'Hyprland environment does not replay cursorTheme'
|
||||||
|
rg -q 'middle_click_paste\s*=\s*prefs\.get\("middleClickPaste", true\)' "$looks_lua" \
|
||||||
|
|| fail 'Hyprland misc does not replay middleClickPaste'
|
||||||
|
|
||||||
|
rg -q 'DesktopPreferences\.get\("cursorTheme"\)' "$accessibility" \
|
||||||
|
|| fail 'Accessibility does not use the stored cursor theme'
|
||||||
|
! rg -q 'gsettings.*cursor-theme|themeQuery' "$accessibility" \
|
||||||
|
|| fail 'Accessibility still queries cursor-theme from gsettings'
|
||||||
|
|
||||||
|
for needle in \
|
||||||
|
'property var cursorThemes' \
|
||||||
|
'property var iconThemes' \
|
||||||
|
'DesktopPreferences.revision' \
|
||||||
|
'Fonts.interfaceFonts' \
|
||||||
|
'Fonts.monospaceFonts' \
|
||||||
|
'gtk-enable-primary-paste' \
|
||||||
|
'button-layout' \
|
||||||
|
'action-double-click-titlebar'; do
|
||||||
|
rg -Fq "$needle" "$service" || fail "DesktopStyle is missing $needle"
|
||||||
|
done
|
||||||
|
! rg -q 'sh -c|bash -c' "$service" \
|
||||||
|
|| fail 'DesktopStyle routes gsettings through a shell'
|
||||||
|
|
||||||
|
for group in typography themes titlebar; do
|
||||||
|
rg -q "\"$group\": \"appearance\"" "$search" \
|
||||||
|
|| fail "settings search does not route $group to Appearance"
|
||||||
|
done
|
||||||
|
rg -q '"pointer": "mouse"' "$search" \
|
||||||
|
|| fail 'settings search no longer routes pointer controls to Mouse'
|
||||||
|
|
||||||
|
for needle in \
|
||||||
|
'title: "Application typography"' \
|
||||||
|
'setting: "applicationFontSize"' \
|
||||||
|
'setting: "documentFontSize"' \
|
||||||
|
'setting: "monospaceFontSize"' \
|
||||||
|
'setting: "fontHinting"' \
|
||||||
|
'setting: "fontAntialiasing"' \
|
||||||
|
'title: "Icons & pointer"' \
|
||||||
|
'DesktopStyle.cursorThemes' \
|
||||||
|
'DesktopStyle.iconThemes' \
|
||||||
|
'title: "Titlebars"' \
|
||||||
|
'setting: "titlebarButtonSide"' \
|
||||||
|
'setting: "titlebarMaximizeButton"' \
|
||||||
|
'setting: "titlebarDoubleClick"'; do
|
||||||
|
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
|
||||||
|
done
|
||||||
|
! rg -q 'setting: "titlebarMinimize|GTK theme|Shell theme' "$appearance" \
|
||||||
|
|| fail 'Appearance exposes an inert or separately-owned theme control'
|
||||||
|
|
||||||
|
rg -q 'setting: "middleClickPaste"' "$mouse" \
|
||||||
|
|| fail 'Mouse does not expose middle-click paste'
|
||||||
|
rg -q 'GTK.*Wayland|Wayland.*GTK' "$mouse" \
|
||||||
|
|| fail 'Mouse does not explain the GTK and Wayland scope honestly'
|
||||||
|
|
||||||
|
printf 'desktop style contract: PASS\n'
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
component="$repo_dir/config/dot/quickshell/modules/settings/DisplayArrangement.qml"
|
||||||
|
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/display-arrangement-harness.qml"
|
||||||
|
identify="$repo_dir/config/dot/quickshell/modules/settings/DisplayIdentify.qml"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
||||||
|
shell="$repo_dir/config/dot/quickshell/shell.qml"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'display arrangement contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f "$component" ]] || fail 'DisplayArrangement.qml is missing'
|
||||||
|
for contract in \
|
||||||
|
'required property var displayService' \
|
||||||
|
'property var draftLayout:' \
|
||||||
|
'function setDraftPosition(' \
|
||||||
|
'function nudge(' \
|
||||||
|
'function applyDraft(' \
|
||||||
|
'function makePrimary(' \
|
||||||
|
'Accessible.name: "Move "' \
|
||||||
|
'DragHandler {' \
|
||||||
|
'Keys.onPressed:'; do
|
||||||
|
rg -Fq "$contract" "$component" \
|
||||||
|
|| fail "arrangement interaction is missing: $contract"
|
||||||
|
done
|
||||||
|
rg -Fq 'DisplayArrangement {' "$page" \
|
||||||
|
|| fail 'Displays page does not expose the arrangement canvas'
|
||||||
|
rg -Fq 'visible: Displays.monitors.length > 1' "$page" \
|
||||||
|
|| fail 'arrangement is shown for a single display'
|
||||||
|
[[ -f "$identify" ]] || fail 'DisplayIdentify.qml is missing'
|
||||||
|
for contract in \
|
||||||
|
'model: Quickshell.screens' \
|
||||||
|
'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' \
|
||||||
|
'mask: Region {}' \
|
||||||
|
'visible: Displays.identifying' \
|
||||||
|
'text: String(win.number)' \
|
||||||
|
'text: win.connector'; do
|
||||||
|
rg -Fq "$contract" "$identify" \
|
||||||
|
|| fail "display identification overlay is incomplete: $contract"
|
||||||
|
done
|
||||||
|
rg -Fq 'DisplayIdentify {}' "$shell" \
|
||||||
|
|| fail 'display identification overlays are not shell-owned'
|
||||||
|
rg -Fq 'function identify()' "$service" \
|
||||||
|
&& rg -Fq 'interval: 3000' "$service" \
|
||||||
|
|| fail 'display identification does not use one three-second service timer'
|
||||||
|
|
||||||
|
state_home="$(mktemp -d /tmp/panama-display-arrangement-state.XXXXXX)"
|
||||||
|
harness_pid=""
|
||||||
|
cleanup() {
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||||
|
/^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3}
|
||||||
|
/^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)"
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||||
|
&& XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" show 2>/dev/null \
|
||||||
|
| rg -q '^target display-arrangement-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'arrangement harness did not start'
|
||||||
|
|
||||||
|
ipc() {
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" \
|
||||||
|
call display-arrangement-test "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
wide="$(ipc status 800)"
|
||||||
|
narrow="$(ipc status 500)"
|
||||||
|
for snapshot in "$wide" "$narrow"; do
|
||||||
|
jq -e '(.rects | length) == 2
|
||||||
|
and .scale > 0
|
||||||
|
and (.rects[0].width / .rects[0].height - 1.5 | fabs) < 0.0001
|
||||||
|
and (.rects[1].width / .rects[1].height - (2560 / 1440) | fabs) < 0.0001' \
|
||||||
|
<<<"$snapshot" >/dev/null \
|
||||||
|
|| fail "canvas lost monitor geometry at a supported width: $snapshot"
|
||||||
|
done
|
||||||
|
|
||||||
|
drag="$(ipc dragFixture)"
|
||||||
|
jq -e '.[0].x == 0 and .[1].x == 3000' <<<"$drag" >/dev/null \
|
||||||
|
|| fail "drag release did not snap and apply the complete layout: $drag"
|
||||||
|
|
||||||
|
keyboard="$(ipc keyboardFixture)"
|
||||||
|
jq -e '.afterArrow == 2990 and .afterShiftArrow == 2890' <<<"$keyboard" >/dev/null \
|
||||||
|
|| fail "keyboard movement did not use 10/100 logical-pixel steps: $keyboard"
|
||||||
|
|
||||||
|
primary="$(ipc primaryFixture)"
|
||||||
|
jq -e '. == [
|
||||||
|
{"name":"DP-2","x":-3000,"y":0,"primary":false},
|
||||||
|
{"name":"HDMI-A-1","x":0,"y":0,"primary":true}
|
||||||
|
]' <<<"$primary" >/dev/null \
|
||||||
|
|| fail "Make primary did not normalize the selected output to 0,0: $primary"
|
||||||
|
|
||||||
|
ipc identify >/dev/null
|
||||||
|
[[ "$(ipc identifying)" == "true" ]] \
|
||||||
|
|| fail 'identify did not reveal the overlays'
|
||||||
|
sleep 3.2
|
||||||
|
[[ "$(ipc identifying)" == "false" ]] \
|
||||||
|
|| fail 'identify overlays did not disappear after one three-second timer'
|
||||||
|
|
||||||
|
printf 'display arrangement contract: PASS\n'
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/display-layout-harness.qml"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-display-layout-state.XXXXXX)"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
harness_pid=""
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'display layout contract: %s\n' "$1" >&2
|
||||||
|
[[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
||||||
|
else
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'geometry harness did not launch'
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||||
|
/^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3}
|
||||||
|
/^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)"
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||||
|
&& qs_for_harness ipc show 2>/dev/null | rg -q '^target display-layout-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'geometry harness process did not start'
|
||||||
|
|
||||||
|
status="$(qs_for_harness ipc call display-layout-test status)"
|
||||||
|
jq -e '.valid == true
|
||||||
|
and .sizes == [{"width":3000,"height":2000},{"width":1440,"height":2560}]
|
||||||
|
and .normalized == [
|
||||||
|
{"name":"DP-2","x":0,"y":0,"primary":true},
|
||||||
|
{"name":"HDMI-A-1","x":3000,"y":0,"primary":false}]
|
||||||
|
and .bounds == {"x":0,"y":0,"width":4440,"height":2560}
|
||||||
|
and .near == 3000 and .far == 3017
|
||||||
|
and (.canvasRects | length) == 2
|
||||||
|
and ((.bounds.width / .bounds.height) - (4440 / 2560) | fabs) < 0.000001' \
|
||||||
|
<<<"$status" >/dev/null || fail "geometry output was wrong: $status"
|
||||||
|
|
||||||
|
invalid="$(qs_for_harness ipc call display-layout-test invalid)"
|
||||||
|
jq -e 'all(. == false)' <<<"$invalid" >/dev/null || fail "an invalid layout was accepted: $invalid"
|
||||||
|
|
||||||
|
printf 'display layout contract: PASS\n'
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'display transaction contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for contract in \
|
||||||
|
'property var pendingPreviousLayout: null' \
|
||||||
|
'property var pendingRequestedLayout: null' \
|
||||||
|
'property var revertExpectedLayout: null' \
|
||||||
|
'function currentLayout()' \
|
||||||
|
'function applyLayout(layout:' \
|
||||||
|
'function makePrimary(output:' \
|
||||||
|
'function matchesLayout(monitors:' \
|
||||||
|
'function pushLayout(layout:'; do
|
||||||
|
rg -Fq "$contract" "$service" \
|
||||||
|
|| fail "complete-layout service boundary is missing: $contract"
|
||||||
|
done
|
||||||
|
|
||||||
|
rg -Fq 'position = "${record.x}x${record.y}"' "$service" \
|
||||||
|
|| fail 'the compositor payload does not include explicit positions'
|
||||||
|
rg -Fq 'generation === root.operationGeneration' "$service" \
|
||||||
|
|| fail 'stale monitor readback can settle a newer transaction'
|
||||||
|
rg -Fq 'function applyLayoutFixture(' "$harness" \
|
||||||
|
|| fail 'the fixture cannot exercise complete layout transactions'
|
||||||
|
rg -Fq 'function injectReadback(' "$harness" \
|
||||||
|
|| fail 'the fixture cannot prove stale readback isolation'
|
||||||
|
|
||||||
|
fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)"
|
||||||
|
state_home="$fixture/state-home"
|
||||||
|
config_home="$fixture/config-home"
|
||||||
|
fake_bin="$fixture/bin"
|
||||||
|
monitor_state="$fixture/monitors.json"
|
||||||
|
eval_log="$fixture/eval.log"
|
||||||
|
harness_pid=""
|
||||||
|
mkdir -p "$state_home" "$config_home" "$fake_bin"
|
||||||
|
|
||||||
|
cat >"$monitor_state" <<'JSON'
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name":"DP-2","description":"Primary fixture","width":4500,"height":3000,
|
||||||
|
"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,
|
||||||
|
"availableModes":["[email protected]"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
||||||
|
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0,
|
||||||
|
"availableModes":["[email protected]"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat >"$fake_bin/hyprctl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
fixture="${PANAMA_DISPLAY_FIXTURE:?}"
|
||||||
|
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
||||||
|
cat "$fixture/monitors.json"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-}" != "eval" ]]; then
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload="${2:-}"
|
||||||
|
printf '%s\n' "$payload" >>"$fixture/eval.log"
|
||||||
|
if [[ -f "$fixture/fail-once" ]]; then
|
||||||
|
rm -f "$fixture/fail-once"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ -f "$fixture/no-apply" ]] && exit 0
|
||||||
|
|
||||||
|
python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
state_path = pathlib.Path(sys.argv[1])
|
||||||
|
payload = sys.argv[2]
|
||||||
|
wrong_y = pathlib.Path(sys.argv[3]).exists()
|
||||||
|
monitors = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
by_name = {monitor["name"]: monitor for monitor in monitors}
|
||||||
|
|
||||||
|
for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload):
|
||||||
|
def field(pattern: str) -> str:
|
||||||
|
match = re.search(pattern, block)
|
||||||
|
if not match:
|
||||||
|
raise SystemExit(f"missing field {pattern}: {block}")
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
name = field(r'output\s*=\s*"([A-Za-z0-9_.-]+)"')
|
||||||
|
mode = field(r'mode\s*=\s*"(\d+x\d+@\d+(?:\.\d+)?)"')
|
||||||
|
position = re.search(r'position\s*=\s*"(-?\d+)x(-?\d+)"', block)
|
||||||
|
if not position or name not in by_name:
|
||||||
|
raise SystemExit(f"bad output or position: {block}")
|
||||||
|
width, height, refresh = re.match(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", mode).groups()
|
||||||
|
monitor = by_name[name]
|
||||||
|
monitor.update({
|
||||||
|
"width": int(width),
|
||||||
|
"height": int(height),
|
||||||
|
"refreshRate": float(refresh),
|
||||||
|
"scale": float(field(r"scale\s*=\s*([0-9.]+)")),
|
||||||
|
"transform": int(field(r"transform\s*=\s*(\d+)")),
|
||||||
|
"x": int(position.group(1)),
|
||||||
|
"y": int(position.group(2)),
|
||||||
|
})
|
||||||
|
if wrong_y and name == "HDMI-A-1":
|
||||||
|
monitor["y"] += 1
|
||||||
|
|
||||||
|
state_path.write_text(json.dumps(monitors), encoding="utf-8")
|
||||||
|
PY
|
||||||
|
SH
|
||||||
|
chmod +x "$fake_bin/hyprctl"
|
||||||
|
|
||||||
|
export PANAMA_DISPLAY_FIXTURE="$fixture"
|
||||||
|
|
||||||
|
run() {
|
||||||
|
PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
|
||||||
|
qs -p "$harness" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction_status() {
|
||||||
|
run ipc --pid "$harness_pid" call displays-test transactionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true
|
||||||
|
rm -rf "$fixture"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
|
||||||
|
qs -p "$harness" --daemonize >/dev/null
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||||
|
/^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3}
|
||||||
|
/^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)"
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
|
||||||
|
ready="$(transaction_status 2>/dev/null || true)"
|
||||||
|
jq -e '.layout | length == 2' <<<"$ready" >/dev/null 2>&1 && break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'fixture shell did not start'
|
||||||
|
|
||||||
|
wait_for() {
|
||||||
|
local expression="$1"
|
||||||
|
local value=""
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
value="$(transaction_status)"
|
||||||
|
jq -e "$expression" <<<"$value" >/dev/null && { printf '%s' "$value"; return 0; }
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail "timed out waiting for $expression: $value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A complete request is one evaluator call carrying every connected output.
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 100)" == "true" ]] \
|
||||||
|
|| fail 'valid complete layout was refused'
|
||||||
|
wait_for '.canConfirm == true' >/dev/null
|
||||||
|
first_payload="$(sed -n '1p' "$eval_log")"
|
||||||
|
[[ "$(rg -o 'hl\.monitor' <<<"$first_payload" | wc -l)" == "2" ]] \
|
||||||
|
|| fail "layout was not sent as one complete payload: $first_payload"
|
||||||
|
rg -Fq 'output = "DP-2"' <<<"$first_payload" \
|
||||||
|
&& rg -Fq 'position = "0x0"' <<<"$first_payload" \
|
||||||
|
&& rg -Fq 'output = "HDMI-A-1"' <<<"$first_payload" \
|
||||||
|
&& rg -Fq 'position = "3000x100"' <<<"$first_payload" \
|
||||||
|
|| fail "layout payload omitted a literal output position: $first_payload"
|
||||||
|
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "true" ]] \
|
||||||
|
|| fail 'verified complete layout could not be kept'
|
||||||
|
store="$config_home/panama/settings.json"
|
||||||
|
jq -e '.displays | length == 2
|
||||||
|
and .["DP-2"].x == 0 and .["DP-2"].y == 0 and .["DP-2"].primary == true
|
||||||
|
and .["HDMI-A-1"].x == 3000 and .["HDMI-A-1"].y == 100
|
||||||
|
and .["HDMI-A-1"].primary == false' "$store" >/dev/null \
|
||||||
|
|| fail 'confirmation did not persist one complete layout with one primary'
|
||||||
|
|
||||||
|
# Wrong readback never enables Keep; explicit revert restores both outputs.
|
||||||
|
touch "$fixture/wrong-y"
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 200)" == "true" ]] \
|
||||||
|
|| fail 'wrong-readback fixture could not start'
|
||||||
|
wait_for '.busy == false and .awaiting == true' >/dev/null
|
||||||
|
jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \
|
||||||
|
|| fail 'Keep enabled while one output had the wrong y coordinate'
|
||||||
|
rm -f "$fixture/wrong-y"
|
||||||
|
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||||
|
wait_for '.busy == false and .awaiting == false' >/dev/null
|
||||||
|
jq -e '.[0].x == 0 and .[0].y == 0 and .[1].x == 3000 and .[1].y == 100' \
|
||||||
|
"$monitor_state" >/dev/null || fail 'explicit revert did not restore the complete previous layout'
|
||||||
|
|
||||||
|
# A stale pre-operation query may update visible data, but cannot settle the
|
||||||
|
# current generation or make Keep available.
|
||||||
|
touch "$fixture/no-apply"
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 250)" == "true" ]] \
|
||||||
|
|| fail 'stale-generation fixture could not start'
|
||||||
|
wait_for '.busy == false and .awaiting == true' >/dev/null
|
||||||
|
generation="$(transaction_status | jq -r .generation)"
|
||||||
|
stale_json="$(jq '.[1].y = 250' "$monitor_state")"
|
||||||
|
run ipc --pid "$harness_pid" call displays-test injectReadback "$stale_json" "$((generation - 1))" >/dev/null
|
||||||
|
jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \
|
||||||
|
|| fail 'stale readback confirmed a newer operation'
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "false" ]] \
|
||||||
|
|| fail 'Keep accepted a display change without a generation-matched readback'
|
||||||
|
rm -f "$fixture/no-apply"
|
||||||
|
run ipc --pid "$harness_pid" call displays-test expireApplyVerification >/dev/null
|
||||||
|
wait_for '.busy == false and .awaiting == false' >/dev/null
|
||||||
|
|
||||||
|
# A non-zero evaluator exit follows the same whole-layout recovery path.
|
||||||
|
touch "$fixture/fail-once"
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 300)" == "true" ]] \
|
||||||
|
|| fail 'failed-evaluator fixture could not start'
|
||||||
|
failed_state="$(wait_for '.busy == false and .awaiting == false')"
|
||||||
|
jq -e '.lastError | contains("rejected")' <<<"$failed_state" >/dev/null \
|
||||||
|
|| fail "failed apply did not retain a useful recovery message: $failed_state"
|
||||||
|
|
||||||
|
# If an output disconnects while a change is pending, rollback sends one
|
||||||
|
# transaction containing every output that is still connected.
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 320)" == "true" ]] \
|
||||||
|
|| fail 'disconnect fixture could not start'
|
||||||
|
wait_for '.canConfirm == true' >/dev/null
|
||||||
|
jq '.[0:1]' "$monitor_state" >"$fixture/connected.json"
|
||||||
|
mv "$fixture/connected.json" "$monitor_state"
|
||||||
|
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
|
||||||
|
wait_for '.layout | length == 1' >/dev/null
|
||||||
|
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||||
|
wait_for '.busy == false and .awaiting == false' >/dev/null
|
||||||
|
disconnect_payload="$(tail -1 "$eval_log")"
|
||||||
|
[[ "$(rg -o 'hl\.monitor' <<<"$disconnect_payload" | wc -l)" == "1" ]] \
|
||||||
|
&& ! rg -Fq 'HDMI-A-1' <<<"$disconnect_payload" \
|
||||||
|
|| fail "disconnect rollback targeted an absent output: $disconnect_payload"
|
||||||
|
|
||||||
|
# Restore the second fixture output without touching the real compositor.
|
||||||
|
jq '. + [{
|
||||||
|
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
||||||
|
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":100,
|
||||||
|
"availableModes":["[email protected]"]
|
||||||
|
}]' "$monitor_state" >"$fixture/reconnected.json"
|
||||||
|
mv "$fixture/reconnected.json" "$monitor_state"
|
||||||
|
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
|
||||||
|
wait_for '.layout | length == 2' >/dev/null
|
||||||
|
|
||||||
|
# A revert that exits zero but reads back wrong remains an explicit manual
|
||||||
|
# recovery error rather than pretending the desktop was restored.
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 400)" == "true" ]] \
|
||||||
|
|| fail 'bad-revert fixture could not start'
|
||||||
|
wait_for '.canConfirm == true' >/dev/null
|
||||||
|
touch "$fixture/wrong-y"
|
||||||
|
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||||
|
wait_for '.reverting != null' >/dev/null
|
||||||
|
run ipc --pid "$harness_pid" call displays-test expireRevertVerification >/dev/null
|
||||||
|
manual_state="$(transaction_status)"
|
||||||
|
jq -e '.busy == false and (.lastError | contains("restore it manually"))' \
|
||||||
|
<<<"$manual_state" >/dev/null \
|
||||||
|
|| fail "wrong revert readback was reported as restored: $manual_state"
|
||||||
|
|
||||||
|
printf 'display transaction contract: PASS\n'
|
||||||
@@ -35,14 +35,17 @@ fail() {
|
|||||||
|
|
||||||
# Keep is unavailable until compositor readback exactly matches the request.
|
# Keep is unavailable until compositor readback exactly matches the request.
|
||||||
for contract in \
|
for contract in \
|
||||||
'property var pendingRequested:' \
|
'property var pendingRequestedLayout:' \
|
||||||
'property var revertExpected:' \
|
'property var revertExpectedLayout:' \
|
||||||
'property bool revertVerificationActive:' \
|
'property bool revertVerificationActive:' \
|
||||||
'property int revertGeneration:' \
|
'property int revertGeneration:' \
|
||||||
'readonly property bool canConfirm:' \
|
'readonly property bool canConfirm:' \
|
||||||
'function matchesRequest(' \
|
'function matchesLayout(' \
|
||||||
'function scalesForMode(' \
|
'function scalesForMode(' \
|
||||||
'function isScaleClean('; do
|
'function isScaleClean(' \
|
||||||
|
'x: Number.isInteger(monitor.x)' \
|
||||||
|
'y: Number.isInteger(monitor.y)' \
|
||||||
|
'primary: monitor.name === primaryName'; do
|
||||||
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
||||||
done
|
done
|
||||||
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
||||||
@@ -60,7 +63,7 @@ rg -Fq 'if (root.busy)' "$service" \
|
|||||||
|
|
||||||
# Stored JSON is untyped at field level, so the Lua startup consumer is the
|
# Stored JSON is untyped at field level, so the Lua startup consumer is the
|
||||||
# final validation boundary and must support every named output it accepts.
|
# final validation boundary and must support every named output it accepts.
|
||||||
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'pairs(displays)'; do
|
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' 'pairs(displays)'; do
|
||||||
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -79,9 +82,27 @@ package.preload["prefs"] = function()
|
|||||||
return {
|
return {
|
||||||
get = function()
|
get = function()
|
||||||
return {
|
return {
|
||||||
["DP-2"] = { mode = "not-a-mode", scale = -1, transform = 99 },
|
["DP-2"] = {
|
||||||
["HDMI-A-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 1 },
|
mode = "4500x3000@60", scale = 1.5, transform = 0,
|
||||||
["BAD OUTPUT"] = { mode = "1920x1080@60", scale = 1, transform = 0 },
|
x = 0, y = 0, primary = true,
|
||||||
|
},
|
||||||
|
["HDMI-A-1"] = {
|
||||||
|
mode = "2560x1440@60", scale = 1, transform = 1,
|
||||||
|
x = 3000, y = 0, primary = false,
|
||||||
|
},
|
||||||
|
["LEGACY-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 0 },
|
||||||
|
["PARTIAL-1"] = {
|
||||||
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||||
|
x = 4440,
|
||||||
|
},
|
||||||
|
["BAD-PRIMARY"] = {
|
||||||
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||||
|
x = 4440, y = 0, primary = "yes",
|
||||||
|
},
|
||||||
|
["BAD OUTPUT"] = {
|
||||||
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||||
|
x = 4440, y = 0, primary = false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -96,13 +117,24 @@ for _, value in ipairs(calls) do by_output[value.output] = value end
|
|||||||
assert(by_output["DP-2"].mode == "4500x3000@60")
|
assert(by_output["DP-2"].mode == "4500x3000@60")
|
||||||
assert(by_output["DP-2"].scale == 1.5)
|
assert(by_output["DP-2"].scale == 1.5)
|
||||||
assert(by_output["DP-2"].transform == 0)
|
assert(by_output["DP-2"].transform == 0)
|
||||||
assert(by_output["HDMI-A-1"].mode == "1920x1080@60")
|
assert(by_output["DP-2"].position == "0x0")
|
||||||
assert(by_output["HDMI-A-1"].scale == 1.5)
|
assert(by_output["DP-2"].bitdepth == 10)
|
||||||
|
assert(by_output["DP-2"].cm == "auto")
|
||||||
|
assert(by_output["HDMI-A-1"].mode == "2560x1440@60")
|
||||||
|
assert(by_output["HDMI-A-1"].scale == 1)
|
||||||
assert(by_output["HDMI-A-1"].transform == 1)
|
assert(by_output["HDMI-A-1"].transform == 1)
|
||||||
|
assert(by_output["HDMI-A-1"].position == "3000x0")
|
||||||
|
assert(by_output["LEGACY-1"].position == "auto")
|
||||||
|
assert(by_output["PARTIAL-1"] == nil)
|
||||||
|
assert(by_output["BAD-PRIMARY"] == nil)
|
||||||
assert(by_output["BAD OUTPUT"] == nil)
|
assert(by_output["BAD OUTPUT"] == nil)
|
||||||
assert(by_output[""] ~= nil)
|
assert(by_output[""] ~= nil)
|
||||||
LUA
|
LUA
|
||||||
|
|
||||||
|
rg -Fq 'Resolution, scale, rotation, position, and primary display' \
|
||||||
|
"$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" \
|
||||||
|
|| fail 'the display preference does not document complete layout persistence'
|
||||||
|
|
||||||
if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then
|
if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then
|
||||||
printf 'displays contract: PASS (static)\n'
|
printf 'displays contract: PASS (static)\n'
|
||||||
exit 0
|
exit 0
|
||||||
@@ -138,6 +170,8 @@ original_transform=""
|
|||||||
original_width=""
|
original_width=""
|
||||||
original_height=""
|
original_height=""
|
||||||
original_refresh=""
|
original_refresh=""
|
||||||
|
original_x=""
|
||||||
|
original_y=""
|
||||||
monitor_name=""
|
monitor_name=""
|
||||||
|
|
||||||
monitor_state() {
|
monitor_state() {
|
||||||
@@ -154,15 +188,18 @@ display_is_restored() {
|
|||||||
--argjson refresh "$original_refresh" \
|
--argjson refresh "$original_refresh" \
|
||||||
--argjson scale "$original_scale" \
|
--argjson scale "$original_scale" \
|
||||||
--argjson transform "$original_transform" \
|
--argjson transform "$original_transform" \
|
||||||
|
--argjson x "$original_x" \
|
||||||
|
--argjson y "$original_y" \
|
||||||
'.width == $width and .height == $height
|
'.width == $width and .height == $height
|
||||||
and ((.refreshRate - $refresh) | fabs) < 0.01
|
and ((.refreshRate - $refresh) | fabs) < 0.01
|
||||||
and ((.scale - $scale) | fabs) < 0.001
|
and ((.scale - $scale) | fabs) < 0.001
|
||||||
and .transform == $transform' <<<"$current" >/dev/null
|
and .transform == $transform
|
||||||
|
and .x == $x and .y == $y' <<<"$current" >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
restore_display() {
|
restore_display() {
|
||||||
[[ -n "$original_mode" ]] || return 0
|
[[ -n "$original_mode" ]] || return 0
|
||||||
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", scale = $original_scale, transform = $original_transform })" >/dev/null \
|
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", position = \"${original_x}x${original_y}\", scale = $original_scale, transform = $original_transform })" >/dev/null \
|
||||||
|| return 1
|
|| return 1
|
||||||
for _ in $(seq 1 50); do
|
for _ in $(seq 1 50); do
|
||||||
display_is_restored && return 0
|
display_is_restored && return 0
|
||||||
@@ -183,8 +220,9 @@ cleanup() {
|
|||||||
local status=$?
|
local status=$?
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
if ! restore_display; then
|
if ! restore_display; then
|
||||||
printf 'displays contract: FAILED to restore %s to %s scale %s transform %s\n' \
|
printf 'displays contract: FAILED to restore %s to %s at %sx%s scale %s transform %s\n' \
|
||||||
"$monitor_name" "$original_mode" "$original_scale" "$original_transform" >&2
|
"$monitor_name" "$original_mode" "$original_x" "$original_y" \
|
||||||
|
"$original_scale" "$original_transform" >&2
|
||||||
status=1
|
status=1
|
||||||
fi
|
fi
|
||||||
stop_harness
|
stop_harness
|
||||||
@@ -209,6 +247,13 @@ jq -e '
|
|||||||
' <<<"$refresh_fixture" >/dev/null \
|
' <<<"$refresh_fixture" >/dev/null \
|
||||||
|| fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture"
|
|| fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture"
|
||||||
|
|
||||||
|
position_fixture="$(run ipc call displays-test positionFixture)"
|
||||||
|
jq -e '. == [
|
||||||
|
{"name":"DP-2","x":140,"y":80,"primary":true},
|
||||||
|
{"name":"HDMI-A-1","x":3140,"y":80,"primary":false}
|
||||||
|
]' <<<"$position_fixture" >/dev/null \
|
||||||
|
|| fail "monitor positions or primary selection were parsed incorrectly: $position_fixture"
|
||||||
|
|
||||||
for _ in $(seq 1 50); do
|
for _ in $(seq 1 50); do
|
||||||
[[ "$(status | jq -r .count)" != "0" ]] && break
|
[[ "$(status | jq -r .count)" != "0" ]] && break
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
@@ -223,6 +268,8 @@ original_height="$(jq -r .height <<<"$state")"
|
|||||||
original_refresh="$(jq -r .refresh <<<"$state")"
|
original_refresh="$(jq -r .refresh <<<"$state")"
|
||||||
original_scale="$(jq -r .scale <<<"$state")"
|
original_scale="$(jq -r .scale <<<"$state")"
|
||||||
original_transform="$(jq -r .transform <<<"$state")"
|
original_transform="$(jq -r .transform <<<"$state")"
|
||||||
|
original_x="$(jq -r .x <<<"$state")"
|
||||||
|
original_y="$(jq -r .y <<<"$state")"
|
||||||
|
|
||||||
[[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
|
[[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
|
||||||
|
|
||||||
@@ -288,9 +335,14 @@ done
|
|||||||
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
|
||||||
|
|
||||||
# ── A confirmed change is what writes ────────────────────────────────────────
|
# ── A confirmed change is what writes ────────────────────────────────────────
|
||||||
run ipc call displays-test applyScale "$target_scale" >/dev/null
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
||||||
[[ "$(run ipc call displays-test confirmChange)" == "false" ]] \
|
|| fail 'the confirmed-change fixture could not apply'
|
||||||
|| fail 'Keep accepted a display change before compositor readback'
|
|
||||||
|
# Hyprland can apply and read back a change faster than two IPC round trips, so
|
||||||
|
# this live test cannot reliably observe the pre-readback state. The
|
||||||
|
# fake-compositor contract deterministically holds that boundary open and
|
||||||
|
# proves Keep refuses it; this path proves a real readback eventually enables
|
||||||
|
# and persists Keep on the physical display.
|
||||||
verified=false
|
verified=false
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
[[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; }
|
[[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; }
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ set -euo pipefail
|
|||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
--version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;;
|
--version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;;
|
||||||
|
list)
|
||||||
|
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
|
||||||
|
*,qs:duplicate,*) printf '%s\n' 'Instance fixture-one:' 'Instance fixture-two:' ;;
|
||||||
|
*,qs:missing,*) ;;
|
||||||
|
*) printf '%s\n' 'Instance fixture-one:' ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
ipc)
|
ipc)
|
||||||
if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then
|
if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then
|
||||||
printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n'
|
printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n'
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ fail() {
|
|||||||
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
||||||
[[ -f "$source_harness" ]] || fail 'health harness is missing'
|
[[ -f "$source_harness" ]] || fail 'health harness is missing'
|
||||||
[[ -f "$shell" ]] || fail 'shell.qml is missing'
|
[[ -f "$shell" ]] || fail 'shell.qml is missing'
|
||||||
|
rg -q 'target: "wallpaper"' "$shell" \
|
||||||
|
|| fail 'wallpaper health target is not exported by the shell'
|
||||||
|
|
||||||
# shell.qml is not started here: it is the active desktop shell. Keep this
|
# shell.qml is not started here: it is the active desktop shell. Keep this
|
||||||
# contract static while pinning the typed, redacted IPC boundary it exports.
|
# contract static while pinning the typed, redacted IPC boundary it exports.
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-lock"
|
||||||
|
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||||
|
fixture="$(mktemp -d /tmp/panama-lock-helper.XXXXXX)"
|
||||||
|
fixture_home="$fixture/home"
|
||||||
|
config_home="$fixture/config"
|
||||||
|
state_home="$fixture/state"
|
||||||
|
test_bin="$fixture/bin"
|
||||||
|
settings="$config_home/panama/settings.json"
|
||||||
|
generated="$state_home/panama/hyprlock.conf"
|
||||||
|
hyprlock_log="$fixture/hyprlock.log"
|
||||||
|
theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'lock screen helper contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
chmod 700 "$state_home/panama" 2>/dev/null || true
|
||||||
|
rm -rf "$fixture"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$fixture_home/Pictures/Wallpapers" "$config_home/panama" \
|
||||||
|
"$config_home/hypr" "$state_home" "$test_bin"
|
||||||
|
cp "$repo_dir/config/dot/hypr/hyprlock.conf.template" \
|
||||||
|
"$config_home/hypr/hyprlock.conf.template"
|
||||||
|
HOME="$fixture_home" XDG_CONFIG_HOME="$config_home" \
|
||||||
|
"$theme_helper" dark >/dev/null
|
||||||
|
[[ -s "$config_home/hypr/hyprlock.conf" ]] \
|
||||||
|
|| fail 'the themed fallback renderer did not create hyprlock.conf'
|
||||||
|
|
||||||
|
cat >"$test_bin/hyprctl" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
||||||
|
printf '%s\n' '[{"name":"DP-2"},{"name":"HDMI-A-1"}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 91
|
||||||
|
EOF
|
||||||
|
chmod +x "$test_bin/hyprctl"
|
||||||
|
|
||||||
|
cat >"$test_bin/hyprlock" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s\n' "$*" >>"$PANAMA_TEST_HYPRLOCK_LOG"
|
||||||
|
EOF
|
||||||
|
chmod +x "$test_bin/hyprlock"
|
||||||
|
|
||||||
|
run_helper() {
|
||||||
|
HOME="$fixture_home" XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||||
|
PATH="$test_bin:$PATH" PANAMA_TEST_HYPRLOCK_LOG="$hyprlock_log" "$helper" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_settings() {
|
||||||
|
printf '%s\n' "$1" >"$settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_schema_entry() {
|
||||||
|
local key="$1"
|
||||||
|
rg -Fq "key: \"$key\"" "$schema" || fail "schema is missing $key"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -x "$helper" ]] || fail 'panama-lock helper is missing or not executable'
|
||||||
|
for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty; do
|
||||||
|
assert_schema_entry "$key"
|
||||||
|
done
|
||||||
|
|
||||||
|
write_settings '{}'
|
||||||
|
run_helper generate
|
||||||
|
[[ -s "$generated" ]] || fail 'default generation did not create a config'
|
||||||
|
rg -Fq 'path = screenshot' "$generated" || fail 'default background is not a screenshot'
|
||||||
|
rg -Fq 'blur_passes = 3' "$generated" || fail 'default blur pass count is not 3'
|
||||||
|
rg -Fq 'blur_size = 8' "$generated" || fail 'default blur size is not 8'
|
||||||
|
rg -Fq 'text = cmd[update:1000] date +"%-I:%M"' "$generated" || fail 'default clock is not 12-hour'
|
||||||
|
rg -Fq 'text = cmd[update:60000] date +"%A, %B %-d"' "$generated" || fail 'default date is missing'
|
||||||
|
rg -Fq 'text = $USER' "$generated" || fail 'default user label is missing'
|
||||||
|
rg -Fq 'fade_on_empty = false' "$generated" || fail 'default password field fades when empty'
|
||||||
|
jq -e '.generated == true and .fallback == false and .error == "" and (.path | endswith("/panama/hyprlock.conf"))' \
|
||||||
|
<<<"$(run_helper status)" >/dev/null || fail 'status did not report the generated config'
|
||||||
|
|
||||||
|
write_settings '{
|
||||||
|
"lockBackgroundMode":"solid",
|
||||||
|
"lockBlurLevel":0,
|
||||||
|
"lockShowClock":false,
|
||||||
|
"lockShowDate":false,
|
||||||
|
"lockShowUser":false,
|
||||||
|
"lockFadeOnEmpty":true,
|
||||||
|
"use24Hour":true,
|
||||||
|
"colorScheme":"light"
|
||||||
|
}'
|
||||||
|
run_helper generate
|
||||||
|
rg -Fq 'color = rgba(225, 226, 231, 1.0)' "$generated" || fail 'light solid mode does not match Theme.bg'
|
||||||
|
rg -Fq 'inner_color = rgba(208, 213, 227, 0.85)' "$generated" || fail 'light password field does not match the themed fallback'
|
||||||
|
rg -Fq 'font_color = rgba(55, 96, 191, 1.0)' "$generated" || fail 'light foreground does not match Theme.fg'
|
||||||
|
rg -Fq 'outer_color = rgba(46, 125, 233, 0.9)' "$generated" || fail 'light focus ring does not match Theme.accent'
|
||||||
|
rg -Fq 'check_color = rgba(46, 125, 233, 1.0)' "$generated" || fail 'light success colour does not match Theme.accent'
|
||||||
|
rg -Fq 'fail_color = rgba(245, 42, 101, 1.0)' "$generated" || fail 'light error colour does not match Theme.red'
|
||||||
|
rg -Fq 'foreground="##6172b0"' "$generated" || fail 'light placeholder markup retained the dark muted colour'
|
||||||
|
rg -Fq 'foreground="##f52a65"' "$generated" || fail 'light failure markup retained the dark error colour'
|
||||||
|
rg -Fq 'blur_passes = 0' "$generated" || fail 'blur level zero did not disable passes'
|
||||||
|
rg -Fq 'blur_size = 1' "$generated" || fail 'blur level zero did not use the safe size'
|
||||||
|
rg -Fq 'fade_on_empty = true' "$generated" || fail 'fade-on-empty setting was ignored'
|
||||||
|
if rg -q '^label \{' "$generated"; then
|
||||||
|
fail 'hidden clock, date, and user labels were still generated'
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_settings '{
|
||||||
|
"lockBackgroundMode":"wallpaper",
|
||||||
|
"wallpaperMode":"per-monitor",
|
||||||
|
"wallpaperPath":"/images/global.jpg",
|
||||||
|
"wallpaperPerMonitor":{"DP-2":"/images/portrait.jpg"}
|
||||||
|
}'
|
||||||
|
run_helper generate
|
||||||
|
[[ "$(rg -c '^background \{' "$generated")" -eq 2 ]] || fail 'wallpaper mode did not create one block per output'
|
||||||
|
awk '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && /path = \/images\/portrait.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \
|
||||||
|
|| fail 'per-monitor wallpaper was not used for DP-2'
|
||||||
|
awk '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && /path = \/images\/global.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \
|
||||||
|
|| fail 'missing monitor assignment did not fall back to the global wallpaper'
|
||||||
|
|
||||||
|
write_settings '{"lockBackgroundMode":"screenshot","lockShowClock":true,"lockShowDate":false,"lockShowUser":false,"use24Hour":true}'
|
||||||
|
run_helper generate
|
||||||
|
rg -Fq 'text = cmd[update:1000] date +"%H:%M"' "$generated" || fail '24-hour clock setting was ignored'
|
||||||
|
|
||||||
|
write_settings '{"lockBackgroundMode":"nope","lockBlurLevel":99,"lockShowClock":"yes","lockShowDate":0,"lockShowUser":null,"lockFadeOnEmpty":[]}'
|
||||||
|
run_helper generate
|
||||||
|
rg -Fq 'path = screenshot' "$generated" || fail 'invalid background mode did not use the default'
|
||||||
|
rg -Fq 'blur_passes = 3' "$generated" || fail 'invalid blur did not use the default'
|
||||||
|
rg -Fq 'text = cmd[update:1000] date +"%-I:%M"' "$generated" || fail 'invalid clock boolean did not use the default'
|
||||||
|
rg -Fq 'text = cmd[update:60000] date +"%A, %B %-d"' "$generated" || fail 'invalid date boolean did not use the default'
|
||||||
|
rg -Fq 'text = $USER' "$generated" || fail 'invalid user boolean did not use the default'
|
||||||
|
rg -Fq 'fade_on_empty = false' "$generated" || fail 'invalid fade boolean did not use the default'
|
||||||
|
|
||||||
|
write_settings 'not json'
|
||||||
|
run_helper generate
|
||||||
|
rg -Fq 'path = screenshot' "$generated" || fail 'malformed JSON did not use shipped defaults'
|
||||||
|
|
||||||
|
before_checksum="$(sha256sum "$generated" | cut -d' ' -f1)"
|
||||||
|
chmod 500 "$state_home/panama"
|
||||||
|
if run_helper generate >/dev/null 2>&1; then
|
||||||
|
fail 'generation unexpectedly succeeded with an unwritable state directory'
|
||||||
|
fi
|
||||||
|
after_checksum="$(sha256sum "$generated" | cut -d' ' -f1)"
|
||||||
|
[[ "$before_checksum" == "$after_checksum" ]] || fail 'failed generation replaced the last valid config'
|
||||||
|
chmod 700 "$state_home/panama"
|
||||||
|
|
||||||
|
: >"$hyprlock_log"
|
||||||
|
run_helper run
|
||||||
|
[[ "$(tail -1 "$hyprlock_log")" == "-c $generated" ]] || fail 'run did not use the generated config after success'
|
||||||
|
|
||||||
|
chmod 500 "$state_home/panama"
|
||||||
|
run_helper run 2>"$fixture/fallback.stderr"
|
||||||
|
chmod 700 "$state_home/panama"
|
||||||
|
[[ "$(tail -1 "$hyprlock_log")" == "-c $config_home/hypr/hyprlock.conf" ]] \
|
||||||
|
|| fail 'run did not use the tracked fallback after generation failure'
|
||||||
|
[[ ! -s "$fixture/fallback.stderr" ]] || fail 'safe fallback leaked an internal filesystem error'
|
||||||
|
|
||||||
|
printf 'lock screen helper contract: PASS\n'
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
shipped_idle="$repo_dir/config/dot/hypr/hypridle.conf"
|
||||||
|
idle_helper="$repo_dir/config/dot/quickshell/scripts/panama-idle"
|
||||||
|
fixture="$(mktemp -d /tmp/panama-lock-service.XXXXXX)"
|
||||||
|
config_home="$fixture/config"
|
||||||
|
state_home="$fixture/state"
|
||||||
|
config_path="$config_home/quickshell"
|
||||||
|
harness="$config_path/lock-screen-harness.qml"
|
||||||
|
test_bin="$fixture/bin"
|
||||||
|
generate_log="$fixture/generate.log"
|
||||||
|
status_response="$fixture/status.json"
|
||||||
|
shell_log="$fixture/quickshell.log"
|
||||||
|
harness_pid=""
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'lock screen service contract: %s\n' "$1" >&2
|
||||||
|
[[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
instances_for_harness() {
|
||||||
|
qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||||
|
/^Instance / { pid = "" }
|
||||||
|
/^[[:space:]]*Process ID:/ { pid = $3 }
|
||||||
|
/^[[:space:]]*Config path:/ {
|
||||||
|
path = $0
|
||||||
|
sub(/^[[:space:]]*Config path: /, "", path)
|
||||||
|
if (path == expected && pid ~ /^[0-9]+$/) print pid
|
||||||
|
}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
|
||||||
|
kill "$harness_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$fixture"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
expected_lock='lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run'
|
||||||
|
rg -Fxq " $expected_lock" "$shipped_idle" || fail 'shipped hypridle does not use panama-lock'
|
||||||
|
|
||||||
|
mkdir -p "$config_home/panama" "$state_home" "$test_bin"
|
||||||
|
printf '%s\n' '{}' >"$config_home/panama/settings.json"
|
||||||
|
cat >"$test_bin/systemctl" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$*" == *"is-active"* ]]; then printf 'active\n'; fi
|
||||||
|
EOF
|
||||||
|
chmod +x "$test_bin/systemctl"
|
||||||
|
HOME="$fixture/home" XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||||
|
PATH="$test_bin:$PATH" "$idle_helper" apply
|
||||||
|
rg -Fxq " $expected_lock" "$state_home/panama/hypridle.conf" \
|
||||||
|
|| fail 'generated hypridle does not use panama-lock'
|
||||||
|
|
||||||
|
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||||
|
cat >"$config_path/scripts/panama-lock" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
case "${1:-}" in
|
||||||
|
generate)
|
||||||
|
printf 'generate\n' >>"$PANAMA_LOCK_TEST_GENERATE_LOG"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
cat "$PANAMA_LOCK_TEST_STATUS_RESPONSE"
|
||||||
|
;;
|
||||||
|
*) exit 92 ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$config_path/scripts/panama-lock"
|
||||||
|
|
||||||
|
printf '%s\n' '{"generated":true,"path":"/fixture/generated-hyprlock.conf","fallback":false,"error":""}' \
|
||||||
|
>"$status_response"
|
||||||
|
: >"$generate_log"
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then
|
||||||
|
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||||
|
PANAMA_LOCK_TEST_GENERATE_LOG="$generate_log" \
|
||||||
|
PANAMA_LOCK_TEST_STATUS_RESPONSE="$status_response" \
|
||||||
|
qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
||||||
|
else
|
||||||
|
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||||
|
PANAMA_LOCK_TEST_GENERATE_LOG="$generate_log" \
|
||||||
|
PANAMA_LOCK_TEST_STATUS_RESPONSE="$status_response" \
|
||||||
|
qs -p "$harness" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated harness did not launch'
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
harness_pid="$(instances_for_harness | head -1)"
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||||
|
&& qs_for_harness ipc show 2>/dev/null | rg -q '^target lock-screen-test$'; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated harness process did not start'
|
||||||
|
qs_for_harness ipc show 2>/dev/null | rg -q '^target lock-screen-test$' \
|
||||||
|
|| fail 'lock-screen-test IPC target did not register'
|
||||||
|
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
initial="$(qs_for_harness ipc call lock-screen-test status)"
|
||||||
|
[[ "$(jq -r '.busy' <<<"$initial")" == false ]] \
|
||||||
|
&& [[ "$(jq -r '.path' <<<"$initial")" == /fixture/generated-hyprlock.conf ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '.generated == true and .fallback == false and .path == "/fixture/generated-hyprlock.conf" and .lastError == ""' \
|
||||||
|
<<<"$initial" >/dev/null || fail "valid helper status was not accepted: $initial"
|
||||||
|
|
||||||
|
# Component startup intentionally schedules one generation. Let that and any
|
||||||
|
# preference-file load revision settle before measuring only the burst below.
|
||||||
|
sleep 0.6
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
startup="$(qs_for_harness ipc call lock-screen-test status)"
|
||||||
|
[[ "$(jq -r '.busy' <<<"$startup")" == false ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
baseline_generations="$(wc -l <"$generate_log")"
|
||||||
|
qs_for_harness ipc call lock-screen-test burst >/dev/null
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
current_generations="$(wc -l <"$generate_log" 2>/dev/null || printf 0)"
|
||||||
|
(( current_generations >= baseline_generations + 1 )) && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
sleep 0.35
|
||||||
|
current_generations="$(wc -l <"$generate_log" 2>/dev/null || printf 0)"
|
||||||
|
[[ "$current_generations" -eq $(( baseline_generations + 1 )) ]] \
|
||||||
|
|| fail "three rapid preferences started $(( current_generations - baseline_generations )) generations"
|
||||||
|
|
||||||
|
printf '%s\n' 'not json' >"$status_response"
|
||||||
|
qs_for_harness ipc call lock-screen-test refresh >/dev/null
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
malformed="$(qs_for_harness ipc call lock-screen-test status)"
|
||||||
|
[[ "$(jq -r '.busy' <<<"$malformed")" == false ]] \
|
||||||
|
&& [[ "$(jq -r '.lastError' <<<"$malformed")" != "" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '.path == "/fixture/generated-hyprlock.conf"
|
||||||
|
and .lastError == "The lock-screen configuration could not be read."' \
|
||||||
|
<<<"$malformed" >/dev/null || fail "malformed status replaced valid state: $malformed"
|
||||||
|
|
||||||
|
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
|
||||||
|
fail 'isolated harness emitted a QML runtime warning'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'lock screen service contract: PASS\n'
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
preview="$repo_dir/config/dot/quickshell/modules/settings/LockScreenPreview.qml"
|
||||||
|
appearance="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
|
||||||
|
power="$repo_dir/config/dot/quickshell/modules/settings/PowerPage.qml"
|
||||||
|
privacy="$repo_dir/config/dot/quickshell/modules/settings/PrivacyPage.qml"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/lock-screen-settings-harness.qml"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-lock-settings-state.XXXXXX)"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
harness_pid=""
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'lock screen settings contract: %s\n' "$1" >&2
|
||||||
|
[[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
instances_for_harness() {
|
||||||
|
qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||||
|
/^Instance / { pid = "" }
|
||||||
|
/^[[:space:]]*Process ID:/ { pid = $3 }
|
||||||
|
/^[[:space:]]*Config path:/ {
|
||||||
|
path = $0
|
||||||
|
sub(/^[[:space:]]*Config path: /, "", path)
|
||||||
|
if (path == expected && pid ~ /^[0-9]+$/) print pid
|
||||||
|
}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
|
||||||
|
kill "$harness_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -f "$preview" ]] || fail 'LockScreenPreview.qml is missing'
|
||||||
|
rg -Fq 'title: "Lock screen"' "$appearance" || fail 'Appearance has no Lock screen card'
|
||||||
|
for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty; do
|
||||||
|
rg -Fq "setting: \"$key\"" "$appearance" || fail "Appearance does not expose $key"
|
||||||
|
! rg -Fq "setting: \"$key\"" "$power" "$privacy" \
|
||||||
|
|| fail "$key was duplicated onto Power or Privacy"
|
||||||
|
done
|
||||||
|
|
||||||
|
python3 - "$appearance" <<'PY' || fail 'Lock screen card is not between Background and Shell typography'
|
||||||
|
import sys
|
||||||
|
|
||||||
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
background = text.index('title: "Background"')
|
||||||
|
lock = text.index('title: "Lock screen"')
|
||||||
|
typography = text.index('title: "Shell typography"')
|
||||||
|
raise SystemExit(0 if background < lock < typography else 1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
||||||
|
else
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
before_lockers="$(pgrep -x hyprlock | sort -n || true)"
|
||||||
|
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated preview harness did not launch'
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
harness_pid="$(instances_for_harness | head -1)"
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||||
|
&& qs_for_harness ipc show 2>/dev/null | rg -q '^target lock-screen-settings-test$'; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated preview harness process did not start'
|
||||||
|
|
||||||
|
status="$(qs_for_harness ipc call lock-screen-settings-test status)"
|
||||||
|
jq -e '
|
||||||
|
.screenshot == {"mode":"screenshot","wallpaperVisible":false,"blurStrength":0.8,"clockVisible":true,"dateVisible":true,"userVisible":true,"passwordVisible":true}
|
||||||
|
and .wallpaper == {"mode":"wallpaper","wallpaperVisible":true,"blurStrength":0.4,"clockVisible":false,"dateVisible":true,"userVisible":false,"passwordVisible":false}
|
||||||
|
and .solid == {"mode":"solid","wallpaperVisible":false,"blurStrength":0,"clockVisible":true,"dateVisible":false,"userVisible":true,"passwordVisible":true}
|
||||||
|
' <<<"$status" >/dev/null || fail "preview roles did not follow their settings: $status"
|
||||||
|
|
||||||
|
after_lockers="$(pgrep -x hyprlock | sort -n || true)"
|
||||||
|
[[ "$after_lockers" == "$before_lockers" ]] || fail 'preview started a real hyprlock process'
|
||||||
|
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign|Failed to load' "$shell_log"; then
|
||||||
|
fail 'preview harness emitted a QML runtime warning'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'lock screen settings contract: PASS\n'
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# The lock screen follows the colour scheme.
|
||||||
|
#
|
||||||
|
# It did not. hyprlock.conf shipped with Tokyo Night Moon hardcoded in six
|
||||||
|
# places, so choosing light mode left the one screen a user sees most often
|
||||||
|
# stubbornly dark. Every other surface -- kitty, GTK, the launcher, btop, tmux,
|
||||||
|
# neovim -- had been taught to follow the scheme; this was the last one.
|
||||||
|
#
|
||||||
|
# It is also the worst place to discover a theming bug, because you find out
|
||||||
|
# while locked out of the machine and cannot fix it from there. Hence a test.
|
||||||
|
#
|
||||||
|
# Generated into a fixture, never the live config: this contract must not
|
||||||
|
# retheme the lock screen of the desktop it is running on.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
template="$repo_dir/config/dot/hypr/hyprlock.conf.template"
|
||||||
|
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'lock screen theme contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -r "$template" ]] || fail 'hyprlock.conf.template is missing, so nothing generates the lock screen'
|
||||||
|
|
||||||
|
# The committed template must carry no literal colours. One left behind is a
|
||||||
|
# colour that silently stays dark in light mode -- exactly the original bug.
|
||||||
|
literal="$(grep -vE '^\s*#' "$template" | grep -oE 'rgba\([0-9]+, *[0-9]+, *[0-9]+' || true)"
|
||||||
|
[[ -z "$literal" ]] \
|
||||||
|
|| fail "the template still contains hardcoded colours, which will not follow the scheme: $literal"
|
||||||
|
|
||||||
|
fixture="$(mktemp -d /tmp/panama-lockscreen.XXXXXX)"
|
||||||
|
trap 'rm -rf "$fixture"' EXIT
|
||||||
|
mkdir -p "$fixture/hypr"
|
||||||
|
cp "$template" "$fixture/hypr/"
|
||||||
|
|
||||||
|
for scheme in dark light; do
|
||||||
|
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
|
||||||
|
generated="$fixture/hypr/hyprlock.conf"
|
||||||
|
|
||||||
|
[[ -r "$generated" ]] || fail "no hyprlock.conf was generated for $scheme"
|
||||||
|
|
||||||
|
# An unsubstituted placeholder is not a parse error to hyprlock; it is an
|
||||||
|
# invalid colour it quietly ignores, falling back to its own default.
|
||||||
|
leftover="$(grep -oE '@[A-Z_]+@' "$generated" || true)"
|
||||||
|
[[ -z "$leftover" ]] \
|
||||||
|
|| fail "$scheme left placeholders unsubstituted: $leftover"
|
||||||
|
|
||||||
|
# Every colour hyprlock is given must be a complete decimal triple. It
|
||||||
|
# takes rgba(r, g, b, a), NOT hex, and a hex value here is silently ignored.
|
||||||
|
while read -r colour; do
|
||||||
|
[[ -n "$colour" ]] || continue
|
||||||
|
grep -qE '^rgba\([0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}$' <<<"$colour" \
|
||||||
|
|| fail "$scheme produced a malformed colour: $colour"
|
||||||
|
done < <(grep -vE '^\s*#' "$generated" | grep -oE 'rgba\([^)]*' | sed 's/,[^,]*$//')
|
||||||
|
|
||||||
|
# The Pango markup values are hex, and hyprlock wants them doubled-hashed.
|
||||||
|
while read -r pango; do
|
||||||
|
[[ -n "$pango" ]] || continue
|
||||||
|
grep -qE '^##[0-9a-f]{6}$' <<<"$pango" \
|
||||||
|
|| fail "$scheme produced malformed Pango markup colour: $pango"
|
||||||
|
done < <(grep -vE '^\s*#' "$generated" | grep -oE '##[0-9a-fA-F]{6}')
|
||||||
|
|
||||||
|
# The whole background block, not a fixed number of lines after it: the
|
||||||
|
# colour sits near the end, after the blur and noise settings.
|
||||||
|
background="$(awk '/^background \{/,/^\}/' "$generated" \
|
||||||
|
| grep -vE '^\s*#' | grep -oE 'color = rgba\([0-9]+' | grep -oE '[0-9]+$' | head -1)"
|
||||||
|
[[ -n "$background" ]] || fail "$scheme produced no background colour"
|
||||||
|
|
||||||
|
# The point of the whole exercise: a light lock screen must actually be
|
||||||
|
# light. 128 splits the two cleanly for these palettes.
|
||||||
|
if [[ "$scheme" == "light" ]]; then
|
||||||
|
(( background > 128 )) \
|
||||||
|
|| fail "light mode produced a DARK lock screen background (red channel $background) -- the original bug"
|
||||||
|
else
|
||||||
|
(( background < 128 )) \
|
||||||
|
|| fail "dark mode produced a LIGHT lock screen background (red channel $background)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# The two schemes must actually differ, or the substitution is a no-op that
|
||||||
|
# passes every check above.
|
||||||
|
XDG_CONFIG_HOME="$fixture" "$theme_apps" dark >/dev/null 2>&1
|
||||||
|
dark_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
|
||||||
|
XDG_CONFIG_HOME="$fixture" "$theme_apps" light >/dev/null 2>&1
|
||||||
|
light_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
|
||||||
|
[[ "$dark_hash" != "$light_hash" ]] \
|
||||||
|
|| fail 'the light and dark lock screens are byte-identical, so the scheme is not being applied'
|
||||||
|
|
||||||
|
printf 'lock screen theme contract: PASS\n'
|
||||||
@@ -8,6 +8,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor"
|
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor"
|
||||||
|
theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||||
fixture_root="$repo_dir/tests/quickshell/fixtures/doctor"
|
fixture_root="$repo_dir/tests/quickshell/fixtures/doctor"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
@@ -90,6 +91,11 @@ for name in hypr quickshell uwsm vicinae; do
|
|||||||
ln -s "$repo_dir/config/dot/$name" "$config_home/$name"
|
ln -s "$repo_dir/config/dot/$name" "$config_home/$name"
|
||||||
done
|
done
|
||||||
ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts/panama"
|
ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts/panama"
|
||||||
|
mkdir -p "$state_home/panama"
|
||||||
|
printf '%s\n' 'background { path = /fixture-secret-wallpaper.jpg }' \
|
||||||
|
>"$state_home/panama/hyprlock.conf"
|
||||||
|
printf '%s\n' '{"generated":true,"path":"/fixture-secret-wallpaper.jpg","fallback":false,"error":""}' \
|
||||||
|
>"$state_home/panama/hyprlock-status.json"
|
||||||
|
|
||||||
run_doctor() {
|
run_doctor() {
|
||||||
HOME="$home" \
|
HOME="$home" \
|
||||||
@@ -107,7 +113,7 @@ run_doctor() {
|
|||||||
/usr/bin/python3 "$doctor" "$@"
|
/usr/bin/python3 "$doctor" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||||
|
|
||||||
assert_schema_and_redaction() {
|
assert_schema_and_redaction() {
|
||||||
local snapshot="$1"
|
local snapshot="$1"
|
||||||
@@ -116,8 +122,8 @@ assert_schema_and_redaction() {
|
|||||||
and (.summary.status | IN("healthy", "warning", "error"))
|
and (.summary.status | IN("healthy", "warning", "error"))
|
||||||
and (.context.session | IN("hyprland", "other"))
|
and (.context.session | IN("hyprland", "other"))
|
||||||
and (.context.versions | type == "array")
|
and (.context.versions | type == "array")
|
||||||
and ([.checks[].id] | length == 25)
|
and ([.checks[].id] | length == 26)
|
||||||
and ([.checks[].id] | unique | length == 25)
|
and ([.checks[].id] | unique | length == 26)
|
||||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
||||||
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
||||||
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
|
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
|
||||||
@@ -139,6 +145,29 @@ check_status() {
|
|||||||
snapshot="$(run_doctor --json)"
|
snapshot="$(run_doctor --json)"
|
||||||
assert_schema_and_redaction "$snapshot"
|
assert_schema_and_redaction "$snapshot"
|
||||||
check_status "$snapshot" panama.vicinae-commands ok
|
check_status "$snapshot" panama.vicinae-commands ok
|
||||||
|
check_status "$snapshot" desktop.hyprlock ok
|
||||||
|
|
||||||
|
printf '%s\n' '{"generated":false,"path":"/fixture-secret-wallpaper.jpg","fallback":true,"error":"fixture-secret-token"}' \
|
||||||
|
>"$state_home/panama/hyprlock-status.json"
|
||||||
|
# A linked source checkout contains the template; installation renders the
|
||||||
|
# ignored machine-local fallback beside it. Model that installed state without
|
||||||
|
# writing an untracked file into the worktree under test.
|
||||||
|
rm "$config_home/hypr"
|
||||||
|
mkdir "$config_home/hypr"
|
||||||
|
cp "$repo_dir/config/dot/hypr/hyprlock.conf.template" \
|
||||||
|
"$config_home/hypr/hyprlock.conf.template"
|
||||||
|
HOME="$home" XDG_CONFIG_HOME="$config_home" "$theme_helper" dark >/dev/null
|
||||||
|
fallback_lock="$(run_doctor --json)"
|
||||||
|
check_status "$fallback_lock" desktop.hyprlock warning
|
||||||
|
assert_schema_and_redaction "$fallback_lock"
|
||||||
|
|
||||||
|
rm -rf "$config_home/hypr"
|
||||||
|
missing_lock="$(run_doctor --json)"
|
||||||
|
check_status "$missing_lock" desktop.hyprlock error
|
||||||
|
assert_schema_and_redaction "$missing_lock"
|
||||||
|
ln -s "$repo_dir/config/dot/hypr" "$config_home/hypr"
|
||||||
|
printf '%s\n' '{"generated":true,"path":"/fixture-secret-wallpaper.jpg","fallback":false,"error":""}' \
|
||||||
|
>"$state_home/panama/hyprlock-status.json"
|
||||||
|
|
||||||
# The diagnostic follows the actual installer contract: the scripts parent is
|
# The diagnostic follows the actual installer contract: the scripts parent is
|
||||||
# a directory and only its Panama child is an authored link.
|
# a directory and only its Panama child is an authored link.
|
||||||
@@ -216,11 +245,14 @@ jq -e '.checks[] | select(.id == "panama.caffeine")
|
|||||||
|| fail 'Caffeine detail exposed inhibitor PIDs'
|
|| fail 'Caffeine detail exposed inhibitor PIDs'
|
||||||
|
|
||||||
# Process counts use only exact authored names and never expose command lines or PIDs.
|
# Process counts use only exact authored names and never expose command lines or PIDs.
|
||||||
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=quickshell:duplicate run_doctor --json)"
|
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:duplicate run_doctor --json)"
|
||||||
check_status "$duplicated_processes" panama.processes warning
|
check_status "$duplicated_processes" panama.processes warning
|
||||||
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
||||||
|| fail 'process detail exposed a PID'
|
|| fail 'process detail exposed a PID'
|
||||||
|
|
||||||
|
missing_quickshell_process="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:missing run_doctor --json)"
|
||||||
|
check_status "$missing_quickshell_process" panama.processes error
|
||||||
|
|
||||||
# Invalid output for a non-Quickshell authored process is not a normal zero
|
# Invalid output for a non-Quickshell authored process is not a normal zero
|
||||||
# count that can be hidden by the running Quickshell process.
|
# count that can be hidden by the running Quickshell process.
|
||||||
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
|
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Anything instantiated per screen must take its screen from `modelData`.
|
||||||
|
#
|
||||||
|
# Variants supplies each delegate a `modelData` holding the screen. A component
|
||||||
|
# that instead declares `required property var screen` is constructed, never
|
||||||
|
# receives a screen, and its window silently never maps -- with nothing logged,
|
||||||
|
# no error, and no visible failure beyond the surface simply not being there.
|
||||||
|
#
|
||||||
|
# shell.qml has warned about this in a comment since the Bar hit it. The comment
|
||||||
|
# did not stop the window switcher hitting it again, which is the argument for a
|
||||||
|
# test: the failure is invisible, so review does not catch it either.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
shell_file="$repo_dir/config/dot/quickshell/shell.qml"
|
||||||
|
modules="$repo_dir/config/dot/quickshell/modules"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'per-screen surface contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -r "$shell_file" ]] || fail "cannot read shell.qml"
|
||||||
|
|
||||||
|
# The component named inside each `Variants { model: Quickshell.screens ... }`.
|
||||||
|
delegates="$(awk '
|
||||||
|
/Variants \{/ { inside = 1; next }
|
||||||
|
inside && /model: Quickshell.screens/ { armed = 1; next }
|
||||||
|
armed && /^[[:space:]]*[A-Z][A-Za-z]* *\{/ {
|
||||||
|
match($0, /[A-Z][A-Za-z]*/)
|
||||||
|
print substr($0, RSTART, RLENGTH)
|
||||||
|
armed = 0; inside = 0
|
||||||
|
}
|
||||||
|
' "$shell_file" | sort -u)"
|
||||||
|
|
||||||
|
[[ -n "$delegates" ]] || fail 'found no per-screen delegates -- this contract is not reading shell.qml correctly'
|
||||||
|
|
||||||
|
checked=0
|
||||||
|
while read -r name; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
|
||||||
|
file="$(find "$modules" -name "$name.qml" -print -quit 2>/dev/null)"
|
||||||
|
[[ -n "$file" ]] || fail "shell.qml instantiates $name per screen, but $name.qml was not found"
|
||||||
|
|
||||||
|
if grep -qE '^\s*required property var screen\b' "$file"; then
|
||||||
|
fail "$name declares 'required property var screen', but Variants supplies modelData -- the window is built and never maps, silently. Use 'property var modelData' and bind screen to it, as Bar and Dock do."
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -qE '^\s*property var modelData' "$file" \
|
||||||
|
|| fail "$name is instantiated per screen but never declares 'property var modelData', so it cannot know which screen it is on"
|
||||||
|
|
||||||
|
grep -qE 'screen: (root\.)?modelData' "$file" \
|
||||||
|
|| fail "$name declares modelData but never binds a screen to it"
|
||||||
|
|
||||||
|
checked=$((checked + 1))
|
||||||
|
done <<<"$delegates"
|
||||||
|
|
||||||
|
printf 'per-screen surface contract: PASS (%d per-screen surfaces)\n' "$checked"
|
||||||
Executable
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# A search result must open the page that actually contains the setting.
|
||||||
|
#
|
||||||
|
# Every schema group routes to one page in services/SettingsSearch.qml, and the
|
||||||
|
# ownership rule in modules/settings/README.md derives a setting's owner from
|
||||||
|
# that route. Nothing checked the two agreed, and two groups had drifted:
|
||||||
|
#
|
||||||
|
# weather routed to Appearance while every weather control lived on Home, so
|
||||||
|
# searching "temperature unit" opened a page without it.
|
||||||
|
#
|
||||||
|
# vitals routed to Appearance, but the refresh interval sat on Home while the
|
||||||
|
# toggles it governs sat on Appearance -- one concept, two pages, which is
|
||||||
|
# precisely what the ownership rule forbids.
|
||||||
|
#
|
||||||
|
# Neither is visible from the code: each page is coherent on its own, and only
|
||||||
|
# following a search result reveals the mismatch.
|
||||||
|
#
|
||||||
|
# Groups whose settings are driven by bespoke UI rather than schema-bound rows
|
||||||
|
# are skipped, because there is nothing to locate.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||||
|
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||||
|
pages_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'search routing contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
routes="$(grep -oE '"[a-zA-Z]+": "[a-z-]+"' "$search" | tr -d '"' | tr ':' ' ')"
|
||||||
|
[[ -n "$routes" ]] || fail 'no group routes found -- this contract is not reading SettingsSearch correctly'
|
||||||
|
|
||||||
|
# page id -> Page component file, as SettingsShell maps them.
|
||||||
|
page_file() {
|
||||||
|
case "$1" in
|
||||||
|
home) printf 'HomePage.qml' ;;
|
||||||
|
appearance) printf 'AppearancePage.qml' ;;
|
||||||
|
displays) printf 'DisplaysPage.qml' ;;
|
||||||
|
connectivity) printf 'ConnectivityPage.qml' ;;
|
||||||
|
home-phone) printf 'HomePhonePage.qml' ;;
|
||||||
|
desktop) printf 'DesktopPage.qml' ;;
|
||||||
|
sound) printf 'SoundPage.qml' ;;
|
||||||
|
notifications) printf 'NotificationsPage.qml' ;;
|
||||||
|
screen-intelligence) printf 'ScreenIntelligencePage.qml' ;;
|
||||||
|
shortcuts) printf 'ShortcutsPage.qml' ;;
|
||||||
|
mouse) printf 'MousePage.qml' ;;
|
||||||
|
privacy) printf 'PrivacyPage.qml' ;;
|
||||||
|
region) printf 'RegionPage.qml' ;;
|
||||||
|
accounts) printf 'OnlineAccountsPage.qml' ;;
|
||||||
|
accessibility) printf 'AccessibilityPage.qml' ;;
|
||||||
|
power) printf 'PowerPage.qml' ;;
|
||||||
|
datetime) printf 'DateTimePage.qml' ;;
|
||||||
|
applications) printf 'ApplicationsPage.qml' ;;
|
||||||
|
services) printf 'HealthPage.qml' ;;
|
||||||
|
about) printf 'AboutPage.qml' ;;
|
||||||
|
*) printf '' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
violations=0
|
||||||
|
checked=0
|
||||||
|
|
||||||
|
while read -r group page; do
|
||||||
|
[[ -n "$group" && -n "$page" ]] || continue
|
||||||
|
|
||||||
|
target="$(page_file "$page")"
|
||||||
|
[[ -n "$target" ]] || fail "group \"$group\" routes to \"$page\", which is not a known page"
|
||||||
|
[[ -r "$pages_dir/$target" ]] || fail "group \"$group\" routes to \"$page\" but $target does not exist"
|
||||||
|
|
||||||
|
# Keys belonging to this group.
|
||||||
|
keys="$(awk -v g="\"$group\"" '
|
||||||
|
/key: "/ { match($0, /key: "[a-zA-Z]+"/); k = substr($0, RSTART+6, RLENGTH-7) }
|
||||||
|
$0 ~ "group: " g { if (k != "") print k; k = "" }
|
||||||
|
' "$schema" | sort -u)"
|
||||||
|
|
||||||
|
while read -r key; do
|
||||||
|
[[ -n "$key" ]] || continue
|
||||||
|
|
||||||
|
# Only settings rendered as schema-bound rows can be located at all.
|
||||||
|
homes="$(grep -rl "setting: \"$key\"" "$pages_dir"/*Page.qml 2>/dev/null | xargs -n1 basename 2>/dev/null || true)"
|
||||||
|
[[ -n "$homes" ]] || continue
|
||||||
|
|
||||||
|
checked=$((checked + 1))
|
||||||
|
grep -qx "$target" <<<"$homes" && continue
|
||||||
|
|
||||||
|
printf ' %s (group "%s") routes to %s but appears only on: %s\n' \
|
||||||
|
"$key" "$group" "$page" "$(tr '\n' ' ' <<<"$homes")" >&2
|
||||||
|
violations=$((violations + 1))
|
||||||
|
done <<<"$keys"
|
||||||
|
done <<<"$routes"
|
||||||
|
|
||||||
|
if (( violations > 0 )); then
|
||||||
|
fail "$violations setting(s) route to a page that does not contain them, so searching for them opens the wrong page"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'search routing contract: PASS (%d routed settings)\n' "$checked"
|
||||||
@@ -44,10 +44,16 @@ for mapping in \
|
|||||||
'HomePreferences.setAlias(id, alias);' \
|
'HomePreferences.setAlias(id, alias);' \
|
||||||
'DesktopPreferences.reload();' \
|
'DesktopPreferences.reload();' \
|
||||||
'DesktopPreferences.set("displays", value);' \
|
'DesktopPreferences.set("displays", value);' \
|
||||||
|
'Displays.currentLayout();' \
|
||||||
|
'Displays.applyProtectedLayout(layout);' \
|
||||||
|
'Displays.canConfirm;' \
|
||||||
|
'Displays.confirm();' \
|
||||||
|
'IdleLock.apply();' \
|
||||||
'Displays.externalChangeBlocked = blocked;' \
|
'Displays.externalChangeBlocked = blocked;' \
|
||||||
'SystemSettings.applyPersistedDisplayPolicy();' \
|
'SystemSettings.applyPersistedDisplayPolicy();' \
|
||||||
'Keybinds.applyReload();' \
|
'Keybinds.applyReload();' \
|
||||||
'Wallpaper.set(path);' \
|
'Wallpaper.applyCurrentPolicy(false);' \
|
||||||
|
'LockScreen.regenerate();' \
|
||||||
'Quickshell.reload(false);'; do
|
'Quickshell.reload(false);'; do
|
||||||
rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping"
|
rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping"
|
||||||
done
|
done
|
||||||
@@ -84,10 +90,13 @@ jq -e '
|
|||||||
"home.alias:light.desk=Desk",
|
"home.alias:light.desk=Desk",
|
||||||
"home.alias:light.office=Office",
|
"home.alias:light.office=Office",
|
||||||
"desktop.reload",
|
"desktop.reload",
|
||||||
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}",
|
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
|
||||||
"system.apply",
|
"display.confirm",
|
||||||
|
"idle.apply",
|
||||||
|
"lock.regenerate",
|
||||||
|
"wallpaper.apply-policy",
|
||||||
"keybinds.reload",
|
"keybinds.reload",
|
||||||
"wallpaper.set:/tmp/restored-wallpaper.jpg",
|
"system.apply",
|
||||||
"display.block:false",
|
"display.block:false",
|
||||||
"shell.reload"
|
"shell.reload"
|
||||||
]
|
]
|
||||||
@@ -122,10 +131,13 @@ jq -e '
|
|||||||
.calls == [
|
.calls == [
|
||||||
"home.reset",
|
"home.reset",
|
||||||
"desktop.reload",
|
"desktop.reload",
|
||||||
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}",
|
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
|
||||||
"system.apply",
|
"display.confirm",
|
||||||
|
"idle.apply",
|
||||||
|
"lock.regenerate",
|
||||||
|
"wallpaper.apply-policy",
|
||||||
"keybinds.reload",
|
"keybinds.reload",
|
||||||
"wallpaper.set:/tmp/restored-wallpaper.jpg",
|
"system.apply",
|
||||||
"display.block:false",
|
"display.block:false",
|
||||||
"shell.reload"
|
"shell.reload"
|
||||||
]
|
]
|
||||||
@@ -133,6 +145,25 @@ jq -e '
|
|||||||
and .favorites == []
|
and .favorites == []
|
||||||
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
|
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
|
||||||
|
|
||||||
|
# If the restored complete layout is rejected before it can be verified, the
|
||||||
|
# original persisted layout is put back and the shell is not reloaded over an
|
||||||
|
# unproven display state.
|
||||||
|
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||||
|
[[ "$(qs_test ipc call settings-backup-behavior applyDisplayFailure "$payload")" == "false" ]] \
|
||||||
|
|| fail 'a rejected restored display layout was reported as successful'
|
||||||
|
status="$(qs_test ipc call settings-backup-behavior status)"
|
||||||
|
jq -e '.calls == [
|
||||||
|
"home.reset",
|
||||||
|
"home.initialize:light.desk,light.office",
|
||||||
|
"home.alias:light.desk=Desk",
|
||||||
|
"home.alias:light.office=Office",
|
||||||
|
"desktop.reload",
|
||||||
|
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
|
||||||
|
"display.protect:{\"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}}",
|
||||||
|
"display.block:false"
|
||||||
|
] and (.lastError | contains("display layout"))' <<<"$status" >/dev/null \
|
||||||
|
|| fail "rejected display restore did not retain the original layout: $status"
|
||||||
|
|
||||||
# Restore refuses before launching the helper while a display apply/recovery is
|
# Restore refuses before launching the helper while a display apply/recovery is
|
||||||
# active, so no snapshot can race the confirmation boundary.
|
# active, so no snapshot can race the confirmation boundary.
|
||||||
qs_test ipc call settings-backup-behavior reset >/dev/null
|
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||||
|
|||||||
@@ -39,15 +39,17 @@ rg -Fq 'HomePreferences.resetHomeDefaults();' "$system_settings" \
|
|||||||
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
|
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
|
||||||
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
|
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
|
||||||
fi
|
fi
|
||||||
rg -Fq 'root.protectDisplays(protectedDisplays)' "$system_settings" \
|
rg -Fq 'DesktopPreferences.resetDesktopDefaults();' "$system_settings" \
|
||||||
|| fail 'restoreDefaults can apply unconfirmed display geometry during reload'
|
|| fail 'restoreDefaults does not clear confirmed display layout fields'
|
||||||
rg -Fq 'Keybinds.applyReload();' "$system_settings" \
|
rg -Fq 'Keybinds.applyReload();' "$system_settings" \
|
||||||
|| fail 'restoreDefaults does not replay shipped keybindings'
|
|| fail 'restoreDefaults does not replay shipped keybindings'
|
||||||
rg -Fq 'root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));' "$system_settings" \
|
rg -Fq 'root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));' "$system_settings" \
|
||||||
|| fail 'restoreDefaults does not visibly reapply the shipped wallpaper'
|
|| fail 'restoreDefaults does not visibly reapply the shipped wallpaper'
|
||||||
|
rg -Fq 'LockScreen.regenerate();' "$system_settings" \
|
||||||
|
|| fail 'restoreDefaults does not regenerate the shipped lock screen'
|
||||||
rg -Fq 'const effectivePath = path === "" ? root.shippedPath : path;' "$wallpaper_service" \
|
rg -Fq 'const effectivePath = path === "" ? root.shippedPath : path;' "$wallpaper_service" \
|
||||||
|| fail 'clearing wallpaper preference leaves the old image visible'
|
|| fail 'clearing wallpaper preference leaves the old image visible'
|
||||||
rg -Fq 'property string storedValue:' "$wallpaper_service" \
|
rg -Fq 'storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal' "$wallpaper_service" \
|
||||||
|| fail 'the shipped wallpaper cannot remain represented by the default empty preference'
|
|| fail 'the shipped wallpaper cannot remain represented by the default empty preference'
|
||||||
|
|
||||||
qs_for_harness() {
|
qs_for_harness() {
|
||||||
@@ -118,7 +120,7 @@ before="$(qs_for_harness ipc call settings-system-test stored windowRounding)"
|
|||||||
# ── Reset spans every store, not just the schema one ─────────────────────────
|
# ── Reset spans every store, not just the schema one ─────────────────────────
|
||||||
qs_for_harness ipc call settings-system-test seedHome >/dev/null
|
qs_for_harness ipc call settings-system-test seedHome >/dev/null
|
||||||
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null
|
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null
|
||||||
display_fixture='{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}'
|
display_fixture='{"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}}'
|
||||||
[[ "$(qs_for_harness ipc call settings-system-test commit displays "$display_fixture")" == "true" ]] \
|
[[ "$(qs_for_harness ipc call settings-system-test commit displays "$display_fixture")" == "true" ]] \
|
||||||
|| fail 'the protected display fixture did not apply'
|
|| fail 'the protected display fixture did not apply'
|
||||||
sleep 0.4
|
sleep 0.4
|
||||||
@@ -136,17 +138,33 @@ sleep 0.6
|
|||||||
reset_state="$(qs_for_harness ipc call settings-system-test resetState)"
|
reset_state="$(qs_for_harness ipc call settings-system-test resetState)"
|
||||||
jq -e '.calls == [
|
jq -e '.calls == [
|
||||||
"display.block:true",
|
"display.block:true",
|
||||||
"display.protect",
|
|
||||||
"keybinds.reload",
|
"keybinds.reload",
|
||||||
"wallpaper.set:",
|
"wallpaper.set:",
|
||||||
|
"lock.regenerate",
|
||||||
"display.block:false"
|
"display.block:false"
|
||||||
] and .displayBlocked == false' <<<"$reset_state" >/dev/null \
|
] and .displayBlocked == false' <<<"$reset_state" >/dev/null \
|
||||||
|| fail "reset did not safely replay non-reactive state: $reset_state"
|
|| fail "reset did not safely replay non-reactive state: $reset_state"
|
||||||
|
|
||||||
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \
|
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \
|
||||||
|| fail 'reset did not restore a schema default'
|
|| fail 'reset did not restore a schema default'
|
||||||
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == "$(jq -cS . <<<"$display_fixture")" ]] \
|
for default_case in \
|
||||||
|| fail 'reset replaced confirmed display geometry without confirmation'
|
'wallpaperMode|"single"' \
|
||||||
|
'wallpaperSlideshowPaths|[]' \
|
||||||
|
'wallpaperIntervalMinutes|30' \
|
||||||
|
'wallpaperShuffle|true' \
|
||||||
|
'wallpaperPerMonitor|{}' \
|
||||||
|
'lockBackgroundMode|"screenshot"' \
|
||||||
|
'lockBlurLevel|3' \
|
||||||
|
'lockShowClock|true' \
|
||||||
|
'lockShowDate|true' \
|
||||||
|
'lockShowUser|true' \
|
||||||
|
'lockFadeOnEmpty|false'; do
|
||||||
|
IFS='|' read -r key expected <<<"$default_case"
|
||||||
|
[[ "$(qs_for_harness ipc call settings-system-test stored "$key")" == "$expected" ]] \
|
||||||
|
|| fail "reset did not restore $key to $expected"
|
||||||
|
done
|
||||||
|
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == '{}' ]] \
|
||||||
|
|| fail 'reset retained confirmed arrangement fields instead of returning startup to shipped placement'
|
||||||
|
|
||||||
home_after="$(qs_for_harness ipc call settings-system-test homeState)"
|
home_after="$(qs_for_harness ipc call settings-system-test homeState)"
|
||||||
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
|
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
|
||||||
|
|||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Every "open the settings for this" jump must land somewhere real.
|
||||||
|
#
|
||||||
|
# ShellState.openSettings() validates its argument against an allow-list and
|
||||||
|
# falls back to Home for anything unknown. That fallback is sensible and it is
|
||||||
|
# also completely silent: a typo, or a page renamed later, turns a right-click
|
||||||
|
# into "opens Settings on the wrong page" with nothing logged and no error.
|
||||||
|
#
|
||||||
|
# Before this, exactly four places in the entire shell could reach Settings, so
|
||||||
|
# the risk was small. The bar now offers a jump on every widget, which makes the
|
||||||
|
# fallback worth guarding.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
||||||
|
modules="$repo_dir/config/dot/quickshell/modules"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'settings jump contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|
||||||
|
|| fail 'could not find the allow-list in ShellState'
|
||||||
|
|
||||||
|
jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \
|
||||||
|
| sed 's/openSettings("//; s/")//' | sort -u)"
|
||||||
|
[[ -n "$jumps" ]] || fail 'found no settings jumps at all -- this contract is not reading the modules correctly'
|
||||||
|
|
||||||
|
count=0
|
||||||
|
while read -r page; do
|
||||||
|
[[ -n "$page" ]] || continue
|
||||||
|
grep -qF "\"$page\"" <<<"$allowed_line" \
|
||||||
|
|| fail "a jump opens \"$page\", which ShellState does not allow -- openSettings falls back to Home silently, so this reads as a right-click that goes to the wrong page"
|
||||||
|
count=$((count + 1))
|
||||||
|
done <<<"$jumps"
|
||||||
|
|
||||||
|
# The bar is where a person looks first, and Pill has offered a right-click
|
||||||
|
# signal all along that nothing connected -- so the gesture did nothing on every
|
||||||
|
# widget in the bar. Anything built on Pill that can be configured should say so.
|
||||||
|
for widget in Clock WeatherWidget VitalsWidget StatusCluster MediaWidget; do
|
||||||
|
file="$modules/bar/$widget.qml"
|
||||||
|
[[ -r "$file" ]] || continue
|
||||||
|
grep -q 'onSecondaryActivated' "$file" \
|
||||||
|
|| fail "$widget has no right-click jump; Pill routes right-click to secondaryActivated, so leaving it unconnected makes the gesture silently inert"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'settings jump contract: PASS (%d distinct destinations)\n' "$count"
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# A setting has one schema-routed owner. A second page may mirror it only when
|
||||||
|
# this contract names the exact owner and mirror set. The same ownership rule
|
||||||
|
# keeps colour scheme propagation away from the focused Prism border.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
pages_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||||
|
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||||
|
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||||
|
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||||
|
looks="$repo_dir/config/dot/hypr/looks.lua"
|
||||||
|
readme="$pages_dir/README.md"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'settings ownership contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
python3 - "$pages_dir" "$schema" "$search" <<'PY' \
|
||||||
|
|| fail 'page ownership or intentional mirrors drifted'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
pages_dir = Path(sys.argv[1])
|
||||||
|
schema_text = Path(sys.argv[2]).read_text(encoding="utf-8")
|
||||||
|
search_text = Path(sys.argv[3]).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"animationsEnabled": {"owner": "appearance", "mirrors": {"accessibility"}},
|
||||||
|
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
|
||||||
|
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
|
||||||
|
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
|
||||||
|
"lockMinutes": {"owner": "power", "mirrors": {"privacy"}},
|
||||||
|
"lockOnSleep": {"owner": "power", "mirrors": {"privacy"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(text: str) -> str:
|
||||||
|
return re.sub(r"//.*", "", text)
|
||||||
|
|
||||||
|
|
||||||
|
def page_name(path: Path) -> str:
|
||||||
|
stem = path.stem.removesuffix("Page")
|
||||||
|
return re.sub(r"(?<!^)(?=[A-Z])", "-", stem).lower()
|
||||||
|
|
||||||
|
|
||||||
|
rows: dict[str, list[str]] = defaultdict(list)
|
||||||
|
row_pattern = re.compile(
|
||||||
|
r"(?:ToggleRow|SliderRow|ChoiceRow|TextEntryRow|TimeOfDayRow)\s*\{(?P<body>.*?)\}",
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
for page_path in pages_dir.glob("*Page.qml"):
|
||||||
|
text = strip_comments(page_path.read_text(encoding="utf-8"))
|
||||||
|
for match in row_pattern.finditer(text):
|
||||||
|
setting = re.search(r'setting\s*:\s*"([^"]+)"', match.group("body"))
|
||||||
|
if setting:
|
||||||
|
rows[setting.group(1)].append(page_name(page_path))
|
||||||
|
|
||||||
|
duplicates = {key: set(pages) for key, pages in rows.items() if len(pages) > 1}
|
||||||
|
if set(duplicates) != set(expected):
|
||||||
|
raise SystemExit(
|
||||||
|
f"duplicate keys are {sorted(duplicates)}, expected {sorted(expected)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
group_pages = dict(re.findall(r'"([^"]+)"\s*:\s*"([^"]+)"', search_text))
|
||||||
|
for key, policy in expected.items():
|
||||||
|
wanted_pages = {policy["owner"], *policy["mirrors"]}
|
||||||
|
if duplicates[key] != wanted_pages:
|
||||||
|
raise SystemExit(f"{key} appears on {sorted(duplicates[key])}, expected {sorted(wanted_pages)}")
|
||||||
|
|
||||||
|
block = re.search(
|
||||||
|
r'\{\s*\n\s*key:\s*"' + re.escape(key) + r'"(?P<body>.*?)\n\s*\}',
|
||||||
|
schema_text,
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
if not block:
|
||||||
|
raise SystemExit(f"schema entry missing for {key}")
|
||||||
|
group = re.search(r'group:\s*"([^"]+)"', block.group("body"))
|
||||||
|
if not group:
|
||||||
|
raise SystemExit(f"schema group missing for {key}")
|
||||||
|
routed = group_pages.get(group.group(1))
|
||||||
|
if routed != policy["owner"]:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{key} routes to {routed!r}, expected primary owner {policy['owner']!r}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
|
for needle in \
|
||||||
|
'## Setting ownership' \
|
||||||
|
'one primary page' \
|
||||||
|
'Intentional mirrors' \
|
||||||
|
'`animationsEnabled`' \
|
||||||
|
'`cursorInactiveTimeout`' \
|
||||||
|
'`cursorSize`' \
|
||||||
|
'`inactiveOpacity`' \
|
||||||
|
'`lockMinutes`' \
|
||||||
|
'`lockOnSleep`' \
|
||||||
|
'scheme-relative role' \
|
||||||
|
'mode, scale, rotation, arrangement, and primary role'; do
|
||||||
|
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
|
||||||
|
done
|
||||||
|
|
||||||
|
python3 - "$scheme" "$looks" <<'PY' \
|
||||||
|
|| fail 'scheme-relative border ownership drifted'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
scheme = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
looks = open(sys.argv[2], encoding="utf-8").read()
|
||||||
|
|
||||||
|
dark = re.search(r'property string inactiveBorderDark:\s*"([^"]+)"', scheme)
|
||||||
|
light = re.search(r'property string inactiveBorderLight:\s*"([^"]+)"', scheme)
|
||||||
|
effective = re.search(r'property string inactiveBorder:\s*root\.dark\s*\?\s*root\.inactiveBorderDark\s*:\s*root\.inactiveBorderLight', scheme)
|
||||||
|
if not dark or not light or not effective:
|
||||||
|
raise SystemExit("ColorScheme does not expose the two inactive-border roles")
|
||||||
|
if dark.group(1) not in looks or light.group(1) not in looks:
|
||||||
|
raise SystemExit("Hyprland startup values disagree with the live scheme roles")
|
||||||
|
|
||||||
|
without_comments = re.sub(r"//.*", "", scheme)
|
||||||
|
if re.search(r"(?<![A-Za-z_])active_border\b", without_comments):
|
||||||
|
raise SystemExit("ColorScheme writes the focused border")
|
||||||
|
if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
|
||||||
|
raise SystemExit("ColorScheme does not apply its effective inactive role")
|
||||||
|
PY
|
||||||
|
|
||||||
|
printf 'settings ownership contract: PASS\n'
|
||||||
@@ -50,7 +50,12 @@ PY
|
|||||||
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
|
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
|
||||||
require_row "$home_page" ChoiceRow temperatureUnit
|
require_row "$home_page" ChoiceRow temperatureUnit
|
||||||
require_row "$home_page" SliderRow weatherRefreshMinutes
|
require_row "$home_page" SliderRow weatherRefreshMinutes
|
||||||
require_row "$home_page" SliderRow vitalsIntervalMs
|
# vitalsIntervalMs moved to Appearance, beside the toggles it governs. It sat
|
||||||
|
# on Home while showCpu/showMemory/showGpu sat on Appearance -- one concept
|
||||||
|
# across two pages, which the ownership rule forbids and which made a search
|
||||||
|
# for it open a page that did not contain it.
|
||||||
|
appearance_page="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
|
||||||
|
require_row "$appearance_page" SliderRow vitalsIntervalMs
|
||||||
|
|
||||||
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
||||||
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
|
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ timezone|Timezone|datetime
|
|||||||
repeat delay|Repeat delay|shortcuts
|
repeat delay|Repeat delay|shortcuts
|
||||||
system health|System Health|services
|
system health|System Health|services
|
||||||
doctor|Copy health report|services
|
doctor|Copy health report|services
|
||||||
|
lock screen background|Lock screen background|appearance
|
||||||
|
password field|Password field|appearance
|
||||||
|
slideshow|Wallpaper mode|appearance
|
||||||
|
shuffle|Shuffle|appearance
|
||||||
|
per-display wallpaper|Per-display wallpaper|appearance
|
||||||
|
arrange displays|Arrange displays|displays
|
||||||
|
monitor position|Monitor position|displays
|
||||||
|
primary display|Primary display|displays
|
||||||
CASES
|
CASES
|
||||||
|
|
||||||
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \
|
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user