pragma Singleton import QtQuick import Quickshell import Quickshell.Io // Shared structural style tokens for the shell. Color is the palette // singleton; Style holds everything else themes can influence — corner // rounding, gap to screen edges, state affordances, spacing, typography // scale, and bar dimensions. // // `cornerRadius` mirrors Hyprland's `decoration:rounding`. `gapsOut` is // half of Hyprland's `general:gaps_out` — Hyprland's value works well as // a window-to-window gap but feels too cavernous when used as the // distance from a panel/notification to the screen edge, so the shell // halves it. Themes and user Hyprland config own those values; the // shell picks them up by re-running `hyprctl getoption` on startup and // after theme IPC applies a theme. // // Typography, spacing, and bar size come from theme/shell.toml. // `[font] base-size` is the rem root; every `Style.font.` derives // from it via the scale multipliers below unless the theme pins that // specific token. `[spacing] scale` multiplies shared margins, gaps, // padding, controls, and panel dimensions while preserving each component's // proportions; by default it also tracks `base-size`. `[bar] // size-horizontal` / `size-vertical` set the cross-axis dimension for // top/bottom and left/right bars at the default 12px font size; by default // those dimensions scale with `base-size` so larger fonts don't clip. QtObject { id: root property int cornerRadius: 0 property int gapsOut: 5 // ---------------------------------------------------------- state tokens // // Shared interactive-state tokens for every reusable surface in the kit. // The vocabulary: // normal — idle control chrome // hover-cursor — mouse hover OR panel keyboard cursor (`hasCursor`) // selected — persistent chosen/current state // focus — actual Qt activeFocus, defaulting to hover-cursor // // Each state has a color token plus fill/border alphas. Color tokens // may be palette roles (`foreground`, `accent`, `urgent`, `background`) // or hex colors. Set a state's border width to 0 to drop that border. property var styleOverrides: ({}) function styleRawNum(key) { var v = styleOverrides[key] var n = Number(v) return isFinite(n) ? n : null } function styleNum(key, fallback) { var n = styleRawNum(key) return n === null ? fallback : n } function styleAlpha(key, fallback) { return Util.clampAlpha(styleNum(key, fallback)) } function styleString(key, fallback) { var v = styleOverrides[key] if (typeof v !== "string") return fallback v = v.replace(/^\s+|\s+$/g, "") return v.length > 0 ? v : fallback } readonly property string normalColorToken: styleString("normal-color", "foreground") readonly property string hoverColorToken: styleString("hover-cursor-color", "foreground") readonly property string selectedColorToken: styleString("selected-color", "foreground") readonly property string pressedColorToken: styleString("pressed-color", hoverColorToken) readonly property string focusColorToken: styleString("focus-color", hoverColorToken) readonly property string selectionColorToken: styleString("selection-color", "foreground") readonly property int normalBorderWidth: Math.max(0, Math.round(styleNum("normal-border-width", 1))) readonly property int hoverBorderWidth: Math.max(0, Math.round(styleNum("hover-cursor-border-width", normalBorderWidth))) readonly property int selectedBorderWidth: Math.max(0, Math.round(styleNum("selected-border-width", 0))) readonly property int focusBorderWidth: Math.max(0, Math.round(styleNum("focus-border-width", hoverBorderWidth))) readonly property real normalFillAlpha: styleAlpha("normal-fill-alpha", 0.04) readonly property real hoverFillAlpha: styleAlpha("hover-cursor-fill-alpha", 0.08) readonly property real selectedFillAlpha: styleAlpha("selected-fill-alpha", 0.18) readonly property real pressedFillAlpha: styleAlpha("pressed-fill-alpha", 0.22) readonly property real focusFillAlpha: styleAlpha("focus-fill-alpha", hoverFillAlpha) readonly property real selectionFillAlpha: styleAlpha("selection-fill-alpha", 0.35) readonly property real normalBorderAlpha: styleAlpha("normal-border-alpha", 0.4) readonly property real hoverBorderAlpha: styleAlpha("hover-cursor-border-alpha", 0.25) readonly property real selectedBorderAlpha: styleAlpha("selected-border-alpha", 1.0) readonly property real focusBorderAlpha: styleAlpha("focus-border-alpha", hoverBorderAlpha) function colorFromHex(value, fallback) { var s = String(value || "").replace(/^\s+|\s+$/g, "") var shortHex = s.match(/^#([0-9A-Fa-f]{3})$/) if (shortHex) { var sh = shortHex[1] return Qt.rgba( parseInt(sh.charAt(0) + sh.charAt(0), 16) / 255, parseInt(sh.charAt(1) + sh.charAt(1), 16) / 255, parseInt(sh.charAt(2) + sh.charAt(2), 16) / 255, 1) } var hex = s.match(/^#([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$/) if (!hex) return fallback var h = hex[1] return Qt.rgba( parseInt(h.substr(0, 2), 16) / 255, parseInt(h.substr(2, 2), 16) / 255, parseInt(h.substr(4, 2), 16) / 255, hex[2] ? parseInt(hex[2], 16) / 255 : 1) } function resolveStateColor(token, foreground, accent, urgent, fallback) { var fb = fallback || foreground || Color.foreground var s = String(token || "").replace(/^\s+|\s+$/g, "") var role = s.toLowerCase() if (role === "foreground" || role === "text") return foreground || Color.foreground if (role === "accent") return accent || Color.accent if (role === "urgent") return urgent || Color.urgent if (role === "background") return Color.background if (role === "transparent") return Qt.rgba(0, 0, 0, 0) return colorFromHex(s, fb) } function normalStateColor(foreground, accent, urgent) { return resolveStateColor(normalColorToken, foreground, accent, urgent, foreground || Color.foreground) } function hoverStateColor(foreground, accent, urgent) { return resolveStateColor(hoverColorToken, foreground, accent, urgent, foreground || Color.foreground) } function selectedStateColor(foreground, accent, urgent) { return resolveStateColor(selectedColorToken, foreground, accent, urgent, foreground || Color.foreground) } function pressedStateColor(foreground, accent, urgent) { return resolveStateColor(pressedColorToken, foreground, accent, urgent, hoverStateColor(foreground, accent, urgent)) } function focusStateColor(foreground, accent, urgent) { var role = String(focusColorToken || "").replace(/^\s+|\s+$/g, "").toLowerCase() if (role === "hover" || role === "hover-cursor" || role === "inherit") return hoverStateColor(foreground, accent, urgent) return resolveStateColor(focusColorToken, foreground, accent, urgent, hoverStateColor(foreground, accent, urgent)) } function selectionStateColor(foreground, accent, urgent) { return resolveStateColor(selectionColorToken, foreground, accent, urgent, foreground || Color.foreground) } function normalFillFor(foreground, accent, urgent) { return Util.alpha(normalStateColor(foreground, accent, urgent), normalFillAlpha) } function hoverFillFor(foreground, accent, urgent) { return Util.alpha(hoverStateColor(foreground, accent, urgent), hoverFillAlpha) } function selectedFillFor(foreground, accent, urgent) { return Util.alpha(selectedStateColor(foreground, accent, urgent), selectedFillAlpha) } function pressedFillFor(foreground, accent, urgent) { return Util.alpha(pressedStateColor(foreground, accent, urgent), pressedFillAlpha) } function focusFillFor(foreground, accent, urgent) { return Util.alpha(focusStateColor(foreground, accent, urgent), focusFillAlpha) } function selectionFillFor(foreground, accent, urgent) { return Util.alpha(selectionStateColor(foreground, accent, urgent), selectionFillAlpha) } function normalBorderFor(foreground, accent, urgent) { return Util.alpha(normalStateColor(foreground, accent, urgent), normalBorderAlpha) } function hoverBorderFor(foreground, accent, urgent) { return Util.alpha(hoverStateColor(foreground, accent, urgent), hoverBorderAlpha) } function selectedBorderFor(foreground, accent, urgent) { return Util.alpha(selectedStateColor(foreground, accent, urgent), selectedBorderAlpha) } function focusBorderFor(foreground, accent, urgent) { return Util.alpha(focusStateColor(foreground, accent, urgent), focusBorderAlpha) } // Composite helpers for the focus > hover > normal priority chain used by // every form control surface (TextField, NumberField, Dropdown, Toggle, // etc.). Saves callers from re-writing the three-line ternary ladder for // fill / border / border-width on every Rectangle background. function controlFill(focused, hot, foreground, accent) { if (focused) return focusFillFor(foreground, accent) if (hot) return hoverFillFor(foreground, accent) return normalFillFor(foreground, accent) } function controlBorder(focused, hot, foreground, accent) { if (focused) return focusBorderFor(foreground, accent) if (hot) return hoverBorderFor(foreground, accent) return normalBorderFor(foreground, accent) } function controlBorderWidth(focused, hot) { if (focused) return focusBorderWidth if (hot) return hoverBorderWidth return normalBorderWidth } // Convenience colors resolved against the foundational palette. readonly property color normalFill: normalFillFor(Color.foreground, Color.accent, Color.urgent) readonly property color hoverFill: hoverFillFor(Color.foreground, Color.accent, Color.urgent) readonly property color selectedFill: selectedFillFor(Color.foreground, Color.accent, Color.urgent) readonly property color pressedFill: pressedFillFor(Color.foreground, Color.accent, Color.urgent) readonly property color focusFillColor: focusFillFor(Color.foreground, Color.accent, Color.urgent) readonly property color normalBorderColor: normalBorderFor(Color.foreground, Color.accent, Color.urgent) readonly property color hoverBorderColor: hoverBorderFor(Color.foreground, Color.accent, Color.urgent) readonly property color selectedBorderColor: selectedBorderFor(Color.foreground, Color.accent, Color.urgent) readonly property color focusBorderColor: focusBorderFor(Color.foreground, Color.accent, Color.urgent) readonly property color selectedAccentFill: Util.alpha(Color.accent, selectedFillAlpha) readonly property color selectionFill: selectionFillFor(Color.foreground, Color.accent, Color.urgent) // ---------------------------------------------------------- spacing // // The spacing scale is the shell equivalent of rem for margins, gaps, // and padding. Components keep their existing proportions by asking for // the old pixel value through `Style.space(px)` (or `spaceReal(px)` for // fractional geometry); themes can make the shell denser or roomier // with `[spacing] scale`, or pin individual tokens. property real spacingScale: 1.0 property bool spacingScaleWithFont: true property var spacingOverrides: ({}) readonly property real effectiveSpacingScale: spacingScale * (spacingScaleWithFont ? fontScale : 1) function spaceReal(px) { var n = Number(px) if (!isFinite(n) || n <= 0) return 0 return n * effectiveSpacingScale } function space(px) { var n = spaceReal(px) if (n <= 0) return 0 return Math.max(1, Math.round(n)) } function spacingToken(key, fallback) { var v = spacingOverrides[key] var n = Number(v) return (isFinite(n) && n >= 0) ? Math.round(n) : space(fallback) } readonly property QtObject spacing: QtObject { readonly property real scale: root.effectiveSpacingScale readonly property int hairline: root.space(1) readonly property int xxs: root.spacingToken("xxs", 2) readonly property int xs: root.spacingToken("xs", 3) readonly property int sm: root.spacingToken("sm", 4) readonly property int md: root.spacingToken("md", 6) readonly property int lg: root.spacingToken("lg", 8) readonly property int xl: root.spacingToken("xl", 10) readonly property int xxl: root.spacingToken("xxl", 12) readonly property int xxxl: root.spacingToken("xxxl", 14) readonly property int huge: root.spacingToken("huge", 18) readonly property int controlGap: root.spacingToken("control-gap", 8) readonly property int controlPaddingX: root.spacingToken("control-padding-x", 10) readonly property int controlPaddingY: root.spacingToken("control-padding-y", 6) readonly property int inputPaddingY: root.spacingToken("input-padding-y", 7) readonly property int controlHeight: root.spacingToken("control-height", 28) readonly property int popupRowHeight: root.spacingToken("popup-row-height", 28) readonly property int dropdownWidth: root.spacingToken("dropdown-width", 240) readonly property int searchableDropdownWidth: root.spacingToken("searchable-dropdown-width", 260) readonly property int numberFieldWidth: root.spacingToken("number-field-width", 120) readonly property int searchablePopupMinHeight: root.spacingToken("searchable-popup-min-height", 220) readonly property int rowGap: root.spacingToken("row-gap", 8) readonly property int rowPaddingX: root.spacingToken("row-padding-x", 12) readonly property int labelGap: root.spacingToken("label-gap", 4) readonly property int panelGap: root.spacingToken("panel-gap", 14) readonly property int panelPadding: root.spacingToken("panel-padding", 18) readonly property int popupPadding: root.spacingToken("popup-padding", 14) } // ---------------------------------------------------------- typography // // `fontFamily` defaults to "monospace" so the bar and every qs.Ui // component follows the fontconfig alias `omarchy-font-set` writes. // Themes can override per-token via [font] in shell.toml, but the // family stays system-wide. property string fontFamily: "monospace" // The concrete family `monospace` resolves to right now, e.g. // "JetBrainsMono Nerd Font". Bind `font.family` to `fontFamily` (so the // alias path keeps working when the user runs `omarchy font set`), but // read `resolvedFontFamily` when you want to *display* what's drawing. property string resolvedFontFamily: "monospace" // The only sanity floor is 1px. Themes and users can make this as large // as they like; if the shell gets ridiculous, that's their call. property int fontBaseSize: 12 property var fontOverrides: ({}) property var barOverrides: ({}) property bool barScaleWithFont: true readonly property real fontScale: Math.max(1 / 12, fontBaseSize / 12) function fontPx(mult) { return Math.max(1, Math.round(fontBaseSize * mult)) } function fontToken(key, fallback) { var v = fontOverrides[key] var n = Number(v) return (isFinite(n) && n > 0) ? Math.round(n) : fallback } function barToken(key, fallback) { var v = barOverrides[key] var n = Number(v) var base = (isFinite(n) && n > 0) ? n : fallback if (barScaleWithFont) base *= fontScale return Math.max(1, Math.round(base)) } function boolToken(value, fallback) { if (value === undefined || value === null) return fallback var s = String(value).replace(/^\s+|\s+$/g, "").toLowerCase() if (s === "true" || s === "1" || s === "yes" || s === "on") return true if (s === "false" || s === "0" || s === "no" || s === "off") return false return fallback } // The launcher, menu, polkit, emojis, and clipboard surfaces honor an // OMARCHY_MENU_FONT override for users who want a different family on the // summoned popups than on the bar. Resolved once at startup; an empty env // value falls back to the shared fontconfig alias. readonly property string menuFontFamily: { var override = Quickshell.env("OMARCHY_MENU_FONT") return (override && override.length > 0) ? override : fontFamily } readonly property QtObject font: QtObject { readonly property string family: root.fontFamily readonly property string resolvedFamily: root.resolvedFontFamily readonly property string menuFamily: root.menuFontFamily readonly property int baseSize: root.fontBaseSize readonly property int caption: root.fontToken("caption", root.fontPx(0.833)) // 10 readonly property int bodySmall: root.fontToken("body-small", root.fontPx(0.917)) // 11 readonly property int body: root.fontToken("body", root.fontPx(1.0)) // 12 readonly property int subtitle: root.fontToken("subtitle", root.fontPx(1.083)) // 13 readonly property int title: root.fontToken("title", root.fontPx(1.167)) // 14 readonly property int heading: root.fontToken("heading", root.fontPx(1.333)) // 16 readonly property int display: root.fontToken("display", root.fontPx(2.0)) // 24 readonly property int displayLarge: root.fontToken("display-large", root.fontPx(2.333)) // 28 readonly property int iconSmall: root.fontToken("icon-small", bodySmall) readonly property int icon: root.fontToken("icon", title) readonly property int iconLarge: root.fontToken("icon-large", root.fontPx(1.5)) // 18 } readonly property QtObject bar: QtObject { readonly property int sizeHorizontal: root.barToken("size-horizontal", 26) readonly property int sizeVertical: root.barToken("size-vertical", 28) } function refresh() { hyprctlProc.running = true gapsOutProc.running = true } function scheduleRefresh() { refreshTimer.restart() } function applyRoundingJson(raw) { try { var json = JSON.parse(raw || "{}") var n = Number(json.int) if (isFinite(n) && n >= 0) cornerRadius = n } catch (e) { // hyprctl missing / Hyprland not running — leave the previous value. } } function applyGapsOutJson(raw) { try { var json = JSON.parse(raw || "{}") var css = String(json.css || "") var parts = css.match(/-?\d+(?:\.\d+)?/g) || [] var n = parts.length > 0 ? Number(parts[0]) : Number(json.int) if (isFinite(n) && n >= 0) gapsOut = Math.max(0, Math.round(n / 2)) } catch (e) { // hyprctl missing / Hyprland not running — leave the previous value. } } // Pull typography, bar dimensions, state tokens, and spacing out of the // shell.toml dict that Color already parsed. Called by Color.loadShell so // a single parse pass feeds both singletons. function applyShellValues(values) { var fontOut = {} var barOut = {} var styleOut = {} var spacingOut = {} var nextBase = 12 var nextSpacingScale = 1.0 var nextSpacingScaleWithFont = true var nextBarScaleWithFont = true var v = values || {} for (var fullKey in v) { var dot = fullKey.indexOf(".") if (dot < 0) continue var section = fullKey.substr(0, dot) var key = fullKey.substr(dot + 1) var raw = v[fullKey] if (section === "font") { var ival = parseInt(raw, 10) if (!isFinite(ival)) continue if (key === "base-size") nextBase = ival else fontOut[key] = ival } else if (section === "bar") { if (key === "scale-with-font") { nextBarScaleWithFont = boolToken(raw, nextBarScaleWithFont) } else if (key === "size-horizontal" || key === "size-vertical") { var b = parseInt(raw, 10) if (isFinite(b)) barOut[key] = b } } else if (section === "spacing") { if (key === "scale-with-font") { nextSpacingScaleWithFont = boolToken(raw, nextSpacingScaleWithFont) } else { var fval = parseFloat(raw) if (!isFinite(fval)) continue if (key === "scale") nextSpacingScale = fval else spacingOut[key] = fval } } else if (section === "controls" || section === "style") { // Strings are passed through; styleRawNum/styleString coerce on read. // [style] is the legacy name for [controls]. styleOut[key] = raw } } // Keep only a 1px sanity floor. Per-token overrides aren't clamped // either — a theme that wants display-large = 64 should be allowed to // ship it. if (!isFinite(nextBase) || nextBase < 1) nextBase = 1 if (!isFinite(nextSpacingScale) || nextSpacingScale < 0) nextSpacingScale = 1.0 spacingScale = nextSpacingScale spacingScaleWithFont = nextSpacingScaleWithFont fontBaseSize = nextBase fontOverrides = fontOut barOverrides = barOut barScaleWithFont = nextBarScaleWithFont spacingOverrides = spacingOut styleOverrides = styleOut } property Process hyprctlProc: Process { id: hyprctlProc command: ["hyprctl", "-j", "getoption", "decoration:rounding"] stdout: StdioCollector { waitForEnd: true onStreamFinished: root.applyRoundingJson(text) } } property Process gapsOutProc: Process { id: gapsOutProc command: ["hyprctl", "-j", "getoption", "general:gaps_out"] stdout: StdioCollector { waitForEnd: true onStreamFinished: root.applyGapsOutJson(text) } } // Resolve the fontconfig alias to a concrete family name. `omarchy font // set ` rewrites ~/.config/fontconfig/fonts.conf and restarts the // shell, but rerun on file change anyway so manual edits propagate too. function resolveFontFamily() { fcMatchProc.running = true } property Process fcMatchProc: Process { id: fcMatchProc command: ["fc-match", "-f", "%{family[0]}", "monospace"] stdout: StdioCollector { waitForEnd: true onStreamFinished: { var name = String(text || "").trim() if (name.length > 0) root.resolvedFontFamily = name } } } property FileView fontconfigFile: FileView { path: Quickshell.env("HOME") + "/.config/fontconfig/fonts.conf" watchChanges: true printErrors: false onFileChanged: root.resolveFontFamily() onLoaded: root.resolveFontFamily() onLoadFailed: root.resolveFontFamily() } // Re-poll Hyprland a beat after either input file changes. Hyprland's // auto-reload runs asynchronously when its sourced .lua files change, // so racing it with an immediate hyprctl gives the old value. 200ms is // generous enough for Hyprland to settle without being user-visible. property Timer refreshTimer: Timer { id: refreshTimer interval: 200 repeat: false onTriggered: root.refresh() } // `omarchy toggle window-gaps` creates/removes this flag file. Hyprland // reloads its config when sourced files change, then hyprctl reflects // the new effective value. property FileView windowNoGapsToggle: FileView { path: Quickshell.env("HOME") + "/.local/state/omarchy/toggles/hypr/window-no-gaps.lua" watchChanges: true printErrors: false onFileChanged: refreshTimer.restart() onLoaded: refreshTimer.restart() onLoadFailed: refreshTimer.restart() } Component.onCompleted: { refresh() resolveFontFamily() } }