diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index e668e26..0caf85d 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -36,7 +36,9 @@ hl.config({ 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 +46,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 +91,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 +122,27 @@ 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), -- 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 +197,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 = { diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index a60ade8..7ee0c0e 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -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", diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index c2b7a1c..f5a9db4 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -131,7 +131,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 +146,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 } diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index 972e083..b87aed3 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -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." diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index c6fb8d4..4bd421d 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -36,6 +36,9 @@ Singleton { "pointer": "mouse", "touchpad": "mouse", "multitasking": "desktop", + "edges": "desktop", + "master": "desktop", + "notices": "desktop", "weather": "appearance", "notifications": "notifications", "capture": "screen-intelligence" diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index 1f4c803..54beb3b 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -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; }