You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The Quattro bar is defined once and instantiated on every output — Variants { model: Quickshell.screens } at shell/plugins/bar/Bar.qml:967, and again at :979 and :992 for the drag ghosts. That is the right default, but there is currently no way to say that one monitor should carry a different bar than another, and two fairly ordinary setups run into it.
Different-sized screens. A laptop panel and a 32" desktop monitor cannot carry the same number of widgets at the same scale. Today you either crowd the laptop or impoverish the desktop, because both read the same layout. This is a space problem rather than a taste problem, and it gets worse the more widgets a user adds.
A screen you share. I keep one output for work and screen sharing and the other for general use. When a monitor is shared in a call, everything on its bar goes out with it — unread mail counts, agent activity, notification and trash indicators. A deliberately quiet bar on the shared output solves that cleanly, and no widget-level setting can: a widget cannot know that one of the two screens it is drawn on is the one being shared.
Proposal
Two optional top-level keys. bars holds named bar profiles; screens assigns them per output. The existing bar block stays exactly as it is and remains the default for any screen not named.
Reading that: DP-3 gets the quiet work bar, the laptop panel gets the compact one, HDMI-A-1 gets no bar at all, and every other output — including ones connected later — gets the existing bar.
Naming the profiles rather than inlining them per screen means a profile can be assigned to several outputs without copy-pasting a layout, which matters as soon as there are three monitors, or a dock whose outputs come and go.
position and transparent are deliberately absent from the profile bodies above. See Scope below — both stay global in a first version, and a profile that sets them would be ignored, so it is better not to accept them yet.
Compatibility
Every existing shell.json is already valid under this: no bars, no screens, one bar on every screen, unchanged behaviour. A single-monitor user never meets either key, and there is nothing to migrate.
The common multi-monitor case is also small, because only the exception has to be stated:
"screens": { "eDP-1": { "bar": "compact" } }
Relationship to existing work
#6501 (feat(bar): add bar.screens to restrict the bar to specific monitors) — this generalises it, and the two differ in default rather than only in spelling. bar.screens is an allowlist, so an output not named loses its bar, including one connected later: dock a laptop or plug in a projector and that screen comes up blank. screens: { "HDMI-A-1": { "bar": false } } states the exception per output, so a new monitor gets a bar unless you say otherwise. Both express the same use case and the implementation cost today is the same; if bar.screens lands first it stays a valid shorthand.
#6722 (public per-screen visibility override API for plugins) — deliberately out of scope. That discussion asks for a runtime API so a plugin can hide the bar on one screen for autohide or presentation policies. That is a plugin-facing question about dynamic state; this is a static configuration question. They compose (a screen with a profile can still be hidden at runtime) but neither replaces the other.
Shared edits are the current behaviour, not a new one
The obvious objection to reusable profiles is that assigning one profile to two screens means a drag on either screen rearranges both. That is true, and it is what already happens today for every screen: the bar is rearranged by moveModuleInConfig at Bar.qml:645, which resolves its target through rawLayoutSection(config, region) at :629 — config.bar.layout[region], with no screen dimension anywhere in the path. Inline settings persist the same way through updateEntryInline at shell/shell.qml:366.
So a single shared layout is the status quo, and profiles do not introduce the coupling — they make it escapable for the first time. Assigning a screen its own profile is the only way to get a drag that does not follow you to the other monitor. Where a profile is shared, edits following it seems like the honest behaviour: sharing a profile is sharing a config, the same way two hosts sharing a file see each other's changes. Forking a profile on first edit was the alternative, and it seems worse — it makes the write path guess at intent.
Implementation notes
Reading
layoutEntries(region) at Bar.qml:392 feeds every module list, and its call sites all sit inside a per-screen panel, so the screen is in scope where it is needed. The function itself is not: it is declared on root and reads root.layoutConfig (:42), and no call site passes a screen today. Making layout per-screen means threading a screen or a resolved profile from BarPanel (:1004) through LeftModules (:1294), RightModules (:1299) and CenterModules (:1304) into ModuleList (:1489) and ModuleSlot (:1543). Bounded, but more than a one-line resolver.
Two related reads would move with it:
moduleSlots is a single flat registry on root, keyed by (region, moduleName), and applySettingsDelta (:376) pushes inline settings by matching that key across every screen at once. With divergent layouts the same id can sit at different indexes on different screens, so the push needs a screen or profile dimension too.
Tray.qml:163 reads bar.layoutConfig directly to decide inner-edge pinning, and becomes per-screen for the same reason.
Writing
This is the larger half, and larger than I first estimated. moveModuleInConfig and updateEntryInline are the bar's own two write sites, but they are not the only ones. shell/services/PluginRegistry.qml holds 26 further references to config.bar.layout — placement resolution (:239–:278), entry lookup (:331–:349), clone and restore (:426–:438), removal (:531) — which is the whole plugin enable/disable/place/move machinery, and it assumes exactly one layout throughout.
Riding on top of that are the IPC handlers putBarWidget (shell.qml:923), setBarWidget (:941) and togglePanelAt (:1018), and the CLI in bin/omarchy-bar (put, move, set, position, transparent, reset, defaults), which speak in --section/--index/--before/--after and have no profile dimension in their vocabulary.
So the write side needs a rule before it needs code. Two candidates:
Writes target the default bar unless told otherwise — the CLI and IPC grow an optional --bar <profile>, and a drag resolves the profile of the screen it started on (which the drag already has in hand). Existing invocations keep their current meaning.
Writes target the profile of the screen they originated on, with the CLI defaulting to the focused output. More intuitive interactively, but it changes what an unqualified omarchy bar put means today.
I lean towards (1), because it leaves every existing command and script meaning exactly what it means now.
Bar plugin contract
shell.qml:115 hands the bar only the bar: subtree, and configureBar (:214) passes the same object to a replacement bar declaring kind: "bar". bars and screens are top-level, so either that contract widens or the built-in bar reaches around it via shell.shellConfig.
Worth being clear about a limitation this shares with #6501 either way: the per-screen Variants live inside the bar component, so the host cannot filter screens on a plugin bar's behalf. Whichever spelling wins, a replacement bar has to honour it itself — exactly as #6501's docs already say.
Scope
Splitting this into two steps, because the first is small enough to land on its own and the second is what actually needs agreement:
Step 1 — screens with { "bar": false } only. No bars, no per-screen layout. Read-side only: filter the model behind the three Variants. It touches none of the write sites above, subsumes #6501's use case, and establishes the config shape.
Step 2 — named profiles, per-screen layout and centerAnchor. The read threading and the write-side rule above.
position stays global. It is a single root property (Bar.qml:58) read for anchors, margins and exclusion zones, and it is also read from outside the bar by Ui/PopupCard.qml, Ui/KeyboardPanel.qml:67 and the notifications service (Service.qml:48 into NotificationLogic.js:398). All of those assume one bar position for the session, so per-screen position means teaching every popup consumer which screen it is placing against.
transparent stays global too, for the same kind of reason — I had this in the per-screen set originally and that was wrong. The transparency pipeline is not inside BarPanel; it is all on root, above the component boundary at :1004: requestedTransparent (:44), setRequestedTransparency (:802), scheduleTransparentForegroundRefresh (:822), refreshTransparentForeground (:830), and the transparentForegroundTimer (:849) and transparentForegroundProc (:856) behind them. It shells out to omarchy-bar-text-color with root.position and root.barSize to sample a contrasting foreground, and single-flights on transparentForegroundProc.running. Per-screen transparency therefore means per-screen sampling, foreground colour and animation state — the same class of change as per-screen position, and no better a fit for a first version.
That leaves step 2 at: profile resolution, per-screen layout and centerAnchor, and a documented rule for which profile a write lands in.
One related simplification worth taking deliberately: inlineSettingsDelta (BarModel.js:76) exists so that a write which only touches inline widget settings patches the running widgets instead of rebuilding every bar. It compares one layout against one layout. Rather than generalise it across profiles in the same change, a first version can decline the fast path when bars/screens are in use and take the full rebuild — which is already what happens when a widget id appears more than once in a layout (counts[entryId(b[j])] > 1 returns null). Configurations that do not use profiles keep the optimisation exactly as it is today.
One detail worth building in from the start rather than bolting on: an unknown profile name should fail soft without persisting — fall back to the default bar and warn, never rewrite the file. #7100 (malformed shell.json reverting the bar to defaults, and the next click overwriting the user's config with them) is the failure mode to avoid here.
Open questions
Which profile does a write land in? The two candidates above. This is the question that most needs an answer before step 2 is worth writing, because it sets the meaning of every existing omarchy bar invocation.
Should screen keys match on description as well as connector name? Something like "desc:Dell U2723QE H4ZK123" alongside "DP-3". Connector names shuffle when a GPU or cable changes, which argues for it. To be clear about the precedent: Omarchy reads desc: nowhere today — config/hypr/monitors.lua uses connector names — so this is new surface rather than consistency with existing behaviour, and it can be added later against the same key.
Should a screen be able to override a scalar on top of a referenced profile? Something like { "bar": "work", "centerAnchor": "omarchy.weather" }, or is reference-or-inline enough? Shallow overrides are convenient but reintroduce the merge semantics that plain replacement avoids.
Should screens eventually accept a list, so one output can carry two bars? A top bar and a bottom dock, say. Named profiles make that expressible later without another config shape, but it need not be in scope now.
Is bar the clearest spelling for the implicit default? The alternative is a reserved profile name inside bars. The former needs no migration, which is why it is written that way above.
Happy to open a PR for step 1, and for step 2 once the write rule is settled — I would rather agree on the spelling here than send a diff that has to be redesigned in review.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Motivation
The Quattro bar is defined once and instantiated on every output —
Variants { model: Quickshell.screens }atshell/plugins/bar/Bar.qml:967, and again at:979and:992for the drag ghosts. That is the right default, but there is currently no way to say that one monitor should carry a different bar than another, and two fairly ordinary setups run into it.Different-sized screens. A laptop panel and a 32" desktop monitor cannot carry the same number of widgets at the same scale. Today you either crowd the laptop or impoverish the desktop, because both read the same
layout. This is a space problem rather than a taste problem, and it gets worse the more widgets a user adds.A screen you share. I keep one output for work and screen sharing and the other for general use. When a monitor is shared in a call, everything on its bar goes out with it — unread mail counts, agent activity, notification and trash indicators. A deliberately quiet bar on the shared output solves that cleanly, and no widget-level setting can: a widget cannot know that one of the two screens it is drawn on is the one being shared.
Proposal
Two optional top-level keys.
barsholds named bar profiles;screensassigns them per output. The existingbarblock stays exactly as it is and remains the default for any screen not named.{ "version": 1, "bar": { "position": "top", "transparent": false, "centerAnchor": "omarchy.clock", "layout": { "left": ["omarchy.menu", "omarchy.workspaces"], "center": ["omarchy.indicators", "omarchy.clock", "omarchy.weather"], "right": ["omarchy.tray", "omarchy.agents", "omarchy.network", "omarchy.audio", "omarchy.power"] } }, "bars": { "work": { "centerAnchor": "omarchy.clock", "layout": { "left": ["omarchy.menu", "omarchy.workspaces"], "center": ["omarchy.clock"], "right": ["omarchy.audio", "omarchy.power"] } }, "compact": { "layout": { "left": ["omarchy.workspaces"], "center": [], "right": ["omarchy.network", "omarchy.audio", "omarchy.power"] } } }, "screens": { "DP-3": { "bar": "work" }, "eDP-1": { "bar": "compact" }, "HDMI-A-1": { "bar": false } }, "plugins": [] }Reading that:
DP-3gets the quiet work bar, the laptop panel gets the compact one,HDMI-A-1gets no bar at all, and every other output — including ones connected later — gets the existingbar.Naming the profiles rather than inlining them per screen means a profile can be assigned to several outputs without copy-pasting a layout, which matters as soon as there are three monitors, or a dock whose outputs come and go.
positionandtransparentare deliberately absent from the profile bodies above. See Scope below — both stay global in a first version, and a profile that sets them would be ignored, so it is better not to accept them yet.Compatibility
Every existing
shell.jsonis already valid under this: nobars, noscreens, one bar on every screen, unchanged behaviour. A single-monitor user never meets either key, and there is nothing to migrate.The common multi-monitor case is also small, because only the exception has to be stated:
Relationship to existing work
#6501 (
feat(bar): add bar.screens to restrict the bar to specific monitors) — this generalises it, and the two differ in default rather than only in spelling.bar.screensis an allowlist, so an output not named loses its bar, including one connected later: dock a laptop or plug in a projector and that screen comes up blank.screens: { "HDMI-A-1": { "bar": false } }states the exception per output, so a new monitor gets a bar unless you say otherwise. Both express the same use case and the implementation cost today is the same; ifbar.screenslands first it stays a valid shorthand.#6722 (
public per-screen visibility override API for plugins) — deliberately out of scope. That discussion asks for a runtime API so a plugin can hide the bar on one screen for autohide or presentation policies. That is a plugin-facing question about dynamic state; this is a static configuration question. They compose (a screen with a profile can still be hidden at runtime) but neither replaces the other.Shared edits are the current behaviour, not a new one
The obvious objection to reusable profiles is that assigning one profile to two screens means a drag on either screen rearranges both. That is true, and it is what already happens today for every screen: the bar is rearranged by
moveModuleInConfigatBar.qml:645, which resolves its target throughrawLayoutSection(config, region)at:629—config.bar.layout[region], with no screen dimension anywhere in the path. Inline settings persist the same way throughupdateEntryInlineatshell/shell.qml:366.So a single shared layout is the status quo, and profiles do not introduce the coupling — they make it escapable for the first time. Assigning a screen its own profile is the only way to get a drag that does not follow you to the other monitor. Where a profile is shared, edits following it seems like the honest behaviour: sharing a profile is sharing a config, the same way two hosts sharing a file see each other's changes. Forking a profile on first edit was the alternative, and it seems worse — it makes the write path guess at intent.
Implementation notes
Reading
layoutEntries(region)atBar.qml:392feeds every module list, and its call sites all sit inside a per-screen panel, so the screen is in scope where it is needed. The function itself is not: it is declared onrootand readsroot.layoutConfig(:42), and no call site passes a screen today. Making layout per-screen means threading a screen or a resolved profile fromBarPanel(:1004) throughLeftModules(:1294),RightModules(:1299) andCenterModules(:1304) intoModuleList(:1489) andModuleSlot(:1543). Bounded, but more than a one-line resolver.Two related reads would move with it:
moduleSlotsis a single flat registry onroot, keyed by(region, moduleName), andapplySettingsDelta(:376) pushes inline settings by matching that key across every screen at once. With divergent layouts the same id can sit at different indexes on different screens, so the push needs a screen or profile dimension too.Tray.qml:163readsbar.layoutConfigdirectly to decide inner-edge pinning, and becomes per-screen for the same reason.Writing
This is the larger half, and larger than I first estimated.
moveModuleInConfigandupdateEntryInlineare the bar's own two write sites, but they are not the only ones.shell/services/PluginRegistry.qmlholds 26 further references toconfig.bar.layout— placement resolution (:239–:278), entry lookup (:331–:349), clone and restore (:426–:438), removal (:531) — which is the whole plugin enable/disable/place/move machinery, and it assumes exactly one layout throughout.Riding on top of that are the IPC handlers
putBarWidget(shell.qml:923),setBarWidget(:941) andtogglePanelAt(:1018), and the CLI inbin/omarchy-bar(put,move,set,position,transparent,reset,defaults), which speak in--section/--index/--before/--afterand have no profile dimension in their vocabulary.So the write side needs a rule before it needs code. Two candidates:
barunless told otherwise — the CLI and IPC grow an optional--bar <profile>, and a drag resolves the profile of the screen it started on (which the drag already has in hand). Existing invocations keep their current meaning.omarchy bar putmeans today.I lean towards (1), because it leaves every existing command and script meaning exactly what it means now.
Bar plugin contract
shell.qml:115hands the bar only thebar:subtree, andconfigureBar(:214) passes the same object to a replacement bar declaringkind: "bar".barsandscreensare top-level, so either that contract widens or the built-in bar reaches around it viashell.shellConfig.Worth being clear about a limitation this shares with #6501 either way: the per-screen
Variantslive inside the bar component, so the host cannot filter screens on a plugin bar's behalf. Whichever spelling wins, a replacement bar has to honour it itself — exactly as #6501's docs already say.Scope
Splitting this into two steps, because the first is small enough to land on its own and the second is what actually needs agreement:
Step 1 —
screenswith{ "bar": false }only. Nobars, no per-screen layout. Read-side only: filter the model behind the threeVariants. It touches none of the write sites above, subsumes #6501's use case, and establishes the config shape.Step 2 — named profiles, per-screen
layoutandcenterAnchor. The read threading and the write-side rule above.positionstays global. It is a single root property (Bar.qml:58) read for anchors, margins and exclusion zones, and it is also read from outside the bar byUi/PopupCard.qml,Ui/KeyboardPanel.qml:67and the notifications service (Service.qml:48intoNotificationLogic.js:398). All of those assume one bar position for the session, so per-screen position means teaching every popup consumer which screen it is placing against.transparentstays global too, for the same kind of reason — I had this in the per-screen set originally and that was wrong. The transparency pipeline is not insideBarPanel; it is all onroot, above the component boundary at:1004:requestedTransparent(:44),setRequestedTransparency(:802),scheduleTransparentForegroundRefresh(:822),refreshTransparentForeground(:830), and thetransparentForegroundTimer(:849) andtransparentForegroundProc(:856) behind them. It shells out toomarchy-bar-text-colorwithroot.positionandroot.barSizeto sample a contrasting foreground, and single-flights ontransparentForegroundProc.running. Per-screen transparency therefore means per-screen sampling, foreground colour and animation state — the same class of change as per-screen position, and no better a fit for a first version.That leaves step 2 at: profile resolution, per-screen
layoutandcenterAnchor, and a documented rule for which profile a write lands in.One related simplification worth taking deliberately:
inlineSettingsDelta(BarModel.js:76) exists so that a write which only touches inline widget settings patches the running widgets instead of rebuilding every bar. It compares one layout against one layout. Rather than generalise it across profiles in the same change, a first version can decline the fast path whenbars/screensare in use and take the full rebuild — which is already what happens when a widget id appears more than once in a layout (counts[entryId(b[j])] > 1returnsnull). Configurations that do not use profiles keep the optimisation exactly as it is today.One detail worth building in from the start rather than bolting on: an unknown profile name should fail soft without persisting — fall back to the default bar and warn, never rewrite the file. #7100 (malformed
shell.jsonreverting the bar to defaults, and the next click overwriting the user's config with them) is the failure mode to avoid here.Open questions
omarchy barinvocation."desc:Dell U2723QE H4ZK123"alongside"DP-3". Connector names shuffle when a GPU or cable changes, which argues for it. To be clear about the precedent: Omarchy readsdesc:nowhere today —config/hypr/monitors.luauses connector names — so this is new surface rather than consistency with existing behaviour, and it can be added later against the same key.{ "bar": "work", "centerAnchor": "omarchy.weather" }, or is reference-or-inline enough? Shallow overrides are convenient but reintroduce the merge semantics that plain replacement avoids.screenseventually accept a list, so one output can carry two bars? A top bar and a bottom dock, say. Named profiles make that expressible later without another config shape, but it need not be in scope now.barthe clearest spelling for the implicit default? The alternative is a reserved profile name insidebars. The former needs no migration, which is why it is written that way above.Happy to open a PR for step 1, and for step 2 once the write rule is settled — I would rather agree on the spelling here than send a diff that has to be redesigned in review.
Line references are against
quattroat 4d01791.All reactions