Author SHA1 Message Date
Gabriel Brown f9eba1e8c5 Add curated XKB option presets 2026-08-18 12:55:54 -04:00
Gabriel Brown 1afa41526a Add startup application picker 2026-08-18 12:50:42 -04:00
Gabriel Brown ccf46c40ef Expose 19 more compositor options that only looks.lua could reach
Measured the gap first: of the 38 real Hyprland options Panama's own Lua
sets, only 16 were editable in Settings. Everything else required a text
editor, which is the thing this app exists to stop. This closes most of
that: 66 mapped options now, from 47.

Window shape and shadows on Appearance: corner shape (rounding_power),
focused and fullscreen opacity, shadow falloff and hard-edged shadows.
Window edges, master layout and Hyprland's own notices on Desktop & Dock.

Three of these are corrections rather than additions.

Master layout options existed nowhere, while Settings has offered "Master
and stack" as a choice since this morning -- a layout you can select and
cannot configure is barely a choice. Its card is hidden unless that
layout is actually selected, since settings that do nothing under the
layout you are running are worse than not offering the layout at all.

The four Hyprland notices -- logo, splash, update news, donation nag --
are all turned off by looks.lua on the user's behalf. Defensible as a
default, but not a decision anyone could reverse. They are stored
positively ("show this") and written as Hyprland's `disable_*` through a
new `invert` flag, because a switch labelled "Disable splash text" that
must be ON to hide something is a small cruelty. The Lua does the same
inversion so both sides agree.

Everything new also reads from prefs in looks.lua. Without that these
would apply live and silently revert on the next compositor reload,
which is the failure this codebase keeps designing against.

Two shapes the write path had never seen. Border colours are gradients
and shadow offsets are vec2, and the verifier understood neither -- it
returned false for anything outside int/bool/float/str/css, so both
would have reported every write as rejected. Gradients also need real
care: the stubs declare them as `string|{colors,angle}`, and the string
form carries only ONE stop, so writing "rgba(a) rgba(b) 45deg" as a
string is accepted and keeps the previous value. Verified that directly.
They are also written in one notation and read back in another
(`{colors={"rgba(3b426199)"},angle=45}` becomes `993b4261 45deg`), so
comparison normalises both sides.

Border COLOUR is deliberately not exposed yet. col.inactive_border is
written by ColorScheme on every scheme change, so a user's choice would
be silently overwritten, and col.active_border is the Prism gradient,
which needs a colour control this app does not have. Shadow offset is
left out for the same reason -- the vec2 support is in place for
whenever the widget exists.

Verified each new option applies and reverts against the live
compositor, and that the schema, enum-map, nav, write and commit/reset
contracts all pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 12:27:34 -04:00
16 changed files with 646 additions and 30 deletions
+2 -2
View File
@@ -10,9 +10,9 @@ local prefs = require("prefs")
hl.config({ hl.config({
input = { input = {
kb_layout = prefs.get("keyboardLayout", "us"), kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "", kb_variant = prefs.get("keyboardVariant", ""),
kb_model = "", kb_model = "",
kb_options = "", kb_options = prefs.get("keyboardOptions", ""),
kb_rules = "", kb_rules = "",
numlock_by_default = prefs.get("numlockByDefault", true), numlock_by_default = prefs.get("numlockByDefault", true),
+36 -11
View File
@@ -36,7 +36,9 @@ hl.config({
and "rgba(a8aecb99)" or "rgba(3b426199)", and "rgba(a8aecb99)" or "rgba(3b426199)",
}, },
resize_on_border = true, resize_on_border = prefs.get("resizeOnBorder", true),
extend_border_grab_area = prefs.getInt("borderGrabArea", 15),
hover_icon_on_border = prefs.get("hoverIconOnBorder", true),
-- Enables the per-window "immediate" rule used for games in rules.lua. -- Enables the per-window "immediate" rule used for games in rules.lua.
-- Harmless on its own; tearing only happens where a rule opts in. -- Harmless on its own; tearing only happens where a rule opts in.
@@ -44,16 +46,22 @@ hl.config({
layout = "dwindle", layout = "dwindle",
snap = { enabled = true }, snap = {
enabled = true,
window_gap = prefs.getInt("snapWindowGap", 10),
monitor_gap = prefs.getInt("snapMonitorGap", 10),
respect_gaps = prefs.get("snapRespectGaps", false),
},
}, },
decoration = { decoration = {
-- 18 to match the shell's popover radius, so a window and a panel sitting -- 18 to match the shell's popover radius, so a window and a panel sitting
-- next to each other read as the same object family. -- next to each other read as the same object family.
rounding = prefs.get("windowRounding", 18), rounding = prefs.get("windowRounding", 18),
rounding_power = 2, rounding_power = prefs.get("roundingPower", 2),
active_opacity = 1.0, active_opacity = prefs.get("activeOpacity", 1.0),
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
inactive_opacity = prefs.get("inactiveOpacity", 1.0), inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = { blur = {
@@ -83,9 +91,13 @@ hl.config({
shadow = { shadow = {
enabled = prefs.get("shadowEnabled", true), enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20), range = prefs.get("shadowRange", 20),
render_power = 3, render_power = prefs.getInt("shadowRenderPower", 3),
sharp = false, sharp = prefs.get("shadowSharp", false),
color = "rgba(15161eee)", color = "rgba(15161eee)",
-- Deliberately not a setting: a two-axis offset needs a control we
-- do not have, and a slider bound to half a value is worse than
-- leaving it alone. SystemSettings understands the vec2 shape
-- already, so adding it later is only a matter of the widget.
offset = { 0, 4 }, offset = { 0, 4 },
scale = 1.0, scale = 1.0,
}, },
@@ -110,14 +122,27 @@ hl.config({
dwindle = { dwindle = {
-- Keep the split orientation a window was created with. Closest match -- Keep the split orientation a window was created with. Closest match
-- to how the Forge extension behaved on GNOME. -- to how the Forge extension behaved on GNOME.
preserve_split = true, preserve_split = prefs.get("preserveSplit", true),
smart_resizing = true, smart_resizing = true,
}, },
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
-- but Settings offers master as a choice, and a layout you can select and
-- cannot configure is barely a choice at all.
master = {
mfact = prefs.get("masterFactor", 0.55),
orientation = prefs.get("masterOrientation", "left"),
new_status = prefs.get("masterNewStatus", "slave"),
new_on_top = prefs.get("masterNewOnTop", false),
},
misc = { misc = {
force_default_wallpaper = 0, force_default_wallpaper = 0,
disable_hyprland_logo = true, -- Stored as "show the logo / show the splash" and written as Hyprland's
disable_splash_rendering = true, -- `disable_*`, matching the `invert` flag on these entries in
-- PreferenceSchema so both sides agree about which way round they are.
disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
disable_splash_rendering = not prefs.get("hyprlandSplash", false),
-- Same setting as Theme.fontFamily in the shell. If only the QML side -- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree -- followed the preference, the compositor and the shell would disagree
@@ -172,8 +197,8 @@ hl.config({
}, },
ecosystem = { ecosystem = {
no_update_news = true, no_update_news = not prefs.get("hyprlandUpdateNews", false),
no_donation_nag = true, no_donation_nag = not prefs.get("hyprlandDonationNag", false),
}, },
xwayland = { xwayland = {
@@ -206,6 +206,147 @@ Singleton {
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" } hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
}, },
{
key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Focused window opacity",
detail: "Fade even the focused window; 1.0 is fully opaque",
hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
},
{
key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Fullscreen opacity",
detail: "Applied instead of the focused opacity when a window is fullscreen",
hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
},
{
key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
group: "windows",
label: "Corner shape",
detail: "2 is a circular corner; higher values approach a squircle",
hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
},
// ── Window edges ────────────────────────────────────────────────────
// How the pointer interacts with a window's border, and how windows
// behave near each other. All shipped by looks.lua with no way to
// change any of it.
{
key: "resizeOnBorder", type: "bool", def: true, group: "edges",
label: "Resize by dragging the border",
detail: "Drag a window's edge to resize it, instead of only with the keyboard",
hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
},
{
key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
unit: "px",
group: "edges",
label: "Border grab area",
detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
},
{
key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
label: "Show the resize cursor",
detail: "Change the pointer when it is over a resizable border",
hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
},
{
key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
unit: "px",
group: "edges",
label: "Snap distance between windows",
detail: "How close two floating windows must be before they snap together",
hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
},
{
key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
unit: "px",
group: "edges",
label: "Snap distance to screen edges",
detail: "How close a floating window must be to an edge before it snaps to it",
hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
},
{
key: "snapRespectGaps", type: "bool", def: false, group: "edges",
label: "Snapping respects gaps",
detail: "Snapped windows keep the configured gap instead of touching",
hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
},
// ── Master layout ───────────────────────────────────────────────────
// Only meaningful when the tiling layout is Master and stack. Offering
// that layout with none of its options was an omission: it is the one
// layout whose whole behaviour is in these settings.
{
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
group: "master",
label: "Master area size",
detail: "How much of the screen the master window takes",
hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
},
{
key: "masterOrientation", type: "enum", def: "left", group: "master",
label: "Master area position",
detail: "Which side of the screen the master window occupies",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
{ value: "top", label: "Top" },
{ value: "bottom", label: "Bottom" },
{ value: "center", label: "Centre" }
],
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
},
{
key: "masterNewStatus", type: "enum", def: "slave", group: "master",
label: "New windows become",
detail: "Whether a new window takes the master area or joins the stack",
options: [
{ value: "master", label: "The master window" },
{ value: "slave", label: "Part of the stack" },
{ value: "inherit", label: "Whatever the focused window is" }
],
hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
},
{
key: "masterNewOnTop", type: "bool", def: false, group: "master",
label: "Add new windows at the top",
detail: "New stack windows go above the others rather than below",
hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
},
// ── Hyprland's own notices ──────────────────────────────────────────
// Panama turns all four off on the user's behalf. That is a defensible
// default and was not a decision anyone could reverse without editing
// looks.lua, which is precisely the kind of thing this app exists to
// stop.
{
key: "hyprlandLogo", type: "bool", def: false, group: "notices",
label: "Hyprland wallpaper",
detail: "The stock background Hyprland draws when no wallpaper is set",
hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
},
{
key: "hyprlandSplash", type: "bool", def: false, group: "notices",
label: "Splash text",
detail: "The line of text Hyprland renders over the stock background",
hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
},
{
key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
label: "Update announcements",
detail: "The window Hyprland opens after an update to describe what changed",
hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
},
{
key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
label: "Donation reminders",
detail: "The prompt Hyprland shows twice a year asking for support",
hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
},
// ── Effects ───────────────────────────────────────────────────────── // ── Effects ─────────────────────────────────────────────────────────
{ {
key: "blurEnabled", type: "bool", def: true, group: "effects", key: "blurEnabled", type: "bool", def: true, group: "effects",
@@ -241,6 +382,19 @@ Singleton {
detail: "How far the shadow spreads from the window edge", detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" } hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
}, },
{
key: "shadowSharp", type: "bool", def: false, group: "effects",
label: "Hard-edged shadow",
detail: "A crisp shadow instead of a soft falloff",
hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
},
{
key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
group: "effects",
label: "Shadow falloff",
detail: "How sharply the shadow fades out. Higher is tighter to the window",
hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
},
{ {
key: "glowEnabled", type: "bool", def: true, group: "effects", key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow", label: "Focus glow",
@@ -131,7 +131,10 @@ SettingsPage {
SliderRow { setting: "gapsIn" } SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" } SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" } SliderRow { setting: "borderSize"; zeroLabel: "None" }
SliderRow { setting: "inactiveOpacity"; divider: false } SliderRow { setting: "roundingPower" }
SliderRow { setting: "inactiveOpacity" }
SliderRow { setting: "activeOpacity" }
SliderRow { setting: "fullscreenOpacity"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -143,6 +146,8 @@ SettingsPage {
SliderRow { setting: "blurPasses" } SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" } ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" } SliderRow { setting: "shadowRange"; zeroLabel: "None" }
SliderRow { setting: "shadowRenderPower" }
ToggleRow { setting: "shadowSharp" }
ToggleRow { setting: "glowEnabled" } ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" } SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false } ToggleRow { setting: "animationsEnabled"; divider: false }
@@ -12,6 +12,7 @@ SettingsPage {
lede: "Choose what opens your files and links, and what starts with your session." lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: "" property string expandedRole: ""
property bool addingAutostart: false
readonly property var applications: DesktopEntries.applications.values readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [ readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
@@ -178,7 +179,28 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "User autostart" title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it." subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
ActionRow {
label: "Add an application"
detail: root.addingAutostart
? "Search the applications installed on this machine"
: "Start another installed application when you sign in"
action: root.addingAutostart ? "Close" : "Choose"
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
enabled: !DefaultApps.busy
onTriggered: root.addingAutostart = !root.addingAutostart
}
AutostartAppPicker {
visible: root.addingAutostart
width: parent.width
existing: DefaultApps.autostartEntries.map(entry => entry.id)
onPicked: id => {
DefaultApps.addAutostart(id);
root.addingAutostart = false;
}
}
TextRow { TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0 visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
@@ -0,0 +1,77 @@
// Adds an installed application to the user's freedesktop autostart directory.
import QtQuick
import Quickshell
import qs.config
import qs.modules.clipboard
Column {
id: root
required property var existing
signal picked(string id)
spacing: 0
function desktopId(entry: var): string {
const id = String(entry?.id ?? "");
return id.endsWith(".desktop") ? id : id + ".desktop";
}
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
const desktopId = root.desktopId(entry);
if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0)
continue;
const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase();
if (haystack.indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
SearchField {
id: search
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
label: String(candidate.modelData.name || root.desktopId(candidate.modelData))
detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData))
divider: candidate.index < root.matches.length - 1
controlWidth: 86
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Add"
onClicked: {
root.picked(root.desktopId(candidate.modelData));
search.text = "";
}
}
}
}
SettingRow {
visible: search.text.trim() !== "" && root.matches.length === 0
label: "No matching applications"
detail: "Only installed desktop applications can start with the session"
divider: false
}
}
@@ -67,6 +67,45 @@ SettingsPage {
} }
// GNOME's Multitasking panel, in Hyprland's terms. // GNOME's Multitasking panel, in Hyprland's terms.
// Only meaningful when the layout above is Master and stack. Hidden
// otherwise, because a card of settings that do nothing under the layout
// you are actually running is worse than not offering the layout at all.
SettingsCard {
visible: DesktopPreferences.get("windowLayout") === "master"
title: "Master and stack"
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
SliderRow { setting: "masterFactor" }
ChoiceRow { setting: "masterOrientation" }
ChoiceRow { setting: "masterNewStatus" }
ToggleRow { setting: "masterNewOnTop"; divider: false }
}
SettingsCard {
title: "Window edges"
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
ToggleRow { setting: "resizeOnBorder" }
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
ToggleRow { setting: "hoverIconOnBorder" }
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
ToggleRow { setting: "snapRespectGaps"; divider: false }
}
// Hyprland's own interruptions. Panama turns all four off, which is a
// defensible default and was not previously a decision anyone could
// reverse without editing looks.lua.
SettingsCard {
title: "Hyprland notices"
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
ToggleRow { setting: "hyprlandLogo" }
ToggleRow { setting: "hyprlandSplash" }
ToggleRow { setting: "hyprlandUpdateNews" }
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
}
SettingsCard { SettingsCard {
title: "Workspaces & focus" title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set." subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
@@ -21,6 +21,25 @@ SettingsPage {
// when nothing is being captured. Held here rather than per row so that // when nothing is being captured. Held here rather than per row so that
// starting a new capture cancels any other. // starting a new capture cancels any other.
property string capturingChord: "" property string capturingChord: ""
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
function xkbOptions(): var {
return root.storedXkbOptions
.split(",")
.map(option => option.trim())
.filter(option => option !== "");
}
function currentXkbOption(prefix: string): string {
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
}
function setXkbOption(prefix: string, option: string): void {
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
if (option !== "")
options.push(option);
SystemSettings.commitPreference("keyboardOptions", options.join(","));
}
title: "Input & Shortcuts" title: "Input & Shortcuts"
lede: "The Forge mental model, carried forward into native tiling." lede: "The Forge mental model, carried forward into native tiling."
@@ -35,6 +54,52 @@ SettingsPage {
// they are real controls. // they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" } TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" } TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
ChoiceGrid {
width: parent.width
label: "Caps Lock"
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
current: root.currentXkbOption("caps:")
options: [
{ value: "", label: "Standard" },
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
{ value: "caps:escape", label: "Escape" },
{ value: "caps:ctrl_modifier", label: "Control" }
]
onPicked: value => root.setXkbOption("caps:", value)
}
ChoiceGrid {
width: parent.width
label: "Compose key"
detail: "Type accented characters and symbols with memorable key sequences"
current: root.currentXkbOption("compose:")
options: [
{ value: "", label: "Off" },
{ value: "compose:ralt", label: "Right Alt" },
{ value: "compose:rwin", label: "Right Super" },
{ value: "compose:menu", label: "Menu" }
]
onPicked: value => root.setXkbOption("compose:", value)
}
ChoiceGrid {
width: parent.width
label: "Layout switching"
detail: "Used when Keyboard layout contains more than one comma-separated layout"
current: root.currentXkbOption("grp:")
options: [
{ value: "", label: "Off" },
{ value: "grp:win_space_toggle", label: "Super + Space" },
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
{ value: "grp:caps_toggle", label: "Caps Lock" }
]
onPicked: value => root.setXkbOption("grp:", value)
}
// Presets preserve every option outside their own category. The raw
// value remains visible for less common xkeyboard-config features.
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" } TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" } SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" } SliderRow { setting: "keyRepeatRate" }
@@ -34,6 +34,7 @@ DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml AccessibilityPage 1.0 AccessibilityPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml WallpaperPicker 1.0 WallpaperPicker.qml
ApplicationsPage 1.0 ApplicationsPage.qml ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsEditor 1.0 DockPinsEditor.qml DockPinsEditor 1.0 DockPinsEditor.qml
DockAppPicker 1.0 DockAppPicker.qml DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml ShortcutCapture 1.0 ShortcutCapture.qml
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)] return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]: def discovered_desktop_files() -> dict[str, Path]:
desktop_ids: set[str] = set() desktop_files: dict[str, Path] = {}
for root in xdg_data_roots(): for root in xdg_data_roots():
applications = root / "applications" applications = root / "applications"
if not applications.is_dir(): if not applications.is_dir():
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
if not path.is_file(): if not path.is_file():
continue continue
relative = path.relative_to(applications) relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts)) desktop_files.setdefault("-".join(relative.parts), path)
return desktop_ids return desktop_files
def discovered_desktop_ids() -> set[str]:
return set(discovered_desktop_files())
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None: def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
@@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None:
run(command) run(command)
def update_hidden(path: Path, *, hidden: bool) -> None: def with_hidden(original: str, *, hidden: bool) -> str:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
lines = original.splitlines() lines = original.splitlines()
output: list[str] = [] output: list[str] = []
section = "" section = ""
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
raise BoundaryError("That autostart entry is not a desktop file.") raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden: if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}") output.append(f"Hidden={'true' if hidden else 'false'}")
return "\n".join(output) + "\n"
mode = path.stat().st_mode
def write_atomic(path: Path, text: str, *, mode: int) -> None:
temporary_path: Path | None = None
try: try:
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary: ) as temporary:
temporary.write("\n".join(output) + "\n") temporary.write(text)
temporary.flush() temporary.flush()
os.fsync(temporary.fileno()) os.fsync(temporary.fileno())
temporary_path = Path(temporary.name) temporary_path = Path(temporary.name)
temporary_path.chmod(mode) temporary_path.chmod(mode)
os.replace(temporary_path, path) os.replace(temporary_path, path)
except OSError as error: except OSError as error:
if "temporary_path" in locals(): if temporary_path is not None:
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error raise BoundaryError("That autostart entry could not be updated.") from error
def update_hidden(path: Path, *, hidden: bool) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
mode = path.stat().st_mode
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
def add_autostart(desktop_id: str) -> None:
desktop_files = discovered_desktop_files()
require_desktop_id(desktop_id, discovered=set(desktop_files))
source = desktop_files[desktop_id]
directory = autostart_directory()
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise BoundaryError("The user autostart directory could not be created.") from error
target = directory / desktop_id
if target.is_symlink():
raise BoundaryError("That autostart entry is not available.")
if target.exists():
if not target.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(target, hidden=False)
return
try:
original = source.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That application could not be read.") from error
write_atomic(target, with_hidden(original, hidden=False), mode=0o644)
def set_autostart(desktop_id: str, enabled_text: str) -> None: def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}: if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or false.") raise BoundaryError("Autostart state must be true or false.")
@@ -260,10 +298,12 @@ def main(arguments: list[str]) -> int:
set_default(arguments[1], arguments[2]) set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart": elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2]) set_autostart(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "add-autostart":
add_autostart(arguments[1])
else: else:
raise BoundaryError( raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | " "Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false" "set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
) )
except BoundaryError as error: except BoundaryError as error:
print(str(error), file=sys.stderr) print(str(error), file=sys.stderr)
@@ -98,5 +98,16 @@ Singleton {
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]); mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
} }
function addAutostart(desktopId: string): void {
if (root.busy)
return;
if (!root.knownDesktopId(desktopId)) {
root.lastError = "Choose an installed application.";
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "add-autostart", desktopId]);
}
Component.onCompleted: root.refresh() Component.onCompleted: root.refresh()
} }
@@ -36,6 +36,9 @@ Singleton {
"pointer": "mouse", "pointer": "mouse",
"touchpad": "mouse", "touchpad": "mouse",
"multitasking": "desktop", "multitasking": "desktop",
"edges": "desktop",
"master": "desktop",
"notices": "desktop",
"weather": "appearance", "weather": "appearance",
"notifications": "notifications", "notifications": "notifications",
"capture": "screen-intelligence" "capture": "screen-intelligence"
@@ -273,6 +273,15 @@ Singleton {
// decides, and config/dot/hypr/prefs.lua does the same conversion via // decides, and config/dot/hypr/prefs.lua does the same conversion via
// prefs.getInt so both sides agree. // prefs.getInt so both sides agree.
function hyprValue(entry: var, value: var): var { function hyprValue(entry: var, value: var): var {
// Some options are phrased as a negative by the compositor -- the four
// Hyprland notices are all `disable_x` -- while the setting reads as
// "show x", because a switch labelled "Disable splash text" that must
// be ON to hide something is a small cruelty. `invert` bridges the two,
// in exactly one place, so nothing downstream has to remember which
// options are backwards.
if (entry.hypr.invert === true && typeof value === "boolean")
value = !value;
if (typeof value === "boolean" && entry.hypr.readAs !== "bool") if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
return value ? 1 : 0; return value ? 1 : 0;
return value; return value;
@@ -300,6 +309,24 @@ Singleton {
return value ? "true" : "false"; return value ? "true" : "false";
if (typeof value === "number") if (typeof value === "number")
return String(value); return String(value);
// A gradient is the one setting whose Lua form is not a scalar. The
// stubs declare it as `string|{colors:string[], angle?:number}`, and
// the string form only ever carries ONE stop -- writing
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
// the previous value, which is how a two-stop write looks like it
// worked and did nothing. Multi-stop must be the table form.
if (value && typeof value === "object" && Array.isArray(value.colors)) {
const stops = value.colors
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
.join(", ");
const angle = Number(value.angle);
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
}
// A vec2 reaches Lua as a two-element table.
if (Array.isArray(value) && value.length === 2)
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
// Strings only reach here after the schema's pattern check; quoting is // Strings only reach here after the schema's pattern check; quoting is
// belt-and-braces rather than the primary defence. // belt-and-braces rather than the primary defence.
return `"${String(value).replace(/["\\]/g, "")}"`; return `"${String(value).replace(/["\\]/g, "")}"`;
@@ -309,7 +336,15 @@ Singleton {
const parts = []; const parts = [];
for (const name in node) { for (const name in node) {
const child = node[name]; const child = node[name];
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`); // A leaf arrives pre-serialised as a string; anything else is
// either a nested section or a structured value (gradient, vec2)
// that serialiseValue knows how to render.
const rendered = typeof child === "string"
? child
: (Array.isArray(child) || (child && child.colors !== undefined)
? root.serialiseValue(child)
: root.serialiseTable(child));
parts.push(`${name} = ${rendered}`);
} }
return `{ ${parts.join(", ")} }`; return `{ ${parts.join(", ")} }`;
} }
@@ -353,6 +388,59 @@ Singleton {
root.drainQueue(); root.drainQueue();
} }
// Gradients are written in one notation and read back in another, so they
// cannot be compared directly the way every other type can.
//
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
// read: "993b4261 45deg"
//
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
// always appended even when it was never given. Comparing the raw strings
// reports every gradient write as rejected, which is what would have
// happened had this been added with readAs: "str".
function gradientMatches(expected: var, observed: string): bool {
if (typeof observed !== "string")
return false;
return root.normaliseGradient(expected) === root.normaliseGradient(observed);
}
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
function normaliseGradient(value: var): string {
const stops = [];
let angle = 0;
const readStop = function (text: string): void {
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
if (rgba) {
let hex = rgba[1].toLowerCase();
// rgb() has no alpha; the compositor reports it as fully opaque.
if (hex.length === 6)
hex = hex + "ff";
// RRGGBBAA in, AARRGGBB out.
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
return;
}
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
if (bare) {
stops.push(bare[1].toLowerCase());
return;
}
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
if (deg)
angle = Number(deg[1]);
};
if (value && typeof value === "object" && Array.isArray(value.colors)) {
value.colors.forEach(readStop);
if (value.angle !== undefined && isFinite(Number(value.angle)))
angle = Number(value.angle);
} else {
String(value).trim().split(/\s+/).forEach(readStop);
}
return stops.join(" ") + " " + angle + "deg";
}
function matchesObserved(entry: var, value: var, answer: var): bool { function matchesObserved(entry: var, value: var, answer: var): bool {
if (!answer) if (!answer)
return false; return false;
@@ -370,6 +458,12 @@ Singleton {
case "css": case "css":
// Gaps read back as a box, e.g. "10 10 10 10". // Gaps read back as a box, e.g. "10 10 10 10".
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected; return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
case "gradient":
return root.gradientMatches(expected, answer.gradient);
case "vec2":
return Array.isArray(answer.vec2) && Array.isArray(expected)
&& Number(answer.vec2[0]) === Number(expected[0])
&& Number(answer.vec2[1]) === Number(expected[1]);
} }
return false; return false;
} }
@@ -33,6 +33,9 @@ done
assert_contains 'title: "Default applications"' assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"' assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"' assert_contains 'title: "Compositor autostart"'
assert_contains 'AutostartAppPicker {'
assert_contains 'DefaultApps.addAutostart('
assert_contains 'label: "Add an application"'
assert_contains 'categories' assert_contains 'categories'
assert_contains 'genericName' assert_contains 'genericName'
assert_contains '.sort(' assert_contains '.sort('
@@ -123,4 +126,14 @@ fi
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \ [[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable' || fail 'default and autostart rows are not both whole-row activatable'
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
rg -Fq 'required property var existing' "$picker" \
|| fail 'autostart picker cannot exclude existing entries'
rg -Fq 'signal picked(string id)' "$picker" \
|| fail 'autostart picker does not emit a validated desktop id'
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|| fail 'autostart picker is not registered in the Settings module'
printf 'applications settings contract: PASS\n' printf 'applications settings contract: PASS\n'
+24
View File
@@ -29,6 +29,7 @@ assert_service_contains 'property string lastError'
assert_service_contains 'function refresh(): void' assert_service_contains 'function refresh(): void'
assert_service_contains 'function setDefault(role: string, desktopId: string): void' assert_service_contains 'function setDefault(role: string, desktopId: string): void'
assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void' assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void'
assert_service_contains 'function addAutostart(desktopId: string): void'
assert_service_contains 'DesktopEntries.applications.values' assert_service_contains 'DesktopEntries.applications.values'
if rg --quiet 'command\s*:\s*"' "$service"; then if rg --quiet 'command\s*:\s*"' "$service"; then
fail 'Process command must be an argument array' fail 'Process command must be an argument array'
@@ -227,4 +228,27 @@ if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then
fail 'read-only compositor entry was accepted for mutation' fail 'read-only compositor entry was accepted for mutation'
fi fi
$helper add-autostart org.mozilla.firefox.desktop
firefox_autostart="$config_home/autostart/org.mozilla.firefox.desktop"
[[ -f "$firefox_autostart" && ! -L "$firefox_autostart" ]] \
|| fail 'adding an installed application did not create a regular user autostart entry'
rg --quiet '^Name=Firefox$' "$firefox_autostart" \
|| fail 'adding an application did not preserve its desktop entry'
rg --quiet '^Hidden=false$' "$firefox_autostart" \
|| fail 'a newly added application was not enabled'
[[ "$(rg --count '^Hidden=' "$firefox_autostart")" == "1" ]] \
|| fail 'adding an application wrote more than one Hidden key'
$helper set-autostart org.mozilla.firefox.desktop false
$helper add-autostart org.mozilla.firefox.desktop
rg --quiet '^Hidden=false$' "$firefox_autostart" \
|| fail 'adding an existing disabled application did not re-enable it'
if $helper add-autostart org.example.Missing.desktop >/dev/null 2>&1; then
fail 'an undiscovered application was accepted for autostart'
fi
if $helper add-autostart ../escape.desktop >/dev/null 2>&1; then
fail 'an unsafe desktop id was accepted for autostart'
fi
printf 'default apps contract: PASS\n' printf 'default apps contract: PASS\n'
+43
View File
@@ -0,0 +1,43 @@
#!/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"
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 'kb_options = prefs.get("keyboardOptions", "")' "$input" \
|| fail 'Hyprland does not replay the stored keyboard options'
printf 'xkb presets contract: PASS\n'