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
2 changes: 2 additions & 0 deletions manual/05-the-top-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ All of it is stored in `~/.config/omarchy/shell.json`, under the `bar` key. Here

Every widget is one entry in one of the three layout arrays, and its settings sit inline on that entry — there's no separate settings file and no `config` sub-object. The clock's `format`, `formatAlt` (what right-click cycles to), and `verticalFormat` all live right there on `{ "id": "omarchy.clock" }`.

The media widget works the same way: `scroll` set to `false` stops the now-playing text from sliding and truncates it with an ellipsis instead, `separator` changes what sits between the track and the artist, `maxLabelWidth` is how wide the label may get before either of those kicks in, and `iconGap` is the space between the play/pause control and the text. Turning scrolling off widens the label and the control gap on its own — a sliding label reads fine when it's narrow and its motion sets it apart from the play/pause button, while a static one has to fit what it can show and sits in the same horizontal run as the button — so reach for `maxLabelWidth` and `iconGap` only when you want something other than that.

`centerAnchor` names the one center widget that gets pinned to the exact center of the screen, with the others flanking it. That's how the clock stays dead center even as the weather and update badge come and go. Set it to an empty string and the center list is just centered as a group instead.

One rule worth internalizing: **once you have your own `shell.json`, it's canonical**. Until you customize anything, the shell reads Omarchy's default file. The moment you drag a widget, run `omarchy bar`, or edit the file yourself, you own it — there's no deep merge, so new default widgets in future Omarchy releases won't appear on your bar automatically. `omarchy bar defaults` puts the shipped layout back whenever you want a clean slate.
Expand Down
88 changes: 83 additions & 5 deletions shell/plugins/services/media/BarWidget.qml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ BarWidget {
property bool popupOpen: false

function close() { popupOpen = false }
property real maxLabelWidth: 180

// Tunables from this widget's inline shell.json entry. Defaults reproduce the
// previous hardcoded behaviour, so an entry without them is unchanged.
readonly property bool scrollLabel: setting("scroll", true)
readonly property string separator: setting("separator", " · ")
// A static label sits in the same horizontal run as the control, so it needs
// a wider gap to read as separate; a scrolling one is set apart by its motion.
readonly property real iconGap: setting("iconGap", scrollLabel ? 6 : 13)
// The scrolling label slides, so it stays readable at a narrow width; a
// static one has to fit what it can show, so it defaults wider. Setting
// maxLabelWidth explicitly overrides both.
property real maxLabelWidth: setting("maxLabelWidth", scrollLabel ? 180 : 360)

visible: hasMedia
implicitWidth: hasMedia ? row.implicitWidth + Style.space(14) : 0
Expand All @@ -28,7 +39,7 @@ BarWidget {
Row {
id: row
anchors.centerIn: parent
spacing: Style.space(6)
spacing: Style.space(root.iconGap)

Text {
id: glyph
Expand All @@ -45,15 +56,19 @@ BarWidget {

Item {
id: scrollClip
width: Math.min(root.maxLabelWidth, labelText.implicitWidth)
width: root.scrollLabel
? Math.min(root.maxLabelWidth, labelText.implicitWidth)
: staticLabel.implicitWidth
height: glyph.height
clip: true
anchors.verticalCenter: parent.verticalCenter
visible: !root.bar.vertical && root.title !== ""

// Scrolling mode: title and artist as one string, slid horizontally.
Text {
id: labelText
text: root.title + (root.artist ? " · " + root.artist : "")
visible: root.scrollLabel
text: root.title + (root.artist ? root.separator + root.artist : "")
color: root.bar.barForeground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
Expand All @@ -63,14 +78,77 @@ BarWidget {

NumberAnimation on x {
id: scrollAnim
running: labelText.needsScroll && !root.popupOpen && !root.bar.vertical
running: root.scrollLabel && labelText.needsScroll && !root.popupOpen && !root.bar.vertical
loops: Animation.Infinite
duration: Math.max(6000, labelText.implicitWidth * 25)
from: scrollClip.width
to: -labelText.implicitWidth
easing.type: Easing.Linear
}
}

// Static mode: title and artist elide against their own share of the
// budget, so a long title truncates instead of pushing the artist out of
// the label entirely. The shares are constants rather than being derived
// from the text metrics, which keeps each label's width binding clear of
// its own implicitWidth.
Row {
id: staticLabel
visible: !root.scrollLabel
spacing: 0
anchors.verticalCenter: parent.verticalCenter

// The artist claims its share first and the title takes whatever is
// left over, so a short artist hands its slack back to the title.
// artistWidth never depends on titleWidth, which keeps the pair clear
// of a binding cycle.
readonly property real separatorWidth: root.artist !== "" ? sepText.implicitWidth : 0
readonly property real artistWidth: Math.min(root.maxLabelWidth * 0.35, artistText.implicitWidth)
readonly property real titleWidth: Math.min(
Math.max(0, root.maxLabelWidth - separatorWidth - artistWidth), titleText.implicitWidth)

Item {
width: staticLabel.titleWidth
height: titleText.implicitHeight
clip: true

Text {
id: titleText
text: root.title
width: parent.width
elide: Text.ElideRight
color: root.bar.barForeground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
}

Text {
id: sepText
visible: root.artist !== ""
text: root.separator
color: root.bar.barForeground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}

Item {
visible: root.artist !== ""
width: staticLabel.artistWidth
height: artistText.implicitHeight
clip: true

Text {
id: artistText
text: root.artist
width: parent.width
elide: Text.ElideRight
color: root.bar.barForeground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
}
}
}
}

Expand Down
28 changes: 28 additions & 0 deletions test/shell.d/media-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"

run_node_test <<'JS'
const fs = require('fs')
const media = requireFromRoot('shell/plugins/services/media/MediaModel.js')

assert(media.isProxyPlayer({ dbusName: 'org.mpris.MediaPlayer2.playerctld' }), 'media detects playerctld proxy by DBus name')
Expand Down Expand Up @@ -40,4 +41,31 @@ assert(media.trackChanged(trackSignature, { ...track, trackTitle: 'Next song' })
assertEqual(media.labelFor({ trackTitle: 'Song', identity: 'Spotify' }), 'Song', 'media labels players by track first')
assertEqual(media.osdMessage({ trackTitle: 'Song', trackArtist: 'Artist' }, 'Fallback'), 'Song - Artist', 'media builds OSD messages')
assertEqual(media.osdMessage(null, 'Fallback'), 'Fallback', 'media falls back OSD messages')
// ---- bar widget settings
// Comments stripped: a wiring assertion that a commented-out line can satisfy
// passes while the widget is broken.
const widgetSource = fs.readFileSync(root + '/shell/plugins/services/media/BarWidget.qml', 'utf8')
.replace(/^\s*\/\/.*$/gm, '')

assert(/setting\("scroll",\s*true\)/.test(widgetSource), 'media widget reads scroll, defaulting to the previous scrolling behaviour')
assert(/setting\("separator",\s*" · "\)/.test(widgetSource), 'media widget reads separator, defaulting to the previous string')
assert(/setting\("iconGap",\s*scrollLabel \? 6 : 13\)/.test(widgetSource), 'media widget keeps the previous control gap while scrolling and widens it when static')
assert(/setting\("maxLabelWidth",\s*scrollLabel \? 180 : 360\)/.test(widgetSource), 'media widget keeps the previous cap while scrolling and widens it when static')

assert(/spacing:\s*Style\.space\(root\.iconGap\)/.test(widgetSource), 'media widget spaces the control from the label by iconGap')
assert(/text:\s*root\.title \+ \(root\.artist \? root\.separator \+ root\.artist : ""\)/.test(widgetSource), 'media widget joins title and artist with the configured separator')

// Both branches must be gated, or the two label layouts render on top of each other.
assert(/id: labelText[\s\S]{0,200}?visible: root\.scrollLabel\b/.test(widgetSource), 'media widget shows the scrolling label only when scrolling')
assert(/id: staticLabel[\s\S]{0,200}?visible: !root\.scrollLabel\b/.test(widgetSource), 'media widget shows the static label only when not scrolling')
assert(/running: root\.scrollLabel &&/.test(widgetSource), 'media widget runs the scroll animation only when scrolling')

// The separator has to come out of the budget, otherwise a long title plus a
// long artist renders at maxLabelWidth + separator width.
assert(/readonly property real separatorWidth: root\.artist !== "" \? sepText\.implicitWidth : 0/.test(widgetSource), 'media widget measures the separator for the static budget')
assert(/artistWidth: Math\.min\(root\.maxLabelWidth \* 0\.35, artistText\.implicitWidth\)/.test(widgetSource), 'media widget caps the static artist at its share')
assert(/titleWidth: Math\.min\(\s*Math\.max\(0, root\.maxLabelWidth - separatorWidth - artistWidth\), titleText\.implicitWidth\)/.test(widgetSource), 'media widget gives the static title the budget left after separator and artist')

assert(/id: titleText[\s\S]{0,220}?elide: Text\.ElideRight/.test(widgetSource), 'media widget elides the static title independently')
assert(/id: artistText[\s\S]{0,220}?elide: Text\.ElideRight/.test(widgetSource), 'media widget elides the static artist independently')
JS