Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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-4.0/settings.ini
|
||||
/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.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- ── GPU selection ───────────────────────────────────────────────────────────
|
||||
-- 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.
|
||||
@@ -47,7 +49,7 @@ end
|
||||
-- (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
|
||||
-- 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")
|
||||
|
||||
-- ── Toolkits ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hyprlock — lock screen
|
||||
# hyprlock — lock screen.
|
||||
#
|
||||
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
|
||||
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
|
||||
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||
# next colour scheme change.
|
||||
#
|
||||
# The colours here follow the desktop's light/dark setting. They used to be
|
||||
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
|
||||
# stayed dark when everything else went light.
|
||||
#
|
||||
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
|
||||
# template carries "R, G, B" triples where the rest of Panama uses hex. The two
|
||||
# Pango markup values are the exception and want ##rrggbb.
|
||||
#
|
||||
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -45,7 +54,7 @@ background {
|
||||
vibrancy_darkness = 0.05
|
||||
|
||||
# Shown if the screenshot is unavailable.
|
||||
color = rgba(34, 36, 54, 1.0)
|
||||
color = rgba(@BG@, 1.0)
|
||||
|
||||
zindex = -1
|
||||
}
|
||||
@@ -54,7 +63,7 @@ background {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:1000] date +"%-I:%M"
|
||||
color = rgba(200, 211, 245, 1.0)
|
||||
color = rgba(@FG@, 1.0)
|
||||
font_size = 120
|
||||
font_family = Adwaita Sans Light
|
||||
position = 0, 260
|
||||
@@ -65,7 +74,7 @@ label {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:60000] date +"%A, %B %-d"
|
||||
color = rgba(130, 139, 184, 1.0)
|
||||
color = rgba(@MUTED@, 1.0)
|
||||
font_size = 24
|
||||
font_family = Adwaita Sans
|
||||
position = 0, 160
|
||||
@@ -84,18 +93,18 @@ input-field {
|
||||
outline_thickness = 2
|
||||
rounding = 26
|
||||
|
||||
outer_color = rgba(130, 170, 255, 0.9)
|
||||
inner_color = rgba(46, 47, 61, 0.85)
|
||||
font_color = rgba(200, 211, 245, 1.0)
|
||||
check_color = rgba(130, 170, 255, 1.0)
|
||||
fail_color = rgba(255, 117, 127, 1.0)
|
||||
outer_color = rgba(@ACCENT@, 0.9)
|
||||
inner_color = rgba(@FIELD@, 0.85)
|
||||
font_color = rgba(@FG@, 1.0)
|
||||
check_color = rgba(@ACCENT@, 1.0)
|
||||
fail_color = rgba(@ERROR@, 1.0)
|
||||
|
||||
dots_size = 0.25
|
||||
dots_spacing = 0.3
|
||||
dots_center = true
|
||||
|
||||
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
|
||||
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
|
||||
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
|
||||
fade_on_empty = false
|
||||
hide_input = false
|
||||
@@ -105,7 +114,7 @@ input-field {
|
||||
label {
|
||||
monitor =
|
||||
text = $USER
|
||||
color = rgba(200, 211, 245, 0.9)
|
||||
color = rgba(@FG@, 0.9)
|
||||
font_size = 16
|
||||
font_family = Adwaita Sans
|
||||
position = 0, -110
|
||||
@@ -10,9 +10,9 @@ local prefs = require("prefs")
|
||||
hl.config({
|
||||
input = {
|
||||
kb_layout = prefs.get("keyboardLayout", "us"),
|
||||
kb_variant = "",
|
||||
kb_variant = prefs.get("keyboardVariant", ""),
|
||||
kb_model = "",
|
||||
kb_options = "",
|
||||
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
|
||||
kb_rules = "",
|
||||
|
||||
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 + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
|
||||
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
-- showing what you are choosing between.
|
||||
--
|
||||
-- The gesture needs three binds, not two. Tab steps the selection, and the
|
||||
-- switch is only COMMITTED when the modifier is released -- which is the sole
|
||||
-- way the compositor can tell the gesture is finished. That release bind is on
|
||||
-- the bare modifier, so it fires on EVERY Super release in the session; the
|
||||
-- handler returns immediately when no switch is open, which is why this is
|
||||
-- affordable.
|
||||
--
|
||||
-- The release bind carries no description on purpose: it is not a shortcut
|
||||
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
|
||||
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
|
||||
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
|
||||
-- Jump back to the previously focused window.
|
||||
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
||||
|
||||
|
||||
+49
-20
@@ -22,21 +22,22 @@ hl.config({
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
||||
-- is visible on both a tall and a wide window. Same two colours as
|
||||
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
|
||||
-- tmux theme this palette came from.
|
||||
-- The focused accent role: blue leads, orchid follows, on a
|
||||
-- diagonal so the pair is visible on both a tall and a wide
|
||||
-- window. ColorScheme never writes this role; a future accent
|
||||
-- picker can own it without fighting light/dark mode.
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- Unfocused windows get no colour at all. The gradient only means
|
||||
-- something if exactly one window on screen is wearing it.
|
||||
-- Follows the colour scheme: a dark neutral is invisible against a
|
||||
-- light desktop. services/ColorScheme.qml applies changes live;
|
||||
-- this is the value a fresh session starts from.
|
||||
-- The neutral inactive role follows the colour scheme because a
|
||||
-- dark neutral disappears against a light desktop.
|
||||
-- services/ColorScheme.qml applies the same values live; this is
|
||||
-- the value a fresh session starts from.
|
||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||
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.
|
||||
-- Harmless on its own; tearing only happens where a rule opts in.
|
||||
@@ -44,16 +45,22 @@ hl.config({
|
||||
|
||||
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 = {
|
||||
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
||||
-- next to each other read as the same object family.
|
||||
rounding = 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),
|
||||
|
||||
blur = {
|
||||
@@ -83,9 +90,13 @@ hl.config({
|
||||
shadow = {
|
||||
enabled = prefs.get("shadowEnabled", true),
|
||||
range = prefs.get("shadowRange", 20),
|
||||
render_power = 3,
|
||||
sharp = false,
|
||||
render_power = prefs.getInt("shadowRenderPower", 3),
|
||||
sharp = prefs.get("shadowSharp", false),
|
||||
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 },
|
||||
scale = 1.0,
|
||||
},
|
||||
@@ -110,14 +121,32 @@ hl.config({
|
||||
dwindle = {
|
||||
-- Keep the split orientation a window was created with. Closest match
|
||||
-- to how the Forge extension behaved on GNOME.
|
||||
preserve_split = true,
|
||||
preserve_split = prefs.get("preserveSplit", 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 = {
|
||||
force_default_wallpaper = 0,
|
||||
disable_hyprland_logo = true,
|
||||
disable_splash_rendering = true,
|
||||
-- Stored as "show the logo / show the splash" and written as Hyprland's
|
||||
-- `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
|
||||
-- followed the preference, the compositor and the shell would disagree
|
||||
@@ -172,8 +201,8 @@ hl.config({
|
||||
},
|
||||
|
||||
ecosystem = {
|
||||
no_update_news = true,
|
||||
no_donation_nag = true,
|
||||
no_update_news = not prefs.get("hyprlandUpdateNews", false),
|
||||
no_donation_nag = not prefs.get("hyprlandDonationNag", false),
|
||||
},
|
||||
|
||||
xwayland = {
|
||||
|
||||
@@ -206,6 +206,147 @@ Singleton {
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
||||
@@ -241,6 +382,19 @@ Singleton {
|
||||
detail: "How far the shadow spreads from the window edge",
|
||||
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",
|
||||
label: "Focus glow",
|
||||
@@ -282,7 +436,7 @@ Singleton {
|
||||
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
|
||||
// list, e.g. "compose:ralt,caps:escape".
|
||||
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
||||
@@ -349,7 +503,7 @@ Singleton {
|
||||
{
|
||||
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||
unit: "s",
|
||||
group: "input",
|
||||
group: "pointer",
|
||||
label: "Hide pointer after",
|
||||
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
||||
// Reported as a float even though it is only ever set to whole
|
||||
@@ -394,6 +548,12 @@ Singleton {
|
||||
detail: "Swap the primary and secondary buttons",
|
||||
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 ────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -652,6 +812,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 ──────────────────────────────────────────────────────
|
||||
// The single largest thing in this desktop that used to be changeable
|
||||
// only by editing Theme.qml.
|
||||
@@ -681,6 +859,91 @@ Singleton {
|
||||
label: "Interface text size",
|
||||
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 ───────────────────────────────────────────────────
|
||||
// Backed by gsettings so GTK applications agree with the shell, and
|
||||
|
||||
@@ -13,6 +13,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.toggle("activity")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
|
||||
onSecondaryActivated: ShellState.openSettings("privacy")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
|
||||
@@ -15,6 +15,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.openDateMenu("agenda")
|
||||
|
||||
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
|
||||
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||
|
||||
ToolTip.visible: root.hovered && root.visible
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
|
||||
|
||||
@@ -26,6 +26,11 @@ Pill {
|
||||
|
||||
onActivated: ShellState.toggleDateMenu("agenda")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
|
||||
// format toggles are mirrored on Appearance, but someone right-clicking a
|
||||
// clock is far more often after the time itself than its typography.
|
||||
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Qt.formatDateTime(clock.date, root.format)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
@@ -34,6 +35,9 @@ Pill {
|
||||
onActivated: if (root.player?.canTogglePlaying)
|
||||
root.player.togglePlaying()
|
||||
|
||||
// Right-click opens the settings that govern this widget. Output device and per-application volume.
|
||||
onSecondaryActivated: ShellState.openSettings("sound")
|
||||
|
||||
// Scroll up = previous, down = next — the same direction as the workspace
|
||||
// switcher, so the whole bar scrolls consistently.
|
||||
onScrolled: delta => {
|
||||
|
||||
@@ -24,6 +24,9 @@ Pill {
|
||||
|
||||
onActivated: root.requestQuickSettings()
|
||||
|
||||
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
|
||||
onSecondaryActivated: ShellState.openSettings("connectivity")
|
||||
|
||||
// ── Audio ───────────────────────────────────────────────────────────────
|
||||
// Without a tracker, volume and muted silently read as zero/false.
|
||||
PwObjectTracker {
|
||||
|
||||
@@ -13,6 +13,10 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Right-click opens the settings that govern this widget. Which readouts
|
||||
// appear in the bar, and how often they update.
|
||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||
|
||||
interactive: false
|
||||
|
||||
Row {
|
||||
|
||||
@@ -10,6 +10,9 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
|
||||
onSecondaryActivated: ShellState.openSettings("home")
|
||||
|
||||
interactive: false
|
||||
visible: Weather.available
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ import qs.services
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
property string expandedPicker: ""
|
||||
|
||||
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 {
|
||||
Column {
|
||||
@@ -83,7 +85,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Typography"
|
||||
title: "Shell typography"
|
||||
subtitle: Fonts.lastError !== ""
|
||||
? Fonts.lastError
|
||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
||||
@@ -123,6 +125,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 {
|
||||
title: "Windows"
|
||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||
@@ -131,7 +264,10 @@ SettingsPage {
|
||||
SliderRow { setting: "gapsIn" }
|
||||
SliderRow { setting: "gapsOut" }
|
||||
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 {
|
||||
@@ -143,6 +279,8 @@ SettingsPage {
|
||||
SliderRow { setting: "blurPasses" }
|
||||
ToggleRow { setting: "shadowEnabled" }
|
||||
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
||||
SliderRow { setting: "shadowRenderPower" }
|
||||
ToggleRow { setting: "shadowSharp" }
|
||||
ToggleRow { setting: "glowEnabled" }
|
||||
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||
@@ -162,7 +300,10 @@ SettingsPage {
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
ToggleRow { setting: "showGpu"; divider: true }
|
||||
// Refresh interval was on the Home page, which split one concept across
|
||||
// two pages -- what the vitals show here, how often they update there.
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
@@ -182,11 +323,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ SettingsPage {
|
||||
lede: "Choose what opens your files and links, and what starts with your session."
|
||||
|
||||
property string expandedRole: ""
|
||||
property bool addingAutostart: false
|
||||
readonly property var applications: DesktopEntries.applications.values
|
||||
readonly property var roles: [
|
||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
||||
@@ -178,7 +179,28 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
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 {
|
||||
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
||||
|
||||
@@ -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.
|
||||
// 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 {
|
||||
title: "Workspaces & focus"
|
||||
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: "allowWorkspaceCycles" }
|
||||
ToggleRow { setting: "focusOnActivate" }
|
||||
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
||||
ChoiceRow { setting: "followMouse"; divider: false }
|
||||
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -117,12 +117,6 @@ SettingsPage {
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Processor, memory, and graphics activity in the bar"
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: false }
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: summaryCards
|
||||
|
||||
|
||||
@@ -31,7 +31,12 @@ SettingsPage {
|
||||
ChoiceRow { setting: "accelProfile" }
|
||||
ToggleRow { setting: "naturalScroll" }
|
||||
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 {
|
||||
|
||||
@@ -59,6 +59,36 @@ If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
|
||||
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
|
||||
file.
|
||||
|
||||
## Setting ownership
|
||||
|
||||
Every preference has **one primary page**, derived from its schema `group` and
|
||||
the route in `services/SettingsSearch.qml`. Search results always open that
|
||||
owner. A control may appear on a second page only when the same adaptation is
|
||||
part of another established mental model; otherwise use a 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. |
|
||||
|
||||
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
|
||||
|
||||
| Component | For |
|
||||
|
||||
@@ -21,6 +21,25 @@ SettingsPage {
|
||||
// when nothing is being captured. Held here rather than per row so that
|
||||
// starting a new capture cancels any other.
|
||||
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"
|
||||
lede: "The Forge mental model, carried forward into native tiling."
|
||||
@@ -35,6 +54,52 @@ SettingsPage {
|
||||
// they are real controls.
|
||||
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||
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" }
|
||||
SliderRow { setting: "keyRepeatDelay" }
|
||||
SliderRow { setting: "keyRepeatRate" }
|
||||
|
||||
@@ -34,6 +34,7 @@ DateTimePage 1.0 DateTimePage.qml
|
||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||
DockAppPicker 1.0 DockAppPicker.qml
|
||||
ShortcutCapture 1.0 ShortcutCapture.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)]
|
||||
|
||||
|
||||
def discovered_desktop_ids() -> set[str]:
|
||||
desktop_ids: set[str] = set()
|
||||
def discovered_desktop_files() -> dict[str, Path]:
|
||||
desktop_files: dict[str, Path] = {}
|
||||
for root in xdg_data_roots():
|
||||
applications = root / "applications"
|
||||
if not applications.is_dir():
|
||||
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(applications)
|
||||
desktop_ids.add("-".join(relative.parts))
|
||||
return desktop_ids
|
||||
desktop_files.setdefault("-".join(relative.parts), path)
|
||||
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:
|
||||
@@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None:
|
||||
run(command)
|
||||
|
||||
|
||||
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
|
||||
|
||||
def with_hidden(original: str, *, hidden: bool) -> str:
|
||||
lines = original.splitlines()
|
||||
output: list[str] = []
|
||||
section = ""
|
||||
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
|
||||
raise BoundaryError("That autostart entry is not a desktop file.")
|
||||
if not wrote_hidden:
|
||||
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:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||
) as temporary:
|
||||
temporary.write("\n".join(output) + "\n")
|
||||
temporary.write(text)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
temporary_path.chmod(mode)
|
||||
os.replace(temporary_path, path)
|
||||
except OSError as error:
|
||||
if "temporary_path" in locals():
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
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:
|
||||
if enabled_text not in {"true", "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])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
||||
set_autostart(arguments[1], arguments[2])
|
||||
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
||||
add_autostart(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"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:
|
||||
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())
|
||||
@@ -130,7 +130,7 @@ RUNTIME_LINK_TARGETS = (
|
||||
("vicinae", Path("config/dot/vicinae")),
|
||||
)
|
||||
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")
|
||||
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||
PROBE_ENVIRONMENT_KEYS = (
|
||||
@@ -436,7 +436,21 @@ def executable_check(check_id: str, title: str, executable: str, config: DoctorC
|
||||
|
||||
|
||||
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:
|
||||
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
||||
if result.state == "ok":
|
||||
|
||||
@@ -31,6 +31,59 @@ case "$scheme" in
|
||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -146,4 +199,5 @@ jq -cn \
|
||||
--arg gtk "$status_gtk" \
|
||||
--arg btop "$status_btop" \
|
||||
--arg tmux "$status_tmux" \
|
||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
|
||||
--arg hyprlock "$status_hyprlock" \
|
||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
|
||||
|
||||
@@ -25,25 +25,14 @@ import qs.config
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string cursorTheme: ""
|
||||
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 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
|
||||
// Process runs one at a time.
|
||||
property var pending: []
|
||||
@@ -71,7 +60,6 @@ Singleton {
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
themeQuery.running = true;
|
||||
settle.restart();
|
||||
}
|
||||
|
||||
@@ -102,9 +90,6 @@ Singleton {
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||||
["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]);
|
||||
root.enqueue(commands);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ Singleton {
|
||||
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||
|
||||
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: ""
|
||||
|
||||
// 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]
|
||||
];
|
||||
|
||||
// Unfocused window borders. The focused border is the prism gradient and
|
||||
// is already scheme-independent; the inactive one is a flat neutral that
|
||||
// would be invisible against the opposite background.
|
||||
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
|
||||
// Unfocused window borders need scheme-relative contrast. The focused
|
||||
// Prism border is deliberately owned by the accent/theme layer.
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
||||
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||
|
||||
// Applications that predate org.freedesktop.appearance and carry their
|
||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||
|
||||
@@ -98,5 +98,16 @@ Singleton {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,20 @@ import qs.config
|
||||
Singleton {
|
||||
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
|
||||
// entry here still appears in results and routes to Home rather than being
|
||||
// dropped, so adding a group can never make a setting unreachable.
|
||||
readonly property var groupPages: ({
|
||||
"clock": "appearance",
|
||||
"vitals": "appearance",
|
||||
"typography": "appearance",
|
||||
"themes": "appearance",
|
||||
"titlebar": "appearance",
|
||||
"windows": "appearance",
|
||||
"effects": "appearance",
|
||||
"wallpaper": "appearance",
|
||||
@@ -36,7 +44,10 @@ Singleton {
|
||||
"pointer": "mouse",
|
||||
"touchpad": "mouse",
|
||||
"multitasking": "desktop",
|
||||
"weather": "appearance",
|
||||
"edges": "desktop",
|
||||
"master": "desktop",
|
||||
"notices": "desktop",
|
||||
"weather": "home",
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence"
|
||||
})
|
||||
|
||||
@@ -273,6 +273,15 @@ Singleton {
|
||||
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
||||
// prefs.getInt so both sides agree.
|
||||
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")
|
||||
return value ? 1 : 0;
|
||||
return value;
|
||||
@@ -300,6 +309,24 @@ Singleton {
|
||||
return value ? "true" : "false";
|
||||
if (typeof value === "number")
|
||||
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
|
||||
// belt-and-braces rather than the primary defence.
|
||||
return `"${String(value).replace(/["\\]/g, "")}"`;
|
||||
@@ -309,7 +336,15 @@ Singleton {
|
||||
const parts = [];
|
||||
for (const name in node) {
|
||||
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(", ")} }`;
|
||||
}
|
||||
@@ -353,6 +388,59 @@ Singleton {
|
||||
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 {
|
||||
if (!answer)
|
||||
return false;
|
||||
@@ -370,6 +458,12 @@ Singleton {
|
||||
case "css":
|
||||
// Gaps read back as a box, e.g. "10 10 10 10".
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import qs.config
|
||||
import qs.services
|
||||
import qs.modules.bar
|
||||
import qs.modules.dock
|
||||
import qs.modules.switcher
|
||||
import qs.modules.overview
|
||||
import qs.modules.quicksettings
|
||||
import qs.modules.notifications
|
||||
@@ -68,6 +69,13 @@ ShellRoot {
|
||||
Dock {}
|
||||
}
|
||||
|
||||
// The Alt-Tab overlay. Present only while a switch is in progress; the
|
||||
// Loader inside it keeps the window unbuilt the rest of the time.
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
WindowSwitcher {}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
SignalGlass {}
|
||||
@@ -103,6 +111,18 @@ ShellRoot {
|
||||
// Every parameter AND the return type must be annotated, or Quickshell
|
||||
// silently declines to register the function — it will not warn you.
|
||||
|
||||
// Driven entirely from keybinds: Super+Tab steps, and a bind on Super
|
||||
// RELEASE commits. `commit` therefore runs on every Super release in the
|
||||
// session, so it returns immediately when no switch is open.
|
||||
IpcHandler {
|
||||
target: "switcher"
|
||||
|
||||
function next(): void { WindowSwitcherState.step(true); }
|
||||
function previous(): void { WindowSwitcherState.step(false); }
|
||||
function commit(): void { WindowSwitcherState.commit(); }
|
||||
function cancel(): void { WindowSwitcherState.cancel(); }
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "overview"
|
||||
function toggle(): void { ShellState.toggle("overview"); }
|
||||
@@ -165,6 +185,23 @@ ShellRoot {
|
||||
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,
|
||||
configured: Wallpaper.configured,
|
||||
availableCount: Wallpaper.available.length,
|
||||
lastError: Wallpaper.lastError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A small diagnostics surface doubles as a deterministic contract harness.
|
||||
// Real producers call StatusEvents.publish() directly; fixtures never run
|
||||
// unless explicitly requested over IPC by the test suite.
|
||||
|
||||
@@ -89,6 +89,38 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
|
||||
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -33,6 +33,9 @@ done
|
||||
assert_contains 'title: "Default applications"'
|
||||
assert_contains 'title: "User autostart"'
|
||||
assert_contains 'title: "Compositor autostart"'
|
||||
assert_contains 'AutostartAppPicker {'
|
||||
assert_contains 'DefaultApps.addAutostart('
|
||||
assert_contains 'label: "Add an application"'
|
||||
assert_contains 'categories'
|
||||
assert_contains 'genericName'
|
||||
assert_contains '.sort('
|
||||
@@ -123,4 +126,14 @@ fi
|
||||
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|
||||
|| 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'
|
||||
|
||||
@@ -29,6 +29,7 @@ assert_service_contains 'property string lastError'
|
||||
assert_service_contains 'function refresh(): 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 addAutostart(desktopId: string): void'
|
||||
assert_service_contains 'DesktopEntries.applications.values'
|
||||
if rg --quiet 'command\s*:\s*"' "$service"; then
|
||||
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'
|
||||
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'
|
||||
|
||||
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'
|
||||
@@ -4,6 +4,13 @@ set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
--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)
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then
|
||||
printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n'
|
||||
|
||||
@@ -47,6 +47,8 @@ fail() {
|
||||
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
||||
[[ -f "$source_harness" ]] || fail 'health harness 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
|
||||
# contract static while pinning the typed, redacted IPC boundary it exports.
|
||||
|
||||
+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'
|
||||
@@ -216,11 +216,14 @@ jq -e '.checks[] | select(.id == "panama.caffeine")
|
||||
|| fail 'Caffeine detail exposed inhibitor 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
|
||||
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
||||
|| 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
|
||||
# count that can be hidden by the running Quickshell process.
|
||||
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
+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"
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
#!/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'; 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"
|
||||
require_row "$home_page" ChoiceRow temperatureUnit
|
||||
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"
|
||||
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Common XKB behavior should be discoverable without hiding the raw option
|
||||
# string from advanced users. This is source-only so it never remaps the live
|
||||
# keyboard while the desktop is in use.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/ShortcutsPage.qml"
|
||||
input="$repo_dir/config/dot/hypr/input.lua"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
|
||||
fail() {
|
||||
printf 'xkb presets contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for needle in \
|
||||
'function currentXkbOption(prefix: string): string' \
|
||||
'function setXkbOption(prefix: string, option: string): void' \
|
||||
'label: "Caps Lock"' \
|
||||
'value: "caps:escape_shifted_capslock"' \
|
||||
'value: "caps:ctrl_modifier"' \
|
||||
'label: "Compose key"' \
|
||||
'value: "compose:ralt"' \
|
||||
'label: "Layout switching"' \
|
||||
'value: "grp:win_space_toggle"' \
|
||||
'SystemSettings.commitPreference("keyboardOptions"' \
|
||||
'TextEntryRow { setting: "keyboardOptions"'; do
|
||||
rg -Fq "$needle" "$page" || fail "Shortcuts is missing $needle"
|
||||
done
|
||||
|
||||
# Picking one category must replace only that category, preserving advanced
|
||||
# options from every other group.
|
||||
rg -Fq 'option.indexOf(prefix) !== 0' "$page" \
|
||||
|| fail 'preset updates do not preserve unrelated XKB options'
|
||||
|
||||
rg -Fq 'kb_variant = prefs.get("keyboardVariant", "")' "$input" \
|
||||
|| fail 'Hyprland does not replay the stored keyboard variant'
|
||||
rg -Fq 'key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock"' "$schema" \
|
||||
|| fail 'the shipped XKB default no longer matches the migrated GNOME behavior'
|
||||
rg -Fq 'kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock")' "$input" \
|
||||
|| fail 'Hyprland does not replay the stored keyboard options'
|
||||
|
||||
printf 'xkb presets contract: PASS\n'
|
||||
Reference in New Issue
Block a user