Based entirely on the work of @robzolkos in #5711. That attempt was
adapted to better fit the standard templating system while also adding
color mixing for use in other themes.
The custom PositionButtonGroup component wrapped Repeater + Button
directly without setting bordered: true on the delegates. After the
recent Button changes (idle non-bordered Buttons have zero border
width), the non-selected position chips lost their visible chip
structure entirely \u2014 they rendered as floating text labels and read
as unclickable.
Replace the bespoke component with qs.Ui.ButtonGroup (which sets
bordered: true on each chip) inside a Column with a 'Position' label,
and drop the now-unused PositionButtonGroup definition. All four
options now read as clickable chips and the chosen value still paints
the accent fill+border.
Pass a section name to open the gallery with the cursor already on
that component instead of scrolling from the top. Useful when
iterating on a single widget.
omarchy dev ui-preview # default position
omarchy dev ui-preview button # land on the Button section
omarchy dev ui-preview button-group # land on ButtonGroup
omarchy dev ui-preview slider # land on Slider, etc.
The bin script forwards the argument as {"section": "..."} in the
shell summon payload. The gallery's open(payloadJson) parses it,
clamps against visibleSections (unknown names fall back to default),
and sets focusSection + selectedIndex inside the existing Qt.callLater
so the cursor's hasCursor bindings fire ensureCursorVisible
automatically.
Three small fixes around the new Button + ButtonGroup so the cursor is
always visible and the section reads as a single Button showing flag
combinations.
ButtonGroup now exposes cursorIndex + hovered(index, isHovered) so
panels can drive a single Button's hasCursor without reaching past
ButtonGroup's API. Every chip gets bordered: true so the row reads as
a real picker — distinct outlines, not just a tinted fill that vanishes
into the background.
Button's border treatment grows two paths so the cursor is always
visible regardless of other state: bordered + hot recolors the border
to the accent and thickens to the focus-ring width, and selected + hot
thickens the existing accent border to the focus-ring width (so the
cursor landing on the chosen option still reads as cursor presence,
instead of disappearing into the selected fill).
Gallery's Button section pairs each demo button with a small caption
('idle', 'active', 'icon only', 'icon + active', 'bordered +
focusable') so the row reads as one Button with its state flags side
by side rather than five unrelated buttons. The ButtonGroup section
gets the cursor wiring (cursorIndex + onHovered) plus an
onFocusedChanged auto-scroll so the cursor visual is reachable via
j/k h/l from the panel cursor model.
Wide content rows (slider rows in audio / monitor panels) opted out of
the cursor fill by setting fill: "transparent" on their CursorSurface,
which left them with no cursor visual at all — j/k landed there
silently with no way to know what was selected.
Add an `outline: bool` flag to CursorSurface. When true, hasCursor
paints an accent border (Style.focusBorderColor at
Style.focusBorderWidth) instead of a fill, leaving the row's chrome
fully visible underneath. Use for slider rows where a fill would
obscure the track.
Audio output / input slider rows and the monitor brightness row opt
in. The dev gallery's slider section replaces its bespoke Rectangle
wrapper with the same CursorSurface { outline: true }, so what the
gallery documents is what the panels ship.
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.
CursorSurface defines the canonical cursor chrome: a tinted fill, no
border. Three components had inconsistent treatments that the recent
standardization pass left half-finished:
- PillButton kept a 1px foreground border on hot (cursor or hover)
via a cursorBordered: true default.
- CursorPill specifically had to override cursorBordered: false to
match the rest of the kit.
- ChoiceButton thickened its border to 2px on hasCursor, which mixed
the cursor visual with the Tab-focus visual.
Drop the cursorBordered escape hatch from PillButton (no caller other
than CursorPill ever used it), drop CursorPill's now-redundant
override, and remove hasCursor from ChoiceButton's border.width
thickening. Update Toggle's class comment (it still claimed cursor
mirrored Tab focus, which f3ba5055 broke without updating the doc).
Now: hot/hasCursor = fill only, activeFocus = accent border ring,
selected/active = stronger fill or accent border per component
semantic. One canonical pattern across the kit.
Themes now drive typography the same way they drive colors: one [font]
base-size in shell.toml is the rem root, and every Style.font.<token>
(caption, bodySmall, body, subtitle, title, heading, display,
displayLarge, iconSmall, icon, iconLarge) derives from it via a fixed
multiplier. Themes can also pin individual tokens for stylistic
emphasis. base-size is clamped 11..13 until row-height tokens exist.
Bar dimensions move to the same singleton: [bar] size-horizontal /
size-vertical replace the hardcoded 26/28 in Bar.qml, exposed as
Style.bar.sizeHorizontal / sizeVertical.
Style.qml also resolves the fontconfig 'monospace' alias via fc-match
and exposes Style.font.resolvedFamily so panels can display the
concrete family. Watches ~/.config/fontconfig/fonts.conf so it tracks
'omarchy font set <name>'.
The qs.Ui kit (PillButton, Dropdown, Toggle, TextField, etc.) and
every first-party plugin (bar widgets, settings, menu, clipboard,
emoji, polkit, notifications, osd, image-picker, dev-gallery) now
bind to Style.font.* instead of pixel literals. Only three deliberate
display-scale outliers remain: the notification empty-state glyph and
the weather flyout's hero temperature pair, all commented.
Background plugin's applyTheme IPC fast-path also pushes shell.toml to
Style so theme swaps update typography and bar size without waiting
for inotify debounce.
Dev gallery (omarchy dev ui-preview) now ships a Typography section
that renders the full scale and theme tokens live, and its summon
command is fixed (omarchy-shell-ipc -> omarchy-shell).
The wifi panel now has full keyboard nav over both sections:
h/l walk the DNS provider pills
j/k walk the wifi network list (j from DNS drops into the
list; k from the top row goes back to DNS)
Enter/Space apply the highlighted DNS provider / connect to the
highlighted wifi network
Esc close the panel
Single-cursor model. The visible cursor lives at the panel root via
focusSection + dnsIndex + selectedIndex. Mouse hover doesn't draw its
own highlight — onContainsMouseChanged on each row/pill MOVES the
cursor instead. Mouse leaving doesn't clear it (the cursor stays where
attention was last; subsequent j/k pick up from there). Last action
wins, whether mouse or keyboard.
New Common.CursorSurface holds the shared chrome (fill + 1px border +
fade) and is the base for both NetworkRow and the new local
DnsProviderPill. Items pass only hasCursor + current and let the
surface render. Contract: items never read containsMouse for visuals.
That's what guarantees exactly one highlight on screen at any time.
DnsProviderPill replaces Common.PillButton for the DNS row because
PillButton's hover > active > background cascade fights the
single-cursor model — its built-in hover would draw a parallel
highlight on whatever pill the pointer is parked on, even after the
cursor moved elsewhere. PillButton stays where it fits (refresh icon,
other bar widgets) and is unchanged here.
The wifi panel was a bar-widget xdg-popup which Hyprland doesn't grant
keyboard focus to until the user clicks or hovers it — keys typed after
a SUPER+CTRL+W summon went to whatever previously had focus.
New shared Common.KeyboardPanel scaffolding: layer-shell PanelWindow
with WlrKeyboardFocus.Exclusive (granted at map time, the protocol-level
equivalent of focus-on-launch for xdg-toplevels), full-screen anchored
with a Region mask that subtracts the bar strip so bar widgets stay
clickable, MouseArea for outside-click dismissal, TransformWatcher for
reactive anchor-position tracking. API is a subset of Common.PopupCard
(no centerOnBar / triggerMode / containsMouse yet — adding when the
other bar popups migrate).
networkPanel.qml uses it. Adds full keyboard navigation:
j/k or Up/Down — move selection
Return/Space — connect (or disconnect if already connected)
x — forget the highlighted network
r — refresh scan
Esc — close
ListView (with positionViewAtIndex on currentIndex) replaces the
Repeater+Column inside Flickable so the selected row stays visible as
j/k walks past the cap. A 1.5s Timer re-polls detailsProc while open
so the connection details (IP, gateway, signal) populate when routing
actually comes up after a connect rather than waiting for re-summon.
Rows: lock glyph on the right for protected-and-not-connected, X
(forget) for connected — aligned to the same 22-wide column. Connect
glyph in the passphrase prompt switched to a check, no separate cancel
(Esc handles it).
SUPER+CTRL+W in utilities.lua now toggles this panel via
omarchy-shell-ipc instead of launching Impala. Right-click on the bar
icon still launches Impala as a fallback for hidden SSIDs / enterprise
auth / iwd edge cases.
quickshell kill currently trips Quickshell #539 (notifyInternal2 event
loop crash via IpcKillCommand::exec), which leaves the Wayland surface
lingering long enough that the new instance starts on top of the dying
one — a duplicate bar. SIGKILL drops the client connection and the
compositor cleans up surfaces immediately; the shell has no persistent
state to flush. Sub-20ms restart, no duplicates.