* Fix unclickable tray submenus by drilling down inside the popup Clicking a tray menu entry that has children was a silent no-op: the row called QsMenuEntry.display(), which renders a *platform* menu, and Quickshell refuses that unless the shell root sets `//@ pragma UseQApplication` -- shell.qml does not. The log shows "Cannot display PlatformMenuEntry as quickshell was not started in QApplication mode" and nothing opens. Apps whose whole menu is submenus, like radiotray-ng's station list, were unusable. Adding the pragma would be the wrong fix: it switches the entire shell from QGuiApplication to QApplication, dragging QtWidgets into the process and changing application-class behavior for the sake of one popup -- which would then render as an unstyled platform menu beside omarchy's own popup styling anyway. Instead, submenus drill down inside the existing popup. A child QsMenuEntry inherits QsMenuHandle, so it can feed a nested QsMenuOpener and render through the same row delegate. Each level keeps its own live opener on a stack -- a child entry is owned by its parent opener's model, so collapsing to a single reassigned opener would destroy the very entry being displayed. A back header row walks out one level; at the root the menu renders exactly as before, and items without a DBusMenu still use the platform fallback. * Destroy submenu openers deepest-first and reset before switching items resetTrayMenu() destroyed openers front-to-back and only cleared submenuStack afterward. A deeper opener's menu entry is owned by its parent's children model, so destroying the parent first could invalidate an entry a still-live child opener referenced. Clear the stack before tearing anything down, then destroy deepest-first so a child is always gone before the parent whose model owns its entry. openTrayMenu() reassigned activeTrayItem before calling resetTrayMenu(). trayMenuOpener.menu binds to activeTrayItem.menu, so that reassignment immediately swaps what the root opener's children expose -- invalidating entries any live submenu opener still referenced, before resetTrayMenu() got a chance to tear them down. Reset first, then switch items. Thanks @Copilot for catching both. * Defer submenu reset until the popup's fade-out actually finishes onTrayMenuOpenChanged reset the submenu stack the instant trayMenuOpen went false, but the popup stays visible for the whole 140ms opacity fade (PopupCard's own visible: open || card.opacity > 0) -- dismissing from a submenu flashed the root menu mid-fade, and could resize or reposition the fading popup if the two have different geometry. Moved the reset to trayMenuPopup's own onVisibleChanged, which only fires once the fade has genuinely completed. Switching to a different tray item is unaffected: openTrayMenu() already resets explicitly before assigning the new item, independent of whether the popup ever dips to invisible (rapid reopen mid-fade never does). Thanks @Copilot for catching this. * Ignore tray menu clicks for a beat after changing submenu level Changing level swaps the Repeater's model, which rebuilds the row delegates synchronously -- a fresh row lands under a cursor that hasn't moved. Submenu clicks used to be silent no-ops, which trained users to click them twice, so that second click now fires whatever entry took the spot. On radiotray-ng that means an accidental station switch. Gate row and back-header clicks for 250ms after each level change. A deliberate follow-up click is slower than that; a double-click is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Pin the submenu back header above the scrolling menu rows The back header lived inside the Flickable's Column, so in a submenu taller than the 420px cap -- exactly the long station list this drill-down exists for -- scrolling down pushed the only way back off screen, with no Escape or right-click alternative. Move it into a pinned Column above the Flickable and account for its height in the popup's contentHeight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Reset the tray menu scroll offset when the drill-down is torn down Flickable keeps its contentY across a model swap whenever the new content is still tall enough to hold it. A menu dismissed while scrolled therefore reopened part-way down with its first entries off screen: reproducible on any tray app whose root menu outgrows the 420px cap, and now reachable on every app once a long submenu has been scrolled. Zero the offset in resetTrayMenu(), which runs both on teardown and before switching items. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Toni Nowak <t.nowak@ai-flow.no> Co-authored-by: David Heinemeier Hansson <david@hey.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Omarchy shell
omarchy-shell is a single long-running Quickshell
instance that hosts the Omarchy desktop. Hyprland autostart launches one shell
per graphical session; everything else — the bar, background switcher, panels,
and overlays — runs inside the shell as a plugin.
Hosting everything inside one shell means:
- shared services and singletons live once, not once per process
- summoning a panel is an IPC call into a process that is already running,
not a fresh
quickshell -p ...cold start - third-party plugins can be loaded from disk without changing any source code in Omarchy itself
The runtime layout:
shell/
shell.qml entry point (ShellRoot)
services/
PluginRegistry.qml discovers, validates plugins, looks up enabled state in shell.json
BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p)
plugins/
bar/ first-party plugins (see plugins/README.md)
image-picker/
menu/
notifications/
panels/
audio/
bluetooth/
monitor/
network/
power/
weather/
agents/
services/
battery/
idle/
osd/
polkit/
The plugin discovery path is documented in plugins/README.md.
Plugin manifest
Every plugin ships a manifest.json describing what it is and how the
shell should load it. Minimal example:
{
"schemaVersion": 1,
"id": "my.org.cool-clock",
"name": "Cool clock",
"version": "1.0.0",
"author": "You",
"description": "A clock that does cool things",
"kinds": ["bar-widget"],
"entryPoints": { "barWidget": "Widget.qml" },
"barWidget": {
"displayName": "Cool clock",
"category": "Time",
"allowMultiple": false,
"defaultSection": "left",
"defaults": { "format": "HH:mm" },
"schema": [
{ "key": "format", "type": "string", "label": "Format" }
]
}
}
Supported kinds:
| Kind | What it is |
|---|---|
bar-widget |
A component that the active bar can drop into a section |
panel |
A persistent or summoned floating window (e.g. OSD) |
overlay |
A fullscreen overlay (e.g. background switcher) |
menu |
A summoned menu surface |
service |
A headless singleton, no UI |
bar |
A full bar option that can replace the built-in omarchy.bar |
Only one bar plugin is active at a time. Missing or invalid selections fall
back to the built-in omarchy.bar, so users always have a safe path home.
Panels, overlays, and menus are loaded when summoned. Plugins that need
to outlive a single summon can set keepLoaded: true (e.g. the image
picker keeps its overlay window mounted between summons). First-party
services are loaded at startup.
The full schema lives in services/PluginRegistry.qml.
Installing a third-party plugin
A plugin is a git repo with a manifest.json at its root. Adding one
clones it straight into ~/.config/omarchy/plugins/<id>/ (named by the
manifest id); updating is a fast-forward pull of that checkout.
omarchy plugin add https://github.com/acme/omarchy-weather.git
omarchy plugin update acme.weather # fetches, shows a diff, fast-forwards
omarchy plugin update # updates every git-managed plugin
omarchy plugin remove acme.weather
⚠️ Plugins run as unsandboxed code inside
omarchy-shell. Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. Only add repos whose code you are willing to run.
Each command is interactive when run bare in a terminal (gum pickers,
confirmation, a diff to review) and fully non-interactive when given
arguments. Pass --yes to skip every prompt — this is the path for scripts and
AI agents:
omarchy plugin add https://github.com/acme/omarchy-weather.git --enable --yes
omarchy plugin update --yes
The installer never runs plugin code, install hooks, or sudo — it only clones files, validates the manifest, and toggles enabled state over shell IPC. Since an installed plugin is a plain git checkout, anything beyond add/update (pinning a ref, switching branches) is ordinary git in the plugin directory.
Installing by hand
You can still drop a plugin in without git:
- Put it in
~/.config/omarchy/plugins/<plugin-id>/with amanifest.jsonplus the QML referenced from itsentryPoints. omarchy-shell shell rescanPlugins.omarchy plugin enable <id>. Bar widgets start inbarWidget.defaultSection, or in the center when it is omitted, and can be moved withomarchy bar move; a full bar replaces the one in use.
The lower-level IPC equivalents remain available via omarchy-shell shell rescanPlugins,
omarchy-shell shell enablePlugin <id> '{}', and omarchy-shell shell listPlugins.
The omarchy plugin commands wrap those calls. omarchy bar move and
omarchy bar set edit the persisted widget layout in shell.json.
To hack on a built-in plugin safely, clone it into user config instead of
editing the built-in source. The complete plugin directory is copied, including
every declared kind and local dependency. A built-in id such as
omarchy.clock becomes <username>.clock (e.g. dhh.clock), with My Clock
as its display name. The username prefix keeps shared clones from colliding
with each other or with other plugin authors.
omarchy plugin clone omarchy.clock
Cloning switches from the built-in to the new personal plugin, preserving an
existing bar widget's position and settings. Setup > Plugins > Clone provides
the interactive picker, then opens the new <username>.* directory in $EDITOR.
Existing shortcuts and shell IPC calls made to the built-in id are routed to
the enabled clone, so cloning does not require changing its callers. Removing
an active clone switches back to its built-in source.
Saving a file anywhere under ~/.config/omarchy/plugins/ reloads plugin code
automatically; omarchy-shell shell rescanPlugins remains available to force a reload.
First-party plugins under shell/plugins/ are discovered the same way and load
by default. Disabling a non-widget records it in disabledPlugins[]; disabling
a widget removes it from the bar layout while leaving its component available
to add again. A full bar has no off state and is replaced by enabling another.
IPC contract
The shell exposes a single shell IPC target plus whatever extra targets
individual plugins register (e.g. the bar's bar target for refresh
hooks, the image picker's image-selector target). omarchy-menu uses the
shell target to summon the first-party omarchy.menu plugin instead of
running a separate Quickshell instance.
| Method | Returns | Effect |
|---|---|---|
ping |
ok |
health check |
summon <id> <payloadJson> |
ok / unknown |
load + open a panel/overlay plugin |
hide <id> |
— | close a previously-summoned plugin |
toggle <id> <payloadJson> |
— | summon if closed, hide if open |
call <id> <method> <arg> |
string | call a method on an already-loaded plugin |
rescanPlugins |
— | re-walk plugin dirs and hot-reload plugin code |
reloadConfig |
ok |
reload ~/.config/omarchy/shell.json |
setPluginEnabled <id> <enabled> |
ok / unknown |
flip the persisted enabled bit (see note) |
listPlugins |
JSON | every discovered plugin, sorted by name |
Direct invocation:
quickshell ipc -p $OMARCHY_PATH/shell call shell ping
Hyprland autostart launches the shell directly with quickshell -p $OMARCHY_PATH/shell. Use omarchy-restart-shell to stop every running
instance of that config and launch one fresh shell process.
A convenience wrapper, omarchy-shell, forwards IPC
calls to the running shell. It does not start the shell.
omarchy-shell shell ping
omarchy-shell shell toggle omarchy.menu '{"menu":"root"}'
omarchy-shell shell listPlugins
omarchy-shell shell rescanPlugins
Note on setPluginEnabled: the enabled argument is a string. Only the
literal "true" enables the plugin; every other value (including "True",
"1", "yes", or omitted) disables it. This keeps the IPC surface
type-stable across QML's string-only IPC arguments.
Persisted state
There is one user config file. Everything that distinguishes your customization from the shipped defaults lives in it.
| Path | Owner | Purpose |
|---|---|---|
~/.config/omarchy/shell.json |
the shell | full layout + per-entry settings + enabled plugin list |
~/.config/omarchy/plugins/<id>/ |
user | drop-in third-party plugin source files |
The config/omarchy/shell.json default config describes the
fresh-install state. When the user has no shell.json, the shell uses
the defaults verbatim. Once the user customizes anything, shell.json
becomes the authoritative file — we do not deep-merge defaults back in.
shell.json shape
{
"version": 1,
"idle": {
"screensaver": 150,
"lock": 300
},
"bar": {
"id": "omarchy.bar",
"position": "top",
"transparent": false,
"centerAnchor": "omarchy.clock",
"layout": {
"left": [ { "id": "omarchy.menu" }, { "id": "omarchy.workspaces" } ],
"center": [ { "id": "omarchy.clock", "format": "HH:mm" } ],
"right": [
{ "id": "omarchy.audio" }
]
}
},
"plugins": []
}
Storage rules
- The active bar option is
bar.id. Omit it or set it toomarchy.barto use the built-in bar. Set it to another plugin id whose manifest declareskind: "bar"to replace the full bar. - Every plugin instance is one entry. Either in
bar.layout.<section>for bar widgets, or inplugins[]for panels, overlays, services, menus, and anything else non-bar. - Settings are inline on the entry. No
config:sub-object, no separate per-plugin settings file, no merge layers. The fields on each entry are the values the plugin sees. - Built-in widget ids are namespaced. Use ids such as
omarchy.clock,omarchy.audio, andomarchy.network. The migration rewrites older ids likeClockandAudioPanelforward. - Third-party enabled ⇔ present. A third-party plugin is enabled iff
its id appears somewhere in shell.json. For full bar options, that means
bar.id; for bar widgets, plugin enable/disable adds/removes layout entries; other plugin kinds are enabled the same way. First-party non-bar plugins are enabled unless listed indisabledPlugins[]. - Multiple instances are allowed when a manifest sets
allowMultiple: true. Each instance is independent — e.g. two clock widgets in different timezones are just two{"id":"omarchy.clock", "timezone": ...}entries with their own values. - Idle timings are top-level.
idle.screensaverandidle.lockare seconds since user idle began, so the default lock fires at 300s even if the 150s screensaver starts first. version: 1is required at the top level. The shell will fall back to defaults rather than load an unknown version.
Implementation history
Built up in phases on this branch:
- Phase 1 —
omarchy-shell phase 1: host the existing bar in a single shell - Phase 2 —
omarchy-shell phase 2: plugin registry and bar widget registry - Phase 3 —
omarchy-shell phase 3: fold bar-settings into the shell as a panel plugin - Phase 4 —
omarchy-shell phase 4: absorb background-switcher as a plugin - Phase 5 —
omarchy-shell phase 5: docs, cleanup, and migration crumbs - Phase 6 —
omarchy-shell phase 6: reviewer cleanup (path traversal, collision, races) - Phase 7 —
omarchy-shell phase 7: replace socket with IpcHandler, rename to image-picker - Phase 8a —
omarchy-shell phase 8a: unified shell.json with inline plugin settings
Shared services and Pipewire/UPower/Hyprland consolidation are explicitly out of scope here and deferred to a follow-up after a review pass.