Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions shell/Ui/BarIconButton.qml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ WidgetButton {
property bool debugOpticalBounds: Quickshell.env("OMARCHY_DEBUG_BAR_ICONS") === "1"
readonly property real opticalCenterErrorX: glyph.visible ? glyph.paintedCenterX - opticalCanvas.width / 2 : 0
readonly property real glyphPaintedWidth: glyph.visible ? glyph.tightWidth : 0
// Forwards the loaded vector icon, if any. Loader.item is statically
// QObject (reading .implicitWidth off it trips missing-property), so the
// bar measures through this untyped alias instead. Null on the glyph path.
readonly property var iconContentItem: iconLoader.item
readonly property real glyphBaselineY: glyph.visible ? glyph.baselineY : 0
readonly property int glyphFontSize: glyph.visible ? glyph.renderedFontSize : 0

Expand Down Expand Up @@ -39,6 +43,7 @@ WidgetButton {
}

Loader {
id: iconLoader
anchors.fill: parent
visible: root.iconComponent !== null
sourceComponent: root.iconComponent
Expand Down
12 changes: 12 additions & 0 deletions shell/Ui/WidgetButton.qml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ Item {
// Width of the painted label, for bar chrome that wants to line up with the
// text rather than with the slot it sits in. Zero on icon-only buttons.
readonly property real labelWidth: label.visible ? label.implicitWidth : 0
// Tight painted width of the label: implicitWidth above includes the
// font's side bearings, which would pad pills a pixel or two wider per
// side than tight-measured icon glyphs. Zero on icon-only buttons.
readonly property real labelTightWidth: label.visible ? Math.max(0, labelMetrics.tightBoundingRect.width) : 0

visible: hasVisualContent || keepSpace
opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1)
Expand All @@ -72,6 +76,14 @@ Item {
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}

TextMetrics {
id: labelMetrics
// Same font the label paints with, so tight bounds match the ink.
font.family: root.fontFamily
font.pixelSize: root.fontSize
text: root.text
}

Text {
id: label
textFormat: Text.PlainText
Expand Down
86 changes: 79 additions & 7 deletions shell/plugins/bar/Bar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -1737,6 +1737,10 @@ Item {
id: horizontalModuleList

Row {
// No spacing here: every ModuleSlot pads itself from its own
// painted width (see slotPad), so ink-to-ink stays uniform whatever
// each widget paints. A fixed spacing would stack on top of the
// widest bearings instead of absorbing them.
spacing: 0

Repeater {
Expand All @@ -1755,6 +1759,7 @@ Item {
id: verticalModuleList

Column {
// As above: per-slot padding carries the gaps, not the positioner.
spacing: 0

Repeater {
Expand Down Expand Up @@ -1809,10 +1814,71 @@ Item {
var key = root.vertical ? "openPanelIndicatorHeight" : "openPanelIndicatorWidth"
var hint = activeItem && key in activeItem ? activeItem[key] : undefined
if (hint !== undefined && hint !== null && hint > 0) return Math.round(hint)
return Math.max(Style.space(10), Math.round((root.vertical ? slot.height : slot.width) * 0.55))
}
implicitWidth: activeItem && activeItem.visible ? (root.vertical ? root.barSize : activeItem.implicitWidth) : 0
implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0
return Math.max(Style.space(10), Math.round((root.vertical ? slot.contentHeight : slot.contentWidth) * 0.55))
}
// Painted half-gap every slot holds its content away from the slot edge.
// Adjacent slots then land exactly 2*paintHalfGap ink-to-ink, whatever
// each widget paints — icon slots, text pills, and paint that overflows
// its slot all end up on the same rhythm.
readonly property int paintHalfGap: Style.space(6)
// How far slot padding may intrude into a widget's own empty margins to
// enforce the gap above when a widget demands wider bearings. Never
// reaches paint, but neighbouring hit areas overlap by up to this much.
readonly property int paintIntrude: Style.space(3)
// Size the slot lays out for its content (what implicitWidth used to be).
readonly property real contentWidth: activeItem && activeItem.visible
? (root.vertical ? root.barSize : activeItem.implicitWidth) : 0
readonly property real contentHeight: activeItem && activeItem.visible
? activeItem.implicitHeight : 0
// Tight painted extent along the layout axis, best effort, measured on
// the bar button: widgets keep paint metrics on the button inside the
// root, never on the root itself. Popup buttons nest deeper and must
// never be measured, so only the root and its direct children qualify.
// Tray is exempt: its chevron is a direct child but does not represent
// the drawer it opens.
readonly property var paintItem: {
if (!activeItem) return null
var id = root.canonicalWidgetId(moduleName)
if (id === "omarchy.spacer" || id === "omarchy.tray") return activeItem
return BarModel.paintChild(activeItem) || activeItem
}
// BarIconButton glyphs (which also covers text painted wider than its
// slot), WidgetButton labels, vector icon content, icon canvases.
// Opaque customs fall back to full-bleed — extra air, never overlap.
readonly property real paintedExtent: {
var item = paintItem
if (!item) return 0
if (root.vertical) {
if ("opticalSize" in item && item.opticalSize > 0) return item.opticalSize
return contentHeight
}
if ("glyphPaintedWidth" in item && item.glyphPaintedWidth > 0) return item.glyphPaintedWidth
if ("labelTightWidth" in item && item.labelTightWidth > 0) return item.labelTightWidth
if ("labelWidth" in item && item.labelWidth > 0) return item.labelWidth
// Vector icons size themselves under the canvas (usually to the icon
// font); measure the loaded item instead of assuming a full canvas,
// capped at the canvas so an over-reporting component cannot shrink
// its padding.
if ("iconContentItem" in item && item.iconContentItem
&& item.iconContentItem.implicitWidth > 0) {
if ("opticalSize" in item && item.opticalSize > 0)
return Math.min(item.iconContentItem.implicitWidth, item.opticalSize)
return item.iconContentItem.implicitWidth
}
if ("opticalSize" in item && item.opticalSize > 0) return item.opticalSize
return contentWidth
}
// Symmetric compensation for this slot's own bearing. Negative bearings
// (paint wider than the slot) pad extra; the pure-gap spacer keeps its
// authored span and stays out of this.
readonly property real slotPad: {
var span = root.vertical ? contentHeight : contentWidth
if (!(span > 0)) return 0
if (root.canonicalWidgetId(moduleName) === "omarchy.spacer") return 0
return BarModel.slotPad(span, paintedExtent, paintHalfGap, paintIntrude)
}
implicitWidth: contentWidth + (root.vertical ? 0 : 2 * slotPad)
implicitHeight: contentHeight + (root.vertical ? 2 * slotPad : 0)
width: implicitWidth
height: implicitHeight
z: modulePointer.dragging ? 100 : 0
Expand All @@ -1839,7 +1905,9 @@ Item {
id: componentLoader
active: !slot.qmlCustom && !slot.registered
sourceComponent: slot.commandCustom ? customCommandModuleComponent : emptyModuleComponent
anchors.fill: parent
width: root.vertical ? parent.width : slot.contentWidth
height: root.vertical ? slot.contentHeight : parent.height
anchors.centerIn: parent
opacity: slot.dragSource ? 0.22 : 1.0
onLoaded: {
slot.injectProps()
Expand All @@ -1851,7 +1919,9 @@ Item {
id: registryLoader
active: slot.registered
sourceComponent: slot.registered ? slot.registryComponent : null
anchors.fill: parent
width: root.vertical ? parent.width : slot.contentWidth
height: root.vertical ? slot.contentHeight : parent.height
anchors.centerIn: parent
opacity: slot.dragSource ? 0.22 : 1.0
onLoaded: {
slot.injectProps()
Expand All @@ -1863,7 +1933,9 @@ Item {
id: qmlLoader
active: slot.qmlCustom
source: slot.qmlCustom ? root.customModuleSource(slot.entry) : ""
anchors.fill: parent
width: root.vertical ? parent.width : slot.contentWidth
height: root.vertical ? slot.contentHeight : parent.height
anchors.centerIn: parent
opacity: slot.dragSource ? 0.22 : 1.0
onLoaded: {
slot.injectProps()
Expand Down
46 changes: 46 additions & 0 deletions shell/plugins/bar/BarModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,54 @@ function nearestDropTarget(candidates, point, vertical) {
return best
}

// Symmetric slot padding that normalizes ink-to-ink gaps: a slot whose
// content paints `paintedExtent` wide inside `contentSpan` holds its paint
// `halfGap` from each slot edge, so neighbours always land 2*halfGap apart.
// Negative bearings (paint wider than the slot) pad extra instead of
// touching the neighbour. Zero spans stay collapsed so hidden widgets keep
// contributing no gap. `maxIntrude` lets the padding go negative into the
// widget's own empty margins to enforce gaps smaller than the widest
// bearing; it never reaches paint, but neighbouring hit areas overlap by
// that much, so keep it small.
function slotPad(contentSpan, paintedExtent, halfGap, maxIntrude) {
var span = Number(contentSpan)
if (!isFinite(span) || span <= 0) return 0
var half = Number(halfGap)
if (!isFinite(half) || half <= 0) return 0
var painted = Number(paintedExtent)
if (!isFinite(painted) || painted < 0) painted = span
var cap = Number(maxIntrude)
if (!isFinite(cap) || cap < 0) cap = 0
return Math.max(-cap, half - (span - painted) / 2)
}

// Bar buttons are always the widget root itself or a direct child of it;
// buttons inside the popup nest deeper and must never be measured. Returns
// the first object exposing bar paint metrics, or null. Duck-typed so the
// same function runs against live QObjects and plain test fixtures.
function hasPaintMetrics(value) {
if (!value) return false
return "glyphPaintedWidth" in value || "labelTightWidth" in value
|| "labelWidth" in value || "iconContentItem" in value
|| "opticalSize" in value
}

function paintChild(item) {
if (!item) return null
if (hasPaintMetrics(item)) return item
var kids = item.children
if (!kids || typeof kids.length !== "number") return null
for (var i = 0; i < kids.length; i++) {
if (hasPaintMetrics(kids[i])) return kids[i]
}
return null
}

if (typeof module !== "undefined") {
module.exports = {
hasPaintMetrics: hasPaintMetrics,
paintChild: paintChild,
slotPad: slotPad,
isDrawnSlot: isDrawnSlot,
pickDrawnSlot: pickDrawnSlot,
pickPanelSlot: pickPanelSlot,
Expand Down
27 changes: 27 additions & 0 deletions shell/plugins/bar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,30 @@ declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See
[../../README.md](../../README.md) for the manifest schema. Rescan, enable,
and place third-party plugins with `omarchy-shell shell rescanPlugins`,
`omarchy plugin enable`, and `omarchy bar move`.

## Widget spacing contract

Bar sections keep ink-to-ink gaps uniform at 12px: every module slot
measures its own painted width (icon glyphs, button labels, icon
canvases) and pads itself symmetrically so its paint sits 6px from each
slot edge. Paint that overflows its slot gets extra compensation instead
of touching its neighbour; padding may intrude up to 3px into a widget's
own empty margins to enforce the gap against wider widget bearings, never
into paint. Hidden widgets collapse to zero and contribute no gap. The
Row/Column itself uses no spacing — the slots carry it all, and
`omarchy.spacer` keeps its authored span exempt.

Widget authors should still follow the shared geometry so the
compensation stays small (custom `Item` modules expose no paint metrics,
so the bar treats them as full-bleed):

- Icon widgets: extend `BarIconButton` from `qs.Ui` (default `slotSize`
`Style.bar.iconSlot`, 16px optical canvas). Compact status icons use
`slotSize: Style.bar.statusSlot`.
- Text pills: extend `WidgetButton` with the default `horizontalMargin`
(8.5). Custom widths should keep equivalent side bearings.
- Fully custom `Item` modules expose no paint metrics, so the bar pads
them as full-bleed: include side padding in the item itself to stay
compact.
- Composite widgets (tray, indicators, workspaces) normalize at their
outer boundary only; gaps between items inside them are the widget's own.
Loading