From c72a8756b31fe5d6b66343b8f1096991e4b6e80d Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Wed, 13 May 2026 18:03:01 -0400 Subject: [PATCH] Add omarchy-shell with a plugin registry --- bin/omarchy-capture-screenrecording | 2 +- bin/omarchy-font-set | 2 +- bin/omarchy-restart-bar | 2 +- bin/omarchy-theme-set | 2 +- bin/omarchy-toggle-bar | 2 +- bin/omarchy-toggle-idle | 2 +- bin/omarchy-toggle-notification-silencing | 2 +- bin/omarchy-update-available-reset | 2 +- bin/omarchy-voxtype-install | 2 +- bin/omarchy-voxtype-model | 2 +- default/hypr/autostart.lua | 2 +- default/quickshell/bar-settings/shell.qml | 4 +- .../plugins/bar/Bar.qml} | 138 ++++++---- .../{ => omarchy-shell/plugins}/bar/README.md | 4 +- .../plugins}/bar/bar-defaults.json | 0 .../plugins}/bar/common/PillButton.qml | 0 .../plugins}/bar/common/PopupCard.qml | 0 .../plugins}/bar/common/Slider.qml | 0 .../plugins}/bar/common/WidgetButton.qml | 0 .../omarchy-shell/plugins/bar/manifest.json | 11 + .../plugins}/bar/widgets/activeWindow.qml | 0 .../plugins}/bar/widgets/audioPanel.qml | 0 .../plugins}/bar/widgets/bluetoothPanel.qml | 0 .../plugins}/bar/widgets/brightness.qml | 0 .../plugins}/bar/widgets/calendar.qml | 0 .../plugins}/bar/widgets/controlCenter.qml | 0 .../plugins}/bar/widgets/idleInhibitor.qml | 0 .../plugins}/bar/widgets/keyboardLayout.qml | 0 .../plugins}/bar/widgets/lockKeys.qml | 0 .../plugins}/bar/widgets/media.qml | 0 .../plugins}/bar/widgets/microphone.qml | 0 .../plugins}/bar/widgets/networkPanel.qml | 0 .../plugins}/bar/widgets/nightLight.qml | 0 .../bar/widgets/notificationCenter.qml | 0 .../plugins}/bar/widgets/powerMenu.qml | 0 .../plugins}/bar/widgets/powerProfile.qml | 0 .../plugins}/bar/widgets/spacer.qml | 0 .../plugins}/bar/widgets/systemStats.qml | 0 .../plugins}/bar/widgets/weatherFlyout.qml | 0 .../plugins}/bar/widgets/workspacesPro.qml | 0 .../services/BarWidgetRegistry.qml | 52 ++++ .../omarchy-shell/services/PluginRegistry.qml | 248 ++++++++++++++++++ .../quickshell/omarchy-shell/services/qmldir | 3 + default/quickshell/omarchy-shell/shell.qml | 160 +++++++++++ 44 files changed, 582 insertions(+), 60 deletions(-) rename default/quickshell/{bar/shell.qml => omarchy-shell/plugins/bar/Bar.qml} (87%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/README.md (93%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/bar-defaults.json (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/common/PillButton.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/common/PopupCard.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/common/Slider.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/common/WidgetButton.qml (100%) create mode 100644 default/quickshell/omarchy-shell/plugins/bar/manifest.json rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/activeWindow.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/audioPanel.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/bluetoothPanel.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/brightness.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/calendar.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/controlCenter.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/idleInhibitor.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/keyboardLayout.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/lockKeys.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/media.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/microphone.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/networkPanel.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/nightLight.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/notificationCenter.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/powerMenu.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/powerProfile.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/spacer.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/systemStats.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/weatherFlyout.qml (100%) rename default/quickshell/{ => omarchy-shell/plugins}/bar/widgets/workspacesPro.qml (100%) create mode 100644 default/quickshell/omarchy-shell/services/BarWidgetRegistry.qml create mode 100644 default/quickshell/omarchy-shell/services/PluginRegistry.qml create mode 100644 default/quickshell/omarchy-shell/services/qmldir create mode 100644 default/quickshell/omarchy-shell/shell.qml diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index a466cffd..82644e06 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -252,7 +252,7 @@ stop_screenrecording() { toggle_screenrecording_indicator() { pkill -RTMIN+8 waybar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} - quickshell ipc -p "$OMARCHY_PATH/default/quickshell/bar" call bar refreshScreenRecording >/dev/null 2>&1 || true + quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshScreenRecording >/dev/null 2>&1 || true } screenrecording_active() { diff --git a/bin/omarchy-font-set b/bin/omarchy-font-set index a62b7b8c..6f2d4f31 100755 --- a/bin/omarchy-font-set +++ b/bin/omarchy-font-set @@ -56,7 +56,7 @@ PY omarchy-restart-waybar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} - if quickshell list -p "$OMARCHY_PATH/default/quickshell/bar" 2>/dev/null | grep -q '^Instance '; then + if quickshell list -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" 2>/dev/null | grep -q '^Instance '; then omarchy-restart-bar fi omarchy-restart-swayosd diff --git a/bin/omarchy-restart-bar b/bin/omarchy-restart-bar index b1302f56..b892b5be 100755 --- a/bin/omarchy-restart-bar +++ b/bin/omarchy-restart-bar @@ -4,7 +4,7 @@ # omarchy:examples=omarchy restart bar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -CONFIG_DIR="$OMARCHY_PATH/default/quickshell/bar" +CONFIG_DIR="$OMARCHY_PATH/default/quickshell/omarchy-shell" quickshell kill -p "$CONFIG_DIR" >/dev/null 2>&1 || true setsid uwsm-app -- env OMARCHY_PATH="$OMARCHY_PATH" quickshell -p "$CONFIG_DIR" >/dev/null 2>&1 & diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index bc26b126..5e3a670d 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -54,7 +54,7 @@ if pgrep -x waybar >/dev/null; then omarchy-restart-waybar fi OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -if quickshell list -p "$OMARCHY_PATH/default/quickshell/bar" 2>/dev/null | grep -q '^Instance '; then +if quickshell list -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" 2>/dev/null | grep -q '^Instance '; then omarchy-restart-bar fi omarchy-restart-swayosd diff --git a/bin/omarchy-toggle-bar b/bin/omarchy-toggle-bar index 572b9aad..68128687 100755 --- a/bin/omarchy-toggle-bar +++ b/bin/omarchy-toggle-bar @@ -4,7 +4,7 @@ # omarchy:examples=omarchy toggle bar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -CONFIG_DIR="$OMARCHY_PATH/default/quickshell/bar" +CONFIG_DIR="$OMARCHY_PATH/default/quickshell/omarchy-shell" omarchy-toggle bar-off diff --git a/bin/omarchy-toggle-idle b/bin/omarchy-toggle-idle index b81ab84b..83398a33 100755 --- a/bin/omarchy-toggle-idle +++ b/bin/omarchy-toggle-idle @@ -12,4 +12,4 @@ fi pkill -RTMIN+9 waybar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -quickshell ipc -p "$OMARCHY_PATH/default/quickshell/bar" call bar refreshIndicators >/dev/null 2>&1 || true +quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshIndicators >/dev/null 2>&1 || true diff --git a/bin/omarchy-toggle-notification-silencing b/bin/omarchy-toggle-notification-silencing index 8045814f..c865b982 100755 --- a/bin/omarchy-toggle-notification-silencing +++ b/bin/omarchy-toggle-notification-silencing @@ -12,4 +12,4 @@ fi pkill -RTMIN+10 waybar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -quickshell ipc -p "$OMARCHY_PATH/default/quickshell/bar" call bar refreshIndicators >/dev/null 2>&1 || true +quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshIndicators >/dev/null 2>&1 || true diff --git a/bin/omarchy-update-available-reset b/bin/omarchy-update-available-reset index 282c1e14..da0c1c4e 100755 --- a/bin/omarchy-update-available-reset +++ b/bin/omarchy-update-available-reset @@ -4,5 +4,5 @@ pkill -RTMIN+7 waybar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -quickshell ipc -p "$OMARCHY_PATH/default/quickshell/bar" call bar refreshUpdate >/dev/null 2>&1 || true +quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshUpdate >/dev/null 2>&1 || true exit 0 diff --git a/bin/omarchy-voxtype-install b/bin/omarchy-voxtype-install index cdcfe22a..f94c6a23 100755 --- a/bin/omarchy-voxtype-install +++ b/bin/omarchy-voxtype-install @@ -22,7 +22,7 @@ if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then omarchy-restart-waybar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} - if quickshell list -p "$OMARCHY_PATH/default/quickshell/bar" 2>/dev/null | grep -q '^Instance '; then + if quickshell list -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" 2>/dev/null | grep -q '^Instance '; then omarchy-restart-bar fi notify-send " Voxtype Dictation Ready" "Hold F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000 diff --git a/bin/omarchy-voxtype-model b/bin/omarchy-voxtype-model index 65619584..1bc7e9e5 100755 --- a/bin/omarchy-voxtype-model +++ b/bin/omarchy-voxtype-model @@ -7,6 +7,6 @@ set -e omarchy-launch-floating-terminal-with-presentation "voxtype setup model" omarchy-restart-waybar OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -if quickshell list -p "$OMARCHY_PATH/default/quickshell/bar" 2>/dev/null | grep -q '^Instance '; then +if quickshell list -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" 2>/dev/null | grep -q '^Instance '; then omarchy-restart-bar fi diff --git a/default/hypr/autostart.lua b/default/hypr/autostart.lua index 1e3ead8a..61ae1ced 100644 --- a/default/hypr/autostart.lua +++ b/default/hypr/autostart.lua @@ -1,7 +1,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("uwsm-app -- hypridle") hl.exec_cmd("uwsm-app -- mako") - hl.exec_cmd("! omarchy-toggle-enabled bar-off && uwsm-app -- env OMARCHY_PATH=$HOME/.local/share/omarchy quickshell -p $HOME/.local/share/omarchy/default/quickshell/bar") + hl.exec_cmd("! omarchy-toggle-enabled bar-off && uwsm-app -- env OMARCHY_PATH=$HOME/.local/share/omarchy quickshell -p $HOME/.local/share/omarchy/default/quickshell/omarchy-shell") hl.exec_cmd("uwsm-app -- fcitx5 --disable notificationitem") hl.exec_cmd("uwsm-app -- swaybg -i ~/.config/omarchy/current/background -m fill") hl.exec_cmd("/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1") diff --git a/default/quickshell/bar-settings/shell.qml b/default/quickshell/bar-settings/shell.qml index 6428f2a3..a27145da 100644 --- a/default/quickshell/bar-settings/shell.qml +++ b/default/quickshell/bar-settings/shell.qml @@ -18,7 +18,7 @@ ShellRoot { } property string omarchyPath: deriveOmarchyPath() readonly property string userConfigPath: home + "/.config/omarchy/bar.json" - readonly property string defaultsPath: omarchyPath + "/default/quickshell/bar/bar-defaults.json" + readonly property string defaultsPath: omarchyPath + "/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json" property color foreground: "#cacccc" property color background: "#101315" @@ -29,7 +29,7 @@ ShellRoot { // Bundled fallback so 'Reset to defaults' never produces an empty bar even // if bar-defaults.json fails to load. Keep in rough sync with the layout - // shipped in default/quickshell/bar/bar-defaults.json. + // shipped in default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json. readonly property var builtinBarConfig: ({ position: "top", fontFamily: "JetBrainsMono Nerd Font", diff --git a/default/quickshell/bar/shell.qml b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml similarity index 87% rename from default/quickshell/bar/shell.qml rename to default/quickshell/omarchy-shell/plugins/bar/Bar.qml index 89042a2e..ae0d0ec8 100644 --- a/default/quickshell/bar/shell.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml @@ -10,22 +10,15 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts -ShellRoot { +import "../../services" as Services + +Item { id: root + // The omarchy-shell host injects omarchyPath when it instantiates this Bar. + // Default fallback keeps the file loadable in isolation (e.g. for QML tooling). + required property string omarchyPath property string home: Quickshell.env("HOME") - // Derive omarchyPath from the loaded shell.qml's directory so the bar finds - // its own widgets/defaults regardless of where it was launched. shellDir is - // `/default/quickshell/bar` so strip three trailing segments. - function deriveOmarchyPath() { - var env = Quickshell.env("OMARCHY_PATH") - if (env) return env - var dir = String(Quickshell.shellDir || "") - if (dir.indexOf("/default/quickshell/bar") !== -1) - return dir.substring(0, dir.indexOf("/default/quickshell/bar")) - return home + "/.local/share/omarchy" - } - property string omarchyPath: deriveOmarchyPath() property string omarchyConfigDir: home + "/.config/omarchy" property var builtinBarConfig: ({ position: "top", @@ -305,33 +298,69 @@ ShellRoot { return source ? fileUrl(source) : "" } - readonly property var firstPartyWidgets: ({ - "media": true, - "audioPanel": true, - "networkPanel": true, - "bluetoothPanel": true, - "calendar": true, - "notificationCenter": true, - "brightness": true, - "powerProfile": true, - "systemStats": true, - "weatherFlyout": true, - "workspacesPro": true, - "powerMenu": true, - "idleInhibitor": true, - "microphone": true, - "notificationCenter": true, - "activeWindow": true, - "nightLight": true, - "keyboardLayout": true, - "lockKeys": true, - "spacer": true, - "controlCenter": true + // First-party widgets are registered with the BarWidgetRegistry at startup. + // Each entry maps a widget id to its display metadata; the QML source lives + // at widgets/.qml and is loaded asynchronously via Qt.createComponent. + readonly property var firstPartyWidgetMetadata: ({ + "media": { displayName: "Media", description: "MPRIS now-playing with playback controls", category: "Media", allowMultiple: false }, + "audioPanel": { displayName: "Audio", description: "Volume slider, output picker, per-app mixer", category: "Audio", allowMultiple: false }, + "networkPanel": { displayName: "Network", description: "Wi-Fi list and connection state", category: "Network", allowMultiple: false }, + "bluetoothPanel": { displayName: "Bluetooth", description: "Bluetooth device list with connect/disconnect", category: "Network", allowMultiple: false }, + "calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false }, + "notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND (replaces mako)", category: "Status", allowMultiple: false }, + "brightness": { displayName: "Brightness", description: "Screen brightness slider", category: "System", allowMultiple: false }, + "powerProfile": { displayName: "Power profile", description: "power-profiles-daemon selector", category: "System", allowMultiple: false }, + "systemStats": { displayName: "System stats", description: "Inline CPU + memory sparklines", category: "System", allowMultiple: false }, + "weatherFlyout": { displayName: "Weather", description: "Weather pill with detail popup", category: "Info", allowMultiple: false }, + "workspacesPro": { displayName: "Workspaces", description: "Animated workspace switcher", category: "Compositor", allowMultiple: false }, + "powerMenu": { displayName: "Power menu", description: "Lock / suspend / reboot / shutdown", category: "System", allowMultiple: false }, + "idleInhibitor": { displayName: "Keep awake", description: "Toggle idle inhibitor", category: "System", allowMultiple: false }, + "microphone": { displayName: "Microphone", description: "Mic input state and mute toggle", category: "Audio", allowMultiple: false }, + "activeWindow": { displayName: "Active window", description: "Title of the focused window", category: "Compositor", allowMultiple: false }, + "nightLight": { displayName: "Night light", description: "hyprsunset toggle", category: "System", allowMultiple: false }, + "keyboardLayout": { displayName: "Keyboard layout", description: "Current xkb layout, click cycles", category: "Compositor", allowMultiple: false }, + "lockKeys": { displayName: "Lock keys", description: "Caps / Num / Scroll lock indicators", category: "System", allowMultiple: false }, + "spacer": { displayName: "Spacer", description: "Configurable blank space", category: "Layout", allowMultiple: true }, + "controlCenter": { displayName: "Quick settings", description: "Volume / brightness / DND in one popup", category: "System", allowMultiple: false } }) - function firstPartyWidgetSource(name) { - if (!firstPartyWidgets[String(name)]) return "" - return Qt.resolvedUrl("widgets/" + String(name) + ".qml") + property var registeredFirstPartyComponents: ({}) + + Component.onCompleted: registerFirstPartyWidgets() + + function registerFirstPartyWidgets() { + var ids = Object.keys(firstPartyWidgetMetadata) + for (var i = 0; i < ids.length; i++) { + var id = ids[i] + if (Services.BarWidgetRegistry.has(id)) continue + registerOneFirstPartyWidget(id) + } + } + + function registerOneFirstPartyWidget(id) { + var url = Qt.resolvedUrl("widgets/" + id + ".qml") + var meta = firstPartyWidgetMetadata[id] || {} + var enrichedMeta = { + displayName: meta.displayName || id, + description: meta.description || "", + category: meta.category || "Misc", + allowMultiple: meta.allowMultiple === true, + source: "first-party" + } + var comp = Qt.createComponent(url, Component.Asynchronous) + function finalize() { + if (comp.status === Component.Ready) { + Services.BarWidgetRegistry.register(id, comp, enrichedMeta) + var next = ({}) + for (var k in registeredFirstPartyComponents) next[k] = registeredFirstPartyComponents[k] + next[id] = comp + registeredFirstPartyComponents = next + } else if (comp.status === Component.Error) { + console.warn("first-party widget " + id + " failed to load: " + comp.errorString()) + } + } + if (comp.status === Component.Loading) comp.statusChanged.connect(finalize) + else finalize() } function networkCommand() { @@ -1146,11 +1175,22 @@ ShellRoot { readonly property var moduleSettings: root.entrySettings(entry) readonly property string customType: root.customModuleType(entry) readonly property var builtinComponent: customType ? null : root.builtinModuleComponent(moduleName) - readonly property string firstPartySource: customType || builtinComponent ? "" : root.firstPartyWidgetSource(moduleName) + // Re-evaluate when the registry mutates (Component reference changes, + // plugin enabled/disabled, etc.). Reading the `widgets` property creates + // the binding dependency — the wrapped function call alone wouldn't. + readonly property var registryComponent: { + var w = Services.BarWidgetRegistry.widgets + if (customType || builtinComponent) return null + return w[moduleName] ? w[moduleName].component : null + } readonly property bool qmlCustom: customType === "qml" readonly property bool commandCustom: customType === "command" - readonly property bool firstParty: firstPartySource !== "" - readonly property var activeItem: qmlCustom || firstParty ? qmlLoader.item : componentLoader.item + readonly property bool registered: registryComponent !== null + readonly property var activeItem: { + if (registered) return registryLoader.item + if (qmlCustom) return qmlLoader.item + return componentLoader.item + } implicitWidth: activeItem && activeItem.visible ? activeItem.implicitWidth : 0 implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0 @@ -1159,15 +1199,23 @@ ShellRoot { Loader { id: componentLoader - active: !slot.qmlCustom && !slot.firstParty + active: !slot.qmlCustom && !slot.registered sourceComponent: slot.builtinComponent || (slot.commandCustom ? customCommandModuleComponent : emptyModuleComponent) anchors.fill: parent } + Loader { + id: registryLoader + active: slot.registered + sourceComponent: slot.registered ? slot.registryComponent : null + anchors.fill: parent + onLoaded: slot.injectProps() + } + Loader { id: qmlLoader - active: slot.qmlCustom || slot.firstParty - source: slot.qmlCustom ? root.customModuleSource(slot.entry) : (slot.firstParty ? slot.firstPartySource : "") + active: slot.qmlCustom + source: slot.qmlCustom ? root.customModuleSource(slot.entry) : "" anchors.fill: parent onLoaded: slot.injectProps() } @@ -1175,7 +1223,7 @@ ShellRoot { onModuleSettingsChanged: injectProps() function injectProps() { - var target = qmlLoader.item + var target = registryLoader.item || qmlLoader.item if (!target) return if ("bar" in target) target.bar = root if ("moduleName" in target) target.moduleName = moduleName diff --git a/default/quickshell/bar/README.md b/default/quickshell/omarchy-shell/plugins/bar/README.md similarity index 93% rename from default/quickshell/bar/README.md rename to default/quickshell/omarchy-shell/plugins/bar/README.md index 09b80a50..8d36d79c 100644 --- a/default/quickshell/bar/README.md +++ b/default/quickshell/omarchy-shell/plugins/bar/README.md @@ -2,7 +2,7 @@ This is the Quickshell implementation of the Omarchy status bar. -- `shell.qml` is Omarchy-owned bar engine code. Users should not edit it directly. +- `Bar.qml` is Omarchy-owned bar engine code, loaded by the omarchy-shell host. Users should not edit it directly. - `bar-defaults.json` is the Omarchy-owned default layout and module settings. - `widgets/` holds first-party widgets — modular, interactive components shipped with Omarchy. - `common/` holds shared QML helpers (buttons, sliders, popup cards). @@ -11,7 +11,7 @@ This is the Quickshell implementation of the Omarchy status bar. ## Customizing -The bar reads `~/.local/share/omarchy/default/quickshell/bar/bar-defaults.json`, then deep-merges `~/.config/omarchy/bar.json` on top of it. Each `layout.{left,center,right}` entry is an object: at minimum `{ "id": "" }`, plus any inline settings the widget reads. +The bar reads `~/.local/share/omarchy/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json`, then deep-merges `~/.config/omarchy/bar.json` on top of it. Each `layout.{left,center,right}` entry is an object: at minimum `{ "id": "" }`, plus any inline settings the widget reads. Launch the visual editor with `omarchy launch bar-settings` (or run `omarchy-launch-bar-settings`) to reorder widgets, add/remove them, and tweak per-widget options without editing JSON by hand. diff --git a/default/quickshell/bar/bar-defaults.json b/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json similarity index 100% rename from default/quickshell/bar/bar-defaults.json rename to default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json diff --git a/default/quickshell/bar/common/PillButton.qml b/default/quickshell/omarchy-shell/plugins/bar/common/PillButton.qml similarity index 100% rename from default/quickshell/bar/common/PillButton.qml rename to default/quickshell/omarchy-shell/plugins/bar/common/PillButton.qml diff --git a/default/quickshell/bar/common/PopupCard.qml b/default/quickshell/omarchy-shell/plugins/bar/common/PopupCard.qml similarity index 100% rename from default/quickshell/bar/common/PopupCard.qml rename to default/quickshell/omarchy-shell/plugins/bar/common/PopupCard.qml diff --git a/default/quickshell/bar/common/Slider.qml b/default/quickshell/omarchy-shell/plugins/bar/common/Slider.qml similarity index 100% rename from default/quickshell/bar/common/Slider.qml rename to default/quickshell/omarchy-shell/plugins/bar/common/Slider.qml diff --git a/default/quickshell/bar/common/WidgetButton.qml b/default/quickshell/omarchy-shell/plugins/bar/common/WidgetButton.qml similarity index 100% rename from default/quickshell/bar/common/WidgetButton.qml rename to default/quickshell/omarchy-shell/plugins/bar/common/WidgetButton.qml diff --git a/default/quickshell/omarchy-shell/plugins/bar/manifest.json b/default/quickshell/omarchy-shell/plugins/bar/manifest.json new file mode 100644 index 00000000..1ce3b34e --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/bar/manifest.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "id": "omarchy.bar", + "name": "Bar", + "version": "1.0.0", + "author": "Omarchy", + "description": "Status bar with widgets", + "kinds": ["bar"], + "activation": "persistent", + "entryPoints": { "bar": "Bar.qml" } +} diff --git a/default/quickshell/bar/widgets/activeWindow.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/activeWindow.qml similarity index 100% rename from default/quickshell/bar/widgets/activeWindow.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/activeWindow.qml diff --git a/default/quickshell/bar/widgets/audioPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml similarity index 100% rename from default/quickshell/bar/widgets/audioPanel.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml diff --git a/default/quickshell/bar/widgets/bluetoothPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml similarity index 100% rename from default/quickshell/bar/widgets/bluetoothPanel.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml diff --git a/default/quickshell/bar/widgets/brightness.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/brightness.qml similarity index 100% rename from default/quickshell/bar/widgets/brightness.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/brightness.qml diff --git a/default/quickshell/bar/widgets/calendar.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/calendar.qml similarity index 100% rename from default/quickshell/bar/widgets/calendar.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/calendar.qml diff --git a/default/quickshell/bar/widgets/controlCenter.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml similarity index 100% rename from default/quickshell/bar/widgets/controlCenter.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml diff --git a/default/quickshell/bar/widgets/idleInhibitor.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/idleInhibitor.qml similarity index 100% rename from default/quickshell/bar/widgets/idleInhibitor.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/idleInhibitor.qml diff --git a/default/quickshell/bar/widgets/keyboardLayout.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/keyboardLayout.qml similarity index 100% rename from default/quickshell/bar/widgets/keyboardLayout.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/keyboardLayout.qml diff --git a/default/quickshell/bar/widgets/lockKeys.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/lockKeys.qml similarity index 100% rename from default/quickshell/bar/widgets/lockKeys.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/lockKeys.qml diff --git a/default/quickshell/bar/widgets/media.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/media.qml similarity index 100% rename from default/quickshell/bar/widgets/media.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/media.qml diff --git a/default/quickshell/bar/widgets/microphone.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/microphone.qml similarity index 100% rename from default/quickshell/bar/widgets/microphone.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/microphone.qml diff --git a/default/quickshell/bar/widgets/networkPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml similarity index 100% rename from default/quickshell/bar/widgets/networkPanel.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml diff --git a/default/quickshell/bar/widgets/nightLight.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/nightLight.qml similarity index 100% rename from default/quickshell/bar/widgets/nightLight.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/nightLight.qml diff --git a/default/quickshell/bar/widgets/notificationCenter.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/notificationCenter.qml similarity index 100% rename from default/quickshell/bar/widgets/notificationCenter.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/notificationCenter.qml diff --git a/default/quickshell/bar/widgets/powerMenu.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/powerMenu.qml similarity index 100% rename from default/quickshell/bar/widgets/powerMenu.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/powerMenu.qml diff --git a/default/quickshell/bar/widgets/powerProfile.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/powerProfile.qml similarity index 100% rename from default/quickshell/bar/widgets/powerProfile.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/powerProfile.qml diff --git a/default/quickshell/bar/widgets/spacer.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/spacer.qml similarity index 100% rename from default/quickshell/bar/widgets/spacer.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/spacer.qml diff --git a/default/quickshell/bar/widgets/systemStats.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/systemStats.qml similarity index 100% rename from default/quickshell/bar/widgets/systemStats.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/systemStats.qml diff --git a/default/quickshell/bar/widgets/weatherFlyout.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/weatherFlyout.qml similarity index 100% rename from default/quickshell/bar/widgets/weatherFlyout.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/weatherFlyout.qml diff --git a/default/quickshell/bar/widgets/workspacesPro.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/workspacesPro.qml similarity index 100% rename from default/quickshell/bar/widgets/workspacesPro.qml rename to default/quickshell/omarchy-shell/plugins/bar/widgets/workspacesPro.qml diff --git a/default/quickshell/omarchy-shell/services/BarWidgetRegistry.qml b/default/quickshell/omarchy-shell/services/BarWidgetRegistry.qml new file mode 100644 index 00000000..7903da54 --- /dev/null +++ b/default/quickshell/omarchy-shell/services/BarWidgetRegistry.qml @@ -0,0 +1,52 @@ +pragma Singleton + +import QtQuick + +QtObject { + id: registry + + // { widgetId: { component: Component, metadata: var } } + property var widgets: ({}) + property int revision: 0 + + signal changed() + + function register(id, component, metadata) { + var key = String(id) + if (!key) return + var next = {} + for (var k in widgets) next[k] = widgets[k] + next[key] = { component: component, metadata: metadata || {} } + widgets = next + revision++ + changed() + } + + function unregister(id) { + var key = String(id) + if (!widgets[key]) return + var next = {} + for (var k in widgets) if (k !== key) next[k] = widgets[k] + widgets = next + revision++ + changed() + } + + function componentFor(id) { + var entry = widgets[String(id)] + return entry ? entry.component : null + } + + function metadataFor(id) { + var entry = widgets[String(id)] + return entry ? entry.metadata : null + } + + function availableIds() { + return Object.keys(widgets) + } + + function has(id) { + return widgets[String(id)] !== undefined + } +} diff --git a/default/quickshell/omarchy-shell/services/PluginRegistry.qml b/default/quickshell/omarchy-shell/services/PluginRegistry.qml new file mode 100644 index 00000000..d7a39810 --- /dev/null +++ b/default/quickshell/omarchy-shell/services/PluginRegistry.qml @@ -0,0 +1,248 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +QtObject { + id: registry + + property string home: Quickshell.env("HOME") + property string pluginsDir: home + "/.config/omarchy/plugins" + property string stateFile: home + "/.config/omarchy/plugins.json" + + // Set by shell.qml at startup so we can also scan bundled first-party plugins. + property string firstPartyDir: "" + + // { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in. + property var installedPlugins: ({}) + // { pluginId: { enabled: bool } } — persisted to stateFile. + property var pluginStates: ({}) + property int registryRevision: 0 + property bool scanning: false + + signal pluginsChanged() + signal pluginLoadFailed(string id, string error) + + // ---------------------------------------------------------------- helpers + + function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) + } + + function fileUrl(path) { + return "file://" + String(path).split("/").map(encodeURIComponent).join("/") + } + + function validateManifest(manifest, sourcePath) { + if (!isPlainObject(manifest)) { + console.warn("PluginRegistry: manifest is not an object at " + sourcePath) + return null + } + if (manifest.schemaVersion !== 1) { + console.warn("PluginRegistry: unsupported schemaVersion at " + sourcePath) + return null + } + var required = ["id", "name", "version", "kinds", "entryPoints"] + for (var i = 0; i < required.length; i++) { + if (manifest[required[i]] === undefined) { + console.warn("PluginRegistry: missing required field '" + required[i] + "' at " + sourcePath) + return null + } + } + var id = String(manifest.id) + if (!id || id.indexOf("/") !== -1 || id.indexOf("..") !== -1 || id.charAt(0) === "/") { + console.warn("PluginRegistry: invalid plugin id '" + id + "' at " + sourcePath) + return null + } + if (!Array.isArray(manifest.kinds) || manifest.kinds.length === 0) { + console.warn("PluginRegistry: kinds must be a non-empty array at " + sourcePath) + return null + } + if (!isPlainObject(manifest.entryPoints)) { + console.warn("PluginRegistry: entryPoints must be an object at " + sourcePath) + return null + } + return manifest + } + + function entryPointUrl(manifest, kind) { + if (!isPlainObject(manifest)) return "" + var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null + if (!ep) return "" + var dir = manifest.__sourceDir || "" + if (!dir) return "" + return fileUrl(dir + "/" + ep) + } + + function isEnabled(id) { + var state = pluginStates[String(id)] + return !!(state && state.enabled) + } + + function setEnabled(id, value) { + var key = String(id) + var next = {} + for (var k in pluginStates) next[k] = pluginStates[k] + next[key] = { enabled: !!value } + pluginStates = next + persistStates() + registryRevision++ + pluginsChanged() + } + + function manifestsOfKind(kind) { + var result = [] + for (var id in installedPlugins) { + var m = installedPlugins[id] + if (m && Array.isArray(m.kinds) && m.kinds.indexOf(kind) !== -1) result.push(m) + } + return result + } + + // ---------------------------------------------------------------- persistence + + property bool suppressStateReload: false + + function persistStates() { + suppressStateReload = true + stateFileView.setText(JSON.stringify({ version: 1, states: pluginStates }, null, 2) + "\n") + } + + property FileView stateFileView: FileView { + path: registry.stateFile + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: { + if (registry.suppressStateReload) { + registry.suppressStateReload = false + return + } + try { + var data = JSON.parse(text() || "{}") + registry.pluginStates = (data && data.states) || {} + } catch (e) { + console.warn("PluginRegistry: bad plugins.json:", e) + registry.pluginStates = {} + } + } + onFileChanged: reload() + } + + // ---------------------------------------------------------------- scanning + + // Output format produced by the rescan script: + // ===::=== + // ... raw manifest.json content ... + // === EOM === + // (repeating for every manifest found) + function parseScanOutput(text) { + var lines = String(text || "").split("\n") + var firstParty = {} + var thirdParty = {} + var currentSource = null + var currentKind = null + var currentJson = [] + + function flush() { + if (!currentSource) return + var raw = currentJson.join("\n").trim() + try { + var manifest = JSON.parse(raw) + manifest.__sourceDir = currentSource + manifest.__isFirstParty = (currentKind === "firstparty") + var validated = validateManifest(manifest, currentSource + "/manifest.json") + if (validated) { + if (currentKind === "firstparty") firstParty[validated.id] = validated + else thirdParty[validated.id] = validated + } + } catch (e) { + console.warn("PluginRegistry: bad manifest at " + currentSource + ": " + e) + } + currentSource = null + currentKind = null + currentJson = [] + } + + for (var i = 0; i < lines.length; i++) { + var line = lines[i] + var startMatch = line.match(/^===([a-z]+)::(.+)===$/) + if (startMatch) { + flush() + currentKind = startMatch[1] + currentSource = startMatch[2].replace(/\/$/, "") + currentJson = [] + continue + } + if (line === "=== EOM ===") { + flush() + continue + } + if (currentSource) currentJson.push(line) + } + flush() + + // Merge defaults into pluginStates: first-party defaults to enabled, third-party to disabled. + var nextStates = {} + for (var k in pluginStates) nextStates[k] = pluginStates[k] + for (var fpid in firstParty) { + if (!nextStates[fpid]) nextStates[fpid] = { enabled: true } + } + for (var tpid in thirdParty) { + if (!nextStates[tpid]) nextStates[tpid] = { enabled: false } + } + + var merged = {} + for (var fk in firstParty) merged[fk] = firstParty[fk] + for (var tk in thirdParty) merged[tk] = thirdParty[tk] + + pluginStates = nextStates + installedPlugins = merged + registryRevision++ + scanning = false + pluginsChanged() + } + + property Process scanProcess: Process { + onExited: function(exitCode) { + var output = scanStdout.text || "" + registry.parseScanOutput(output) + } + stdout: StdioCollector { + id: scanStdout + waitForEnd: true + } + } + + property Process initProcess: Process { + onExited: registry.rescan() + } + + function rescan() { + if (scanning) return + scanning = true + // $0 = first-party dir, $1 = third-party dir. Some bash versions need the explicit -- separator. + var script = "" + + "scan() { local dir=\"$1\"; local kind=\"$2\"; " + + " [[ -d \"$dir\" ]] || return 0; " + + " for sub in \"$dir\"/*/; do " + + " [[ -f \"$sub/manifest.json\" ]] || continue; " + + " printf '===%s::%s===\\n' \"$kind\" \"$sub\"; " + + " cat \"$sub/manifest.json\"; " + + " printf '\\n=== EOM ===\\n'; " + + " done; " + + "}; " + + "scan \"$0\" firstparty; " + + "scan \"$1\" thirdparty" + scanProcess.command = ["bash", "-lc", script, registry.firstPartyDir, registry.pluginsDir] + scanProcess.running = true + } + + function ensureUserDir() { + initProcess.command = ["bash", "-lc", "mkdir -p \"$0\"", registry.pluginsDir] + initProcess.running = true + } + + Component.onCompleted: ensureUserDir() +} diff --git a/default/quickshell/omarchy-shell/services/qmldir b/default/quickshell/omarchy-shell/services/qmldir new file mode 100644 index 00000000..8a925b66 --- /dev/null +++ b/default/quickshell/omarchy-shell/services/qmldir @@ -0,0 +1,3 @@ +module omarchy.services +singleton PluginRegistry 1.0 PluginRegistry.qml +singleton BarWidgetRegistry 1.0 BarWidgetRegistry.qml diff --git a/default/quickshell/omarchy-shell/shell.qml b/default/quickshell/omarchy-shell/shell.qml new file mode 100644 index 00000000..0552f995 --- /dev/null +++ b/default/quickshell/omarchy-shell/shell.qml @@ -0,0 +1,160 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +import "plugins/bar" +import "services" as Services + +ShellRoot { + id: shell + + property string home: Quickshell.env("HOME") + + // The omarchy-shell host is the long-running entry point. Plugins live in + // sibling directories under plugins/. Most child code (the bar, future + // plugins) accept omarchyPath as a required property; resolve it once here + // from the shellDir so we don't depend on OMARCHY_PATH being set in the env. + function deriveOmarchyPath() { + var env = Quickshell.env("OMARCHY_PATH") + if (env) return env + var dir = String(Quickshell.shellDir || "") + if (dir.indexOf("/default/quickshell/omarchy-shell") !== -1) + return dir.substring(0, dir.indexOf("/default/quickshell/omarchy-shell")) + return home + "/.local/share/omarchy" + } + property string omarchyPath: deriveOmarchyPath() + readonly property string firstPartyPluginsDir: omarchyPath + "/default/quickshell/omarchy-shell/plugins" + + Component.onCompleted: { + console.log("omarchy-shell paths", + "omarchyPath=" + shell.omarchyPath, + "shellDir=" + Quickshell.shellDir, + "firstPartyPluginsDir=" + shell.firstPartyPluginsDir) + Services.PluginRegistry.firstPartyDir = shell.firstPartyPluginsDir + // PluginRegistry.ensureUserDir() runs in its own Component.onCompleted and + // chains rescan() once the directory exists. We also kick a scan here in + // case the user dir already existed at startup. + Services.PluginRegistry.rescan() + } + + Bar { + id: bar + omarchyPath: shell.omarchyPath + } + + // ---------------------------------------------------------- plugin loader + + // Mirror plugin registry state into BarWidgetRegistry whenever it changes. + // Each enabled plugin with kind "bar-widget" gets a Component created from + // its manifest entry point and registered under the id "plugin:". + Connections { + target: Services.PluginRegistry + function onPluginsChanged() { shell.syncPluginWidgets() } + } + + property var pluginWidgetComponents: ({}) + + function syncPluginWidgets() { + var plugins = Services.PluginRegistry.installedPlugins + var seen = ({}) + + for (var pluginId in plugins) { + var manifest = plugins[pluginId] + if (!manifest || !manifest.kinds || manifest.kinds.indexOf("bar-widget") === -1) continue + if (!Services.PluginRegistry.isEnabled(pluginId)) continue + + var registryKey = "plugin:" + manifest.id + seen[registryKey] = true + + // Already loaded with matching source — leave it alone. + var existing = pluginWidgetComponents[registryKey] + var url = Services.PluginRegistry.entryPointUrl(manifest, "barWidget") + if (!url) { + console.warn("Plugin " + manifest.id + " has no barWidget entry point") + continue + } + if (existing && existing.url === url && Services.BarWidgetRegistry.has(registryKey)) continue + + var meta = manifest.barWidget || {} + meta = { + displayName: meta.displayName || manifest.name, + description: meta.description || manifest.description, + category: meta.category || "Plugin", + allowMultiple: meta.allowMultiple === true, + defaults: meta.defaults || {}, + schema: meta.schema || [], + pluginId: manifest.id, + source: "plugin" + } + + loadPluginWidget(registryKey, url, meta) + } + + // Drop registrations for plugins that are no longer present or enabled. + var allIds = Services.BarWidgetRegistry.availableIds() + for (var i = 0; i < allIds.length; i++) { + var id = allIds[i] + if (id.indexOf("plugin:") !== 0) continue + if (!seen[id]) { + Services.BarWidgetRegistry.unregister(id) + var next = ({}) + for (var k in pluginWidgetComponents) if (k !== id) next[k] = pluginWidgetComponents[k] + pluginWidgetComponents = next + } + } + } + + function loadPluginWidget(registryKey, url, meta) { + var comp = Qt.createComponent(url, Component.Asynchronous) + function finalize() { + if (comp.status === Component.Ready) { + Services.BarWidgetRegistry.register(registryKey, comp, meta) + var next = ({}) + for (var k in pluginWidgetComponents) next[k] = pluginWidgetComponents[k] + next[registryKey] = { url: url, component: comp } + pluginWidgetComponents = next + } else if (comp.status === Component.Error) { + console.warn("Plugin widget " + registryKey + " failed: " + comp.errorString()) + Services.PluginRegistry.pluginLoadFailed(registryKey, comp.errorString()) + } + } + if (comp.status === Component.Loading) { + comp.statusChanged.connect(finalize) + } else { + finalize() + } + } + + // ---------------------------------------------------------- shell IPC + + IpcHandler { + target: "shell" + + function ping(): string { + return "ok" + } + + function rescanPlugins(): void { + Services.PluginRegistry.rescan() + } + + function setPluginEnabled(id: string, enabled: string): void { + Services.PluginRegistry.setEnabled(id, enabled === "true") + } + + function listPlugins(): string { + var out = [] + var plugins = Services.PluginRegistry.installedPlugins + for (var id in plugins) { + out.push({ + id: id, + name: plugins[id].name, + kinds: plugins[id].kinds, + enabled: Services.PluginRegistry.isEnabled(id), + firstParty: !!plugins[id].__isFirstParty + }) + } + return JSON.stringify(out) + } + } +}