Files
arthur-os/shell/Commons/Style.qml
T
Ryan Hughes b5f7ba02c2 Unify PillButton, CursorPill, ChoiceButton into qs.Ui.Button + ButtonGroup
The three components were three takes on the same shape \u2014 a clickable
rectangle with text/icon, a hot/hover state, an optional persistent
border, and an optional 'selected' or 'active' highlight. CursorPill
was a 30-line PillButton wrapper that added a hovered() signal;
ChoiceButton was effectively PillButton with selected: bool painting
an accent fill+border.

Collapse them into a single qs.Ui.Button. State flags compose
independently:

  hasCursor / hover         hot fill
  active                    persistent foreground-tint fill
  selected                  accent fill + accent border
  bordered: true            persistent 1px idle border (form primaries)
  focusable: true           Tab focus paints the accent ring
  pressed                   pressed fill

The hovered(bool) signal is now built-in, so CursorPill's wrapper is
unnecessary. ButtonGroup wraps a Row+Repeater for the form-style
'pick one of N' pattern; panel-cursor-driven cases still compose
Buttons directly in a Row with per-instance hasCursor wiring.

Theme tokens move into a new [style] section in shell.toml:

  border-width        = 1
  focus-border-width  = 3
  idle-border-alpha   = 0.4
  hot-fill-alpha      = 0.08
  selected-fill-alpha = 0.18
  pressed-fill-alpha  = 0.22
  focus-fill-alpha    = 0.22

Style.qml parses these out of the same shell.toml [font] / [bar]
already reads, and exposes pre-computed Style.hotFill / selectedFill /
pressedFill / idleBorderColor / selectedAccentFill / borderWidth +
the existing focusBorder* tokens. Themes that don't ship a [style]
section get the previous defaults unchanged.

Dev gallery consolidates three sections (PillButton, CursorPill,
ChoiceButton) into Button + ButtonGroup, with the cursor model
sections renamed accordingly.
2026-05-18 12:09:13 -04:00

283 lines
11 KiB
QML

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, focus affordances, typography scale, and bar dimensions —
// so panels and qs.Ui components have a single source of truth.
//
// `cornerRadius` mirrors Hyprland's `decoration:rounding`. Themes ship
// their own rounding via theme/hyprland.lua; the user toggle via
// `omarchy style corners <round|sharp>` flips Hyprland's flag file
// and Hyprland's auto-reload pushes the new value out. The shell picks
// up the change here by re-running `hyprctl getoption` whenever either
// of those input files changes.
//
// Typography and bar size come from `theme/shell.toml`. `[font] base-size`
// is the rem root; every `Style.font.<token>` derives from it via the
// scale multipliers below unless the theme pins that specific token.
// `[bar] size-horizontal` / `size-vertical` set the cross-axis dimension
// for top/bottom and left/right bars respectively.
QtObject {
id: root
property int cornerRadius: 0
// ---------------------------------------------------------- state alphas
//
// Foreground-tinted fills + border alphas used by every interactive
// surface in the kit. Themes can override these via [style] in
// shell.toml; otherwise the kit defaults below apply.
property var styleOverrides: ({})
function styleNum(key, fallback) {
var v = styleOverrides[key]
var n = Number(v)
return isFinite(n) ? n : fallback
}
readonly property int borderWidth: Math.max(0, Math.round(styleNum("border-width", 1)))
readonly property int focusBorderWidth: Math.max(1, Math.round(styleNum("focus-border-width", 3)))
readonly property real idleBorderAlpha: styleNum("idle-border-alpha", 0.4)
readonly property real hotFillAlpha: styleNum("hot-fill-alpha", 0.08)
readonly property real selectedFillAlpha: styleNum("selected-fill-alpha", 0.18)
readonly property real pressedFillAlpha: styleNum("pressed-fill-alpha", 0.22)
readonly property real focusFillAlpha: styleNum("focus-fill-alpha", 0.22)
function alphaForeground(a) {
return Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, a)
}
function alphaAccent(a) {
return Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, a)
}
// Focus affordances. Deliberately distinct from "selected" (which uses an
// accent fill) so the keyboard cursor never reads as the chosen value.
readonly property color focusBorderColor: Color.accent
readonly property color focusFillColor: alphaAccent(focusFillAlpha)
// Convenience fills used by panel rows and pills. Hot is hover/keyboard
// cursor; selected/active is the persistent chosen/current state.
readonly property color hotFill: alphaForeground(hotFillAlpha)
readonly property color selectedFill: alphaForeground(selectedFillAlpha)
readonly property color pressedFill: alphaForeground(pressedFillAlpha)
readonly property color idleBorderColor: alphaForeground(idleBorderAlpha)
readonly property color selectedAccentFill: alphaAccent(selectedFillAlpha)
// ---------------------------------------------------------- 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"
// Clamped 11..13 by loadShell — bar height and row heights are fixed
// until we ship matching spacing tokens, so unbounded growth clips.
property int fontBaseSize: 12
// Parsed maps populated by loadShell. Keep them as plain dicts so
// reassigning the whole property fires reactive bindings. styleOverrides
// is declared up top next to the helpers that consume it.
property var fontOverrides: ({})
property var barOverrides: ({})
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)
return (isFinite(n) && n > 0) ? Math.round(n) : fallback
}
readonly property QtObject font: QtObject {
readonly property string family: root.fontFamily
readonly property string resolvedFamily: root.resolvedFontFamily
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
}
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.
}
}
// Parse [font] base-size + per-token overrides and [bar] size-* keys
// out of shell.toml. Color.qml owns the quoted-string side of the same
// file; we handle the unquoted numeric tokens for [font], [bar] sizes,
// and [style] (alphas + border widths).
function loadShell(raw) {
var fontOut = {}
var barOut = {}
var styleOut = {}
var nextBase = 12
var text = String(raw || "")
if (text) {
var lines = text.split("\n")
var section = ""
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (!line || line.charAt(0) === "#") continue
var sectionMatch = line.match(/^\[([A-Za-z0-9_-]+)\]\s*(#.*)?$/)
if (sectionMatch) { section = sectionMatch[1]; continue }
// Accept ints OR floats (alphas are 0..1).
var kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(-?\d+(?:\.\d+)?)\s*(#.*)?$/)
if (!kv) continue
var key = kv[1]
var raw = kv[2]
if (section === "font") {
var ival = parseInt(raw, 10)
if (key === "base-size") nextBase = ival
else fontOut[key] = ival
} else if (section === "bar" && (key === "size-horizontal" || key === "size-vertical")) {
barOut[key] = parseInt(raw, 10)
} else if (section === "style") {
styleOut[key] = parseFloat(raw)
}
}
}
// Clamp the rem root. Per-token overrides aren't clamped — a theme
// that wants display-large = 64 should be allowed to ship it.
if (nextBase < 11) nextBase = 11
if (nextBase > 13) nextBase = 13
fontBaseSize = nextBase
fontOverrides = fontOut
barOverrides = barOut
styleOverrides = styleOut
}
property Process hyprctlProc: Process {
id: hyprctlProc
command: ["hyprctl", "-j", "getoption", "decoration:rounding"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.applyRoundingJson(text)
}
}
// Resolve the fontconfig alias to a concrete family name. `omarchy font
// set <name>` 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()
}
// The theme name flips whenever `omarchy-theme-set` swaps the theme/
// symlink; that's when theme/hyprland.lua's `rounding` value changes
// and the new theme/shell.toml drops into place. Force a reload of
// shell.toml here so we don't wait on the inotify watch — Color.qml
// uses the same tripwire for the same reason.
property FileView themeNameFile: FileView {
path: Quickshell.env("HOME") + "/.config/omarchy/current/theme.name"
watchChanges: true
printErrors: false
onFileChanged: {
refreshTimer.restart()
shellTomlFile.reload()
}
}
// `omarchy style corners <round|sharp>` creates or removes this flag file.
// Hyprland reloads its config when sourced files change, then hyprctl
// reflects the new effective rounding value.
property FileView roundedCornersToggle: FileView {
path: Quickshell.env("HOME") + "/.local/state/omarchy/toggles/hypr/rounded-corners.lua"
watchChanges: true
printErrors: false
onFileChanged: refreshTimer.restart()
onLoaded: refreshTimer.restart()
onLoadFailed: refreshTimer.restart()
}
property FileView shellTomlFile: FileView {
id: shellTomlFile
path: Quickshell.env("HOME") + "/.config/omarchy/current/theme/shell.toml"
watchChanges: true
printErrors: false
onLoaded: root.loadShell(text())
onLoadFailed: root.loadShell("")
onFileChanged: reload()
}
Component.onCompleted: {
refresh()
resolveFontFamily()
}
}