diff --git a/bin/omarchy-font-current b/bin/omarchy-font-current index fb032e9b..a52ed821 100755 --- a/bin/omarchy-font-current +++ b/bin/omarchy-font-current @@ -3,10 +3,10 @@ # omarchy:summary=Show current monospace font # omarchy:examples=omarchy font current -bar_config="$HOME/.config/omarchy/bar.json" +shell_config="$HOME/.config/omarchy/shell.json" -if [[ -f $bar_config ]]; then - font_family=$(python3 - "$bar_config" <<'PY' +if [[ -f $shell_config ]]; then + font_family=$(python3 - "$shell_config" <<'PY' import json import sys @@ -16,7 +16,8 @@ try: except (FileNotFoundError, json.JSONDecodeError): data = {} -font = data.get("fontFamily") if isinstance(data, dict) else None +bar = data.get("bar") if isinstance(data, dict) else None +font = bar.get("fontFamily") if isinstance(bar, dict) else None if font: print(font) PY diff --git a/bin/omarchy-font-set b/bin/omarchy-font-set index 6f2d4f31..56f21051 100755 --- a/bin/omarchy-font-set +++ b/bin/omarchy-font-set @@ -29,7 +29,7 @@ if [[ -n $font_name ]]; then sed -i "s/font_family = .*/font_family = $font_name/g" ~/.config/hypr/hyprlock.conf sed -i "s/font-family: .*/font-family: '$font_name';/g" ~/.config/waybar/style.css mkdir -p "$HOME/.config/omarchy" - python3 - "$HOME/.config/omarchy/bar.json" "$font_name" <<'PY' + python3 - "$HOME/.config/omarchy/shell.json" "$font_name" <<'PY' import json import sys @@ -42,7 +42,12 @@ try: except (FileNotFoundError, json.JSONDecodeError): data = {} -data["fontFamily"] = font_name +data.setdefault("version", 1) +bar = data.get("bar") +if not isinstance(bar, dict): + bar = {} + data["bar"] = bar +bar["fontFamily"] = font_name with open(path, "w") as file: json.dump(data, file, indent=2) diff --git a/bin/omarchy-refresh-bar b/bin/omarchy-refresh-bar index 8d57fbb9..b2e2f03a 100755 --- a/bin/omarchy-refresh-bar +++ b/bin/omarchy-refresh-bar @@ -1,17 +1,20 @@ #!/bin/bash -# omarchy:summary=Reset bar user overrides to Omarchy defaults +# omarchy:summary=Reset shell.json to Omarchy defaults # omarchy:examples=omarchy refresh bar set -e -OMARCHY_PATH="${OMARCHY_PATH:-$HOME/.local/share/omarchy}" -DEFAULT_CONFIG="$OMARCHY_PATH/config/omarchy/bar.json" +USER_CONFIG="$HOME/.config/omarchy/shell.json" -if [[ ! -f $DEFAULT_CONFIG ]]; then - echo "Missing default bar config at $DEFAULT_CONFIG" >&2 - exit 1 +# Removing the user file lets the shell fall back to shell-defaults.json on +# its next reload. omarchy-restart-bar (which is now an alias for restarting +# omarchy-shell) reloads the shell so the change takes effect immediately. +if [[ -f $USER_CONFIG ]]; then + backup="$USER_CONFIG.bak.$(date +%s)" + cp "$USER_CONFIG" "$backup" + echo "Backed up user shell config to $backup" + rm -f "$USER_CONFIG" fi -omarchy-refresh-config omarchy/bar.json omarchy-restart-bar diff --git a/bin/omarchy-shell-ipc b/bin/omarchy-shell-ipc index 6533ebb4..fe83401b 100755 --- a/bin/omarchy-shell-ipc +++ b/bin/omarchy-shell-ipc @@ -14,6 +14,7 @@ Examples: omarchy-shell-ipc shell summon omarchy.bar-settings "{}" omarchy-shell-ipc shell hide omarchy.image-picker omarchy-shell-ipc shell listPlugins + omarchy-shell-ipc shell listShellConfig omarchy-shell-ipc shell rescanPlugins omarchy-shell-ipc image-selector ping omarchy-shell-ipc image-selector cancel "" diff --git a/bin/omarchy-style-bar-position b/bin/omarchy-style-bar-position index 1e84d745..4cea7539 100755 --- a/bin/omarchy-style-bar-position +++ b/bin/omarchy-style-bar-position @@ -6,7 +6,7 @@ set -e -CONFIG_FILE="$HOME/.config/omarchy/bar.json" +CONFIG_FILE="$HOME/.config/omarchy/shell.json" position=$1 if [[ ! $position =~ ^(top|bottom|left|right)$ ]]; then @@ -17,7 +17,6 @@ fi mkdir -p "$(dirname "$CONFIG_FILE")" python3 - "$CONFIG_FILE" "$position" <<'PY' import json -import os import sys path, position = sys.argv[1], sys.argv[2] @@ -29,7 +28,12 @@ try: except (FileNotFoundError, json.JSONDecodeError): data = {} -data["position"] = position +data.setdefault("version", 1) +bar = data.get("bar") +if not isinstance(bar, dict): + bar = {} + data["bar"] = bar +bar["position"] = position with open(path, "w") as file: json.dump(data, file, indent=2) diff --git a/default/quickshell/omarchy-shell/Commons/Color.qml b/default/quickshell/omarchy-shell/Commons/Color.qml new file mode 100644 index 00000000..ff0ded28 --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/Color.qml @@ -0,0 +1,111 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// Noctalia compat shim. Plugins import `qs.Commons` and reach for these +// palette tokens / helpers. Values stream from the Omarchy theme file; the +// shell wires this singleton up at startup via setHostBar()/setHostShell(). +// +// We don't try to replicate Noctalia's full Material You resolver — for the +// fields plugins actually read, a flat token table is enough. +QtObject { + id: root + + // Live updated from theme/colors.toml via the FileView below. + property color foreground: "#cacccc" + property color background: "#101315" + property color accent: "#cacccc" + property color urgent: "#a55555" + + // Noctalia palette tokens. We map them onto our theme colors. + readonly property color mPrimary: accent + readonly property color mSecondary: Qt.darker(accent, 1.2) + readonly property color mTertiary: Qt.lighter(accent, 1.3) + readonly property color mSurface: background + readonly property color mSurfaceVariant: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.06) + readonly property color mOnSurface: foreground + readonly property color mOnSurfaceVariant: Qt.darker(foreground, 1.4) + readonly property color mOutline: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18) + readonly property color mHover: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.14) + readonly property color mOnHover: foreground + readonly property color mError: urgent + readonly property color mOnError: background + + // Plugins read this to know whether to skip mid-flight transitions. We + // don't ship theme transitions ourselves, so it's always false. + readonly property bool isTransitioning: false + + function alpha(c, opacity) { + if (!c) return Qt.rgba(0, 0, 0, opacity) + return Qt.rgba(c.r, c.g, c.b, opacity) + } + + // Noctalia's smartAlpha picks an alpha based on the host theme's perceived + // contrast. A flat 0.6 reads acceptably across our themes; cheap, no math. + function smartAlpha(c) { + return alpha(c, 0.6) + } + + // adaptiveOpacity takes a 0..1 ratio and clamps it. Plugins use it for + // fade animations relative to a "full" opacity value. + function adaptiveOpacity(value) { + if (value === undefined || value === null) return 1.0 + return Math.max(0, Math.min(1, Number(value))) + } + + // Plugins occasionally pass a color key like "accent" or "primary" via + // their own settings. resolveColorKey returns a color; resolveColorKeyOptional + // returns null/undefined-ish for "none". + function resolveColorKey(key) { + var resolved = resolveColorKeyOptional(key) + return resolved === null ? foreground : resolved + } + + function resolveColorKeyOptional(key) { + var k = String(key || "").toLowerCase() + if (!k || k === "none") return null + switch (k) { + case "primary": return mPrimary + case "secondary": return mSecondary + case "tertiary": return mTertiary + case "surface": return mSurface + case "surfacevariant": return mSurfaceVariant + case "onsurface": return mOnSurface + case "onsurfacevariant": return mOnSurfaceVariant + case "outline": return mOutline + case "hover": return mHover + case "onhover": return mOnHover + case "error": return mError + case "onerror": return mOnError + case "accent": return accent + case "foreground": return foreground + case "background": return background + case "urgent": + case "red": return urgent + } + // Anything else, treat as a literal CSS color string (the QML color type + // does this conversion implicitly when assigned). + return Qt.color(k) + } + + function loadTheme(raw) { + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var match = lines[i].match(/^\s*([A-Za-z0-9_-]+)\s*=\s*["']?(#[0-9A-Fa-f]{6})/) + if (!match) continue + if (match[1] === "foreground") foreground = match[2] + else if (match[1] === "background") background = match[2] + else if (match[1] === "color4" || match[1] === "accent") accent = match[2] + else if (match[1] === "red") urgent = match[2] + } + } + + property FileView themeFile: FileView { + path: Quickshell.env("HOME") + "/.config/omarchy/current/theme/colors.toml" + watchChanges: true + printErrors: false + onLoaded: root.loadTheme(text()) + onFileChanged: reload() + } +} diff --git a/default/quickshell/omarchy-shell/Commons/I18n.qml b/default/quickshell/omarchy-shell/Commons/I18n.qml new file mode 100644 index 00000000..732091b8 --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/I18n.qml @@ -0,0 +1,15 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. Their plugins normally route translations through +// pluginApi.tr(), not this singleton, so the stub here mostly exists to +// satisfy `import qs.Commons` style usage. +QtObject { + readonly property string langCode: "en" + + function tr(key, interp) { return String(key === undefined ? "" : key) } + function trp(key, count, interp) { return String(key === undefined ? "" : key) } + function hasTranslation(key) { return false } + + signal translationsLoaded() +} diff --git a/default/quickshell/omarchy-shell/Commons/Icons.qml b/default/quickshell/omarchy-shell/Commons/Icons.qml new file mode 100644 index 00000000..07211565 --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/Icons.qml @@ -0,0 +1,86 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. Their plugins resolve Tabler icon names to glyphs via +// this singleton. The original ships ~3000 mappings; for v1 we cover the +// names most commonly used by the plugins in their repo and fall back to +// "?" for unknowns so missing glyphs are visually obvious. +QtObject { + // Tabler icon names → Nerd Font glyphs. Most entries are mdi-* substitutes + // since Omarchy installs JetBrains Mono Nerd Font which includes Material + // Design Icons. Glyph codepoints embedded as actual chars (Python helper + // when the write tool strips multi-byte codepoints in some positions). + readonly property var glyphs: ({ + "bell": "\udb80\udc9a", // mdi-bell + "bell-off": "\udb80\udc9b", // mdi-bell_off + "bell-ring": "\udb80\udd6b", // mdi-bell_ring + "clock": "\udb80\udc50", // mdi-clock + "clock-outline": "\udb80\udc51", // mdi-clock_outline + "wifi": "\udb81\udda9", // mdi-wifi + "wifi-off": "\udb81\uddaa", // mdi-wifi_off + "ethernet": "\udb80\udc02", // mdi-ethernet + "bluetooth": "\udb80\udcaf", // mdi-bluetooth + "bluetooth-off": "\udb80\udcb2", // mdi-bluetooth_off + "volume": "\udb81\udd7e", // mdi-volume_high + "volume-up": "\udb81\udd7e", + "volume-down": "\udb81\udd7f", + "volume-off": "\udb83\udc08", // mdi-volume_variant_off + "volume-mute": "\udb83\udc08", + "headphones": "\udb80\udecb", // mdi-headphones + "microphone": "\udb80\udf6c", // mdi-microphone + "microphone-off": "\udb80\udf6d", // mdi-microphone_off + "play": "\udb81\udc0a", // mdi-play + "pause": "\udb80\udfe4", // mdi-pause + "skip-back": "\udb81\udcae", // mdi-skip_previous + "skip-forward": "\udb81\udcad", // mdi-skip_next + "music": "\udb81\udd5a", // mdi-music + "weather": "\udb81\udd99", // mdi-weather_sunny + "weather-sun": "\udb81\udd99", + "weather-moon": "\udb81\udd94", // mdi-weather_night + "weather-cloud": "\udb81\udd90", // mdi-weather_cloudy + "weather-rain": "\udb81\udd96", // mdi-weather_pouring + "battery": "\udb80\udc83", // mdi-battery + "battery-charging": "\udb80\udc84", + "cpu": "\udb83\udee0", // mdi-cpu_64_bit + "memory": "\udb80\udd5b", // mdi-memory + "tools": "\udb80\udd64", // mdi-tools + "settings": "\udb80\udc93", // mdi-cog + "settings-outline":"\udb80\udcbb", + "power": "\udb80\udc25", // mdi-power + "lock": "\udb80\udd3e", // mdi-lock + "unlock": "\udb80\udd3f", + "refresh": "\udb81\udc50", // mdi-refresh + "x": "\udb80\udd56", // mdi-close + "check": "\udb80\udc12", // mdi-check + "chevron-down": "\udb80\udd40", + "chevron-up": "\udb80\udd43", + "chevron-left": "\udb80\udd41", + "chevron-right": "\udb80\udd42", + "plus": "\udb80\udc15", + "minus": "\udb80\udc16", + "leaf": "\udb80\udf2a", + "rocket": "\udb81\udc63", + "balance": "\udb81\uddd1", + "moon": "\udb81\udd94", + "sun": "\udb81\udd99", + "trash": "\udb81\udcd7", // mdi-delete + "edit": "\udb80\udd66", + "search": "\udb80\udd6f", + "menu": "\udb80\udd6c", + "home": "\udb80\udf0c", + "folder": "\udb80\udd99", + "file": "\udb80\udd97", + "calendar": "\udb80\udcf7", // mdi-calendar + "circle": "\udb80\udd2f" + }) + + function get(name) { + var key = String(name || "").toLowerCase() + if (glyphs[key]) return glyphs[key] + return "?" + } + + function has(name) { + return !!glyphs[String(name || "").toLowerCase()] + } +} diff --git a/default/quickshell/omarchy-shell/Commons/Logger.qml b/default/quickshell/omarchy-shell/Commons/Logger.qml new file mode 100644 index 00000000..bac5f9c7 --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/Logger.qml @@ -0,0 +1,19 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. Plugins call Logger.d/i/w/e routinely. +QtObject { + function format(args) { + var out = [] + for (var i = 0; i < args.length; i++) { + var a = args[i] + out.push(a === undefined ? "undefined" : (a === null ? "null" : String(a))) + } + return out.join(" ") + } + + function d() { console.debug(format(arguments)) } + function i() { console.info(format(arguments)) } + function w() { console.warn(format(arguments)) } + function e() { console.error(format(arguments)) } +} diff --git a/default/quickshell/omarchy-shell/Commons/Settings.qml b/default/quickshell/omarchy-shell/Commons/Settings.qml new file mode 100644 index 00000000..015ffd4f --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/Settings.qml @@ -0,0 +1,62 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// Noctalia compat shim. Their plugins read Settings.data.* and call +// Settings.getBarPositionForScreen / getBarWidgetsForScreen. We expose a +// read-only view of our shell.json so reads succeed; writes are best routed +// through pluginApi.saveSettings() instead. +QtObject { + id: root + + property var data: ({ + bar: { + position: "top", + widgets: { left: [], center: [], right: [] } + }, + general: {}, + colorSchemes: { darkMode: true } + }) + + // Plugins use this gate around verbose debug logging. Defaulting to false + // keeps their Logger.d calls silent unless we explicitly flip it. + property bool isDebug: false + + // shellConfig is wired by shell.qml at startup; reading it keeps Settings.data + // in lockstep with the live shell config. + property var shellConfig: null + onShellConfigChanged: rebuildData() + + function rebuildData() { + var sc = shellConfig || {} + var bar = (sc && sc.bar) ? sc.bar : {} + var layout = (bar && bar.layout) ? bar.layout : {} + data = { + bar: { + position: bar.position || "top", + centerAnchor: bar.centerAnchor || "", + fontFamily: bar.fontFamily || "JetBrainsMono Nerd Font", + widgets: { + left: Array.isArray(layout.left) ? layout.left : [], + center: Array.isArray(layout.center) ? layout.center : [], + right: Array.isArray(layout.right) ? layout.right : [] + } + }, + general: {}, + colorSchemes: { darkMode: true } + } + } + + function getBarPositionForScreen(name) { + return data && data.bar ? data.bar.position : "top" + } + + function getBarWidgetsForScreen(name) { + return data && data.bar ? data.bar.widgets : { left: [], center: [], right: [] } + } + + function getScreenOverrideEntry(name) { return null } + function hasScreenOverride(name, key) { return false } + function setScreenOverride(name, key, value) { /* not supported */ } +} diff --git a/default/quickshell/omarchy-shell/Commons/ShellState.qml b/default/quickshell/omarchy-shell/Commons/ShellState.qml new file mode 100644 index 00000000..bd1ce2b1 --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/ShellState.qml @@ -0,0 +1,13 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. Their plugins occasionally introspect global UI +// state through this singleton. Returning safe defaults lets reads succeed +// without us tracking the state ourselves. +QtObject { + readonly property bool isLoaded: true + property var activeScreen: null + property bool launcherOpen: false + property bool controlCenterOpen: false + property bool settingsOpen: false +} diff --git a/default/quickshell/omarchy-shell/Commons/Style.qml b/default/quickshell/omarchy-shell/Commons/Style.qml new file mode 100644 index 00000000..de84185a --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/Style.qml @@ -0,0 +1,101 @@ +pragma Singleton +import QtQuick +import "." as Commons + +// Noctalia compat shim. Sizing/spacing/typography tokens that plugins +// reference unconditionally. Real values picked to roughly match what +// Omarchy renders today — close enough that plugin layouts don't look +// jarring next to native widgets. +QtObject { + id: root + + // Radii. + readonly property real radiusXS: 2 + readonly property real radiusS: 3 + readonly property real radiusM: 4 + readonly property real radiusL: 6 + readonly property real radiusXL: 8 + readonly property real iRadiusXS: 2 + readonly property real iRadiusS: 3 + readonly property real iRadiusM: 4 + readonly property real iRadiusL: 6 + + // Margins / paddings. Noctalia uses two parallel scales for margins (one + // tighter, one looser); we map them to the same values. + readonly property real marginXS: 2 + readonly property real marginS: 4 + readonly property real marginM: 6 + readonly property real marginL: 10 + readonly property real marginXL: 14 + readonly property real margin2XXS: 1 + readonly property real margin2XS: 2 + readonly property real margin2S: 3 + readonly property real margin2M: 5 + readonly property real margin2L: 8 + readonly property real margin2XL: 12 + + // Border widths. + readonly property real borderS: 1 + readonly property real borderM: 1 + readonly property real borderL: 2 + + // Font sizes. + readonly property real fontSizeXXS: 9 + readonly property real fontSizeXS: 10 + readonly property real fontSizeS: 11 + readonly property real fontSizeM: 12 + readonly property real fontSizeL: 14 + readonly property real fontSizeXL: 16 + readonly property real fontSizeXXL: 20 + readonly property real fontSizeXXXL: 24 + + // Font weights. + readonly property int fontWeightLight: 300 + readonly property int fontWeightRegular: 400 + readonly property int fontWeightMedium: 500 + readonly property int fontWeightSemiBold: 600 + readonly property int fontWeightBold: 700 + + // Opacities. + readonly property real opacityLight: 0.4 + readonly property real opacityMedium: 0.6 + readonly property real opacityHeavy: 0.8 + readonly property real opacityFull: 1.0 + + // Animation durations (ms). + readonly property int animationFast: 100 + readonly property int animationNormal: 160 + readonly property int animationSlow: 250 + readonly property int animationSlower: 400 + readonly property int animationSlowest: 600 + + // Tooltip delay (ms). + readonly property int tooltipDelay: 400 + + // Capsule (bar pill) tokens. The Omarchy bar is flat, no capsule background, + // so we route these to transparent + the foreground outline for visual hint. + readonly property color capsuleColor: Qt.rgba(0, 0, 0, 0) + readonly property color capsuleBorderColor: Qt.rgba(0, 0, 0, 0) + readonly property real capsuleBorderWidth: 0 + readonly property real baseWidgetSize: 22 + + // UI scale ratio. Noctalia plugins multiply lengths by this when + // applyUiScale is true; we leave it at 1. + readonly property real uiScaleRatio: 1.0 + + // Helpers. Plugins pass screen names but in practice we ignore them and + // return our shell-wide values. + function getBarHeightForScreen(name) { return 28 } + function getCapsuleHeightForScreen(name) { return 22 } + function getBarFontSizeForScreen(name) { return fontSizeM } + function getBarRadiusForScreen(name) { return radiusM } + + function pixelAlignCenter(parentSize, childSize) { + return Math.round((Number(parentSize) - Number(childSize)) / 2) + } + + function toOdd(n) { + var i = Math.floor(Number(n)) + return i | 1 + } +} diff --git a/default/quickshell/omarchy-shell/Commons/ThemeIcons.qml b/default/quickshell/omarchy-shell/Commons/ThemeIcons.qml new file mode 100644 index 00000000..59d974bc --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/ThemeIcons.qml @@ -0,0 +1,10 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. Their plugins resolve app icons through XDG icon +// themes; we don't ship that machinery, so iconForAppId returns empty and +// callers fall back to their default glyph. +QtObject { + function iconFromName(name) { return "" } + function iconForAppId(appId) { return "" } +} diff --git a/default/quickshell/omarchy-shell/Commons/Time.qml b/default/quickshell/omarchy-shell/Commons/Time.qml new file mode 100644 index 00000000..9c549ff2 --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/Time.qml @@ -0,0 +1,18 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. +QtObject { + function getFormattedTimestamp() { + return new Date().toISOString() + } + + function nowMs() { + return Date.now() + } + + function formatDate(date, fmt) { + if (!date) date = new Date() + return Qt.formatDateTime(date, fmt || "yyyy-MM-dd HH:mm:ss") + } +} diff --git a/default/quickshell/omarchy-shell/Commons/qmldir b/default/quickshell/omarchy-shell/Commons/qmldir new file mode 100644 index 00000000..f34bcbe4 --- /dev/null +++ b/default/quickshell/omarchy-shell/Commons/qmldir @@ -0,0 +1,10 @@ +module qs.Commons +singleton Color 1.0 Color.qml +singleton Style 1.0 Style.qml +singleton Logger 1.0 Logger.qml +singleton Settings 1.0 Settings.qml +singleton I18n 1.0 I18n.qml +singleton Time 1.0 Time.qml +singleton Icons 1.0 Icons.qml +singleton ThemeIcons 1.0 ThemeIcons.qml +singleton ShellState 1.0 ShellState.qml diff --git a/default/quickshell/omarchy-shell/README.md b/default/quickshell/omarchy-shell/README.md index db65be2a..85255895 100644 --- a/default/quickshell/omarchy-shell/README.md +++ b/default/quickshell/omarchy-shell/README.md @@ -19,8 +19,9 @@ The runtime layout in this branch: ``` default/quickshell/omarchy-shell/ shell.qml entry point (ShellRoot) + shell-defaults.json canonical out-of-the-box config services/ - PluginRegistry.qml discovers, validates, persists plugin state + PluginRegistry.qml discovers, validates plugins, looks up enabled state in shell.json BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p) ui/ settings/ @@ -133,12 +134,63 @@ type-stable across QML's `string`-only IPC arguments. ## Persisted state -| Path | Owner | Purpose | -|-------------------------------------------|----------------|--------------------------------------| -| `~/.config/omarchy/bar.json` | bar plugin | section layout + per-entry settings | -| `~/.config/omarchy/plugins.json` | PluginRegistry | enabled/disabled state | -| `~/.config/omarchy/plugins//` | user | manifest + entry points + assets | -| `~/.config/omarchy/plugins//settings.json` | user | optional per-plugin overrides | +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//` | user | drop-in third-party plugin source files | + +The `shell-defaults.json` bundled with the shell 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. Pressing **Reset to defaults** in `omarchy launch bar-settings` +rewrites `shell.json` from the current `shell-defaults.json`. + +### shell.json shape + +```json +{ + "version": 1, + "bar": { + "position": "top", + "centerAnchor": "calendar", + "fontFamily": "JetBrainsMono Nerd Font", + "layout": { + "left": [ { "id": "omarchy" }, { "id": "workspacesPro" } ], + "center": [ { "id": "calendar", "format": "HH:mm" } ], + "right": [ + { "id": "audioPanel" }, + { "id": "controlCenter" }, + { "id": "powerMenu" } + ] + } + }, + "plugins": [ + { "id": "omarchy.bar-settings" }, + { "id": "omarchy.image-picker" } + ] +} +``` + +### Storage rules + +1. **Every plugin instance is one entry.** Either in `bar.layout.
` + for bar widgets, or in `plugins[]` for panels, overlays, services, + menus, and anything else non-bar. +2. **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. +3. **Enabled ⇔ present.** A plugin is enabled iff its id appears somewhere + in shell.json. To disable, remove it. (The bar settings UI does both.) +4. **Multiple instances** are allowed when a manifest sets + `allowMultiple: true`. Each instance is independent — e.g. two clocks + in different timezones are just two `{"id":"calendar", "timezone": ...}` + entries with their own values. +5. **`version: 1` is required** at the top level. The shell will fall back + to defaults rather than load an unknown version. ## Implementation history @@ -151,6 +203,7 @@ Built up in phases on this branch: - 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. diff --git a/default/quickshell/omarchy-shell/Services/Power/PowerProfileService.qml b/default/quickshell/omarchy-shell/Services/Power/PowerProfileService.qml new file mode 100644 index 00000000..cca40490 --- /dev/null +++ b/default/quickshell/omarchy-shell/Services/Power/PowerProfileService.qml @@ -0,0 +1,9 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. The "noctaliaPerformanceMode" flag was a v3 internal +// state Noctalia plugins occasionally check. We hardcode false; plugins that +// branch on it will fall into the non-performance code path. +QtObject { + readonly property bool noctaliaPerformanceMode: false +} diff --git a/default/quickshell/omarchy-shell/Services/Power/qmldir b/default/quickshell/omarchy-shell/Services/Power/qmldir new file mode 100644 index 00000000..f9e714fc --- /dev/null +++ b/default/quickshell/omarchy-shell/Services/Power/qmldir @@ -0,0 +1,2 @@ +module qs.Services.Power +singleton PowerProfileService 1.0 PowerProfileService.qml diff --git a/default/quickshell/omarchy-shell/Services/System/HostService.qml b/default/quickshell/omarchy-shell/Services/System/HostService.qml new file mode 100644 index 00000000..835a807f --- /dev/null +++ b/default/quickshell/omarchy-shell/Services/System/HostService.qml @@ -0,0 +1,36 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// Noctalia compat shim. activate-linux and a few other plugins display +// distro/host info. We parse /etc/os-release lazily. +QtObject { + id: root + + property string osPretty: "" + property string osName: "" + property string osVersion: "" + property string hostname: "" + + property Process osReleaseProc: Process { + command: ["bash", "-c", + "( . /etc/os-release && printf '%s\\t%s\\t%s\\n' \"$PRETTY_NAME\" \"$NAME\" \"$VERSION_ID\" ); hostname"] + onExited: { + var text = String(osReleaseStdout.text || "").trim().split("\n") + if (text.length >= 1) { + var fields = text[0].split("\t") + root.osPretty = fields[0] || "" + root.osName = fields[1] || "" + root.osVersion = fields[2] || "" + } + if (text.length >= 2) root.hostname = text[1].trim() + } + stdout: StdioCollector { + id: osReleaseStdout + waitForEnd: true + } + } + + Component.onCompleted: osReleaseProc.running = true +} diff --git a/default/quickshell/omarchy-shell/Services/System/qmldir b/default/quickshell/omarchy-shell/Services/System/qmldir new file mode 100644 index 00000000..2d3fbbc6 --- /dev/null +++ b/default/quickshell/omarchy-shell/Services/System/qmldir @@ -0,0 +1,2 @@ +module qs.Services.System +singleton HostService 1.0 HostService.qml diff --git a/default/quickshell/omarchy-shell/Services/UI/BarService.qml b/default/quickshell/omarchy-shell/Services/UI/BarService.qml new file mode 100644 index 00000000..df5c8f13 --- /dev/null +++ b/default/quickshell/omarchy-shell/Services/UI/BarService.qml @@ -0,0 +1,80 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. Plugin bar widgets call into BarService for +// registration bookkeeping and to ask the host where their tooltip should +// pop. We track the bar reference (set by the host at startup) and expose +// the helpers plugins actually use. +QtObject { + id: root + + // Wired by omarchy-shell's Bar.qml on construction so we can reach back + // for things like position and the tooltip popup. + property var bar: null + + // Plugins read this when computing their own layout-revision markers. + property int widgetsRevision: 0 + + // Bookkeeping for plugin widgets. Not consulted by Omarchy itself; some + // plugins call lookupWidget() to coordinate across instances. + property var registered: ({}) + + function registerWidget(screen, section, widgetId, index, item) { + var key = (screen && screen.name ? screen.name : "_") + ":" + section + ":" + widgetId + ":" + index + var next = {} + for (var k in registered) next[k] = registered[k] + next[key] = { screen: screen, section: section, widgetId: widgetId, index: index, item: item } + registered = next + widgetsRevision++ + } + + function unregisterWidget(screen, section, widgetId, index) { + var key = (screen && screen.name ? screen.name : "_") + ":" + section + ":" + widgetId + ":" + index + if (!registered[key]) return + var next = {} + for (var k in registered) if (k !== key) next[k] = registered[k] + registered = next + widgetsRevision++ + } + + // Plugins occasionally search for a sibling widget by id. We do a linear + // scan since the registry is small in practice. + function lookupWidget(widgetId) { + for (var k in registered) { + if (registered[k].widgetId === widgetId) return registered[k] + } + return undefined + } + + // Translate the bar's edge to a tooltip direction string. Noctalia expects + // strings; the Omarchy bar drives its own tooltip popup off bar.position. + function getTooltipDirection(screenName) { + var pos = bar ? bar.position : "top" + if (pos === "top") return "down" + if (pos === "bottom") return "up" + if (pos === "left") return "right" + if (pos === "right") return "left" + return "down" + } + + function getPillDirection(item) { + var pos = bar ? bar.position : "top" + return pos === "left" || pos === "right" ? "horizontal" : "vertical" + } + + // Forward into the omarchy-shell host. Most Noctalia widgets call this + // from a right-click handler. + function openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings) { + if (bar && bar.shell && typeof bar.shell.summon === "function") { + bar.shell.summon("omarchy.bar-settings", + JSON.stringify({ focusWidgetId: widgetId, section: section, index: sectionWidgetIndex })) + } + } + + function openPluginSettings(screen, manifest) { + if (bar && bar.shell && typeof bar.shell.summon === "function") { + bar.shell.summon("omarchy.bar-settings", + JSON.stringify({ focusPluginId: manifest ? manifest.id : "" })) + } + } +} diff --git a/default/quickshell/omarchy-shell/Services/UI/PanelService.qml b/default/quickshell/omarchy-shell/Services/UI/PanelService.qml new file mode 100644 index 00000000..6892682f --- /dev/null +++ b/default/quickshell/omarchy-shell/Services/UI/PanelService.qml @@ -0,0 +1,34 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. Plugins use this for context menus and to reach +// the host's panels. We forward into the omarchy-shell host where there's +// a matching surface, and log a warning for anything we don't ship in v1. +QtObject { + id: root + + property var bar: null + property var shell: null + + // Plugins call this on right-click with a Menu component. The Noctalia + // implementation parents the menu to the screen's overlay; we simply + // call `open()` on whatever the plugin passes — most plugin context menus + // are plain QtQuick `Menu` items that know how to show themselves. + function showContextMenu(menu, item, screen) { + if (!menu) return + if (typeof menu.popup === "function") menu.popup() + else if (typeof menu.open === "function") menu.open() + } + + function closeContextMenu(screen) { + // No-op; the Menu / PopupWindow closes itself on outside click. + } + + function openLauncherWithSearch(screen, prefix) { + console.warn("PanelService.openLauncherWithSearch is not supported in the Omarchy compat layer") + } + + function getPanel(name, screen) { + return null + } +} diff --git a/default/quickshell/omarchy-shell/Services/UI/TooltipService.qml b/default/quickshell/omarchy-shell/Services/UI/TooltipService.qml new file mode 100644 index 00000000..28c3554d --- /dev/null +++ b/default/quickshell/omarchy-shell/Services/UI/TooltipService.qml @@ -0,0 +1,25 @@ +pragma Singleton +import QtQuick + +// Noctalia compat shim. Their plugins call TooltipService.show(item, text) +// on hover; we route that into the Omarchy bar's shared tooltip popup. +QtObject { + id: root + + // Wired by Bar.qml on construction. + property var bar: null + + function show(item, text, direction) { + if (!bar || !item) return + if (typeof bar.showTooltip === "function") { + bar.showTooltip(item, text || "") + } + } + + function hide(item) { + if (!bar) return + if (typeof bar.hideTooltip === "function") { + bar.hideTooltip(item || null) + } + } +} diff --git a/default/quickshell/omarchy-shell/Services/UI/qmldir b/default/quickshell/omarchy-shell/Services/UI/qmldir new file mode 100644 index 00000000..d36460cb --- /dev/null +++ b/default/quickshell/omarchy-shell/Services/UI/qmldir @@ -0,0 +1,4 @@ +module qs.Services.UI +singleton BarService 1.0 BarService.qml +singleton TooltipService 1.0 TooltipService.qml +singleton PanelService 1.0 PanelService.qml diff --git a/default/quickshell/omarchy-shell/Widgets/NBox.qml b/default/quickshell/omarchy-shell/Widgets/NBox.qml new file mode 100644 index 00000000..b3bb2b7f --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NBox.qml @@ -0,0 +1,9 @@ +import QtQuick +import qs.Commons + +Rectangle { + color: Color.mSurfaceVariant + border.color: Color.mOutline + border.width: Style.borderS + radius: Style.radiusM +} diff --git a/default/quickshell/omarchy-shell/Widgets/NButton.qml b/default/quickshell/omarchy-shell/Widgets/NButton.qml new file mode 100644 index 00000000..db709429 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NButton.qml @@ -0,0 +1,26 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons + +Button { + id: root + property string label: "" + property color colorBg: Color.mSurfaceVariant + property color colorFg: Color.mOnSurface + + text: label || "" + background: Rectangle { + color: root.hovered ? Color.mHover : root.colorBg + radius: Style.radiusM + border.color: Color.mOutline + border.width: Style.borderS + } + contentItem: Text { + text: root.text + color: root.colorFg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Style.fontSizeM + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/NCheckbox.qml b/default/quickshell/omarchy-shell/Widgets/NCheckbox.qml new file mode 100644 index 00000000..9b0b8633 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NCheckbox.qml @@ -0,0 +1,11 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons + +CheckBox { + id: root + property string label: "" + text: label || "" + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Style.fontSizeS +} diff --git a/default/quickshell/omarchy-shell/Widgets/NComboBox.qml b/default/quickshell/omarchy-shell/Widgets/NComboBox.qml new file mode 100644 index 00000000..c45008e3 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NComboBox.qml @@ -0,0 +1,17 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons + +ComboBox { + id: root + property string label: "" + + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Style.fontSizeS + background: Rectangle { + color: Color.mSurfaceVariant + border.color: Color.mOutline + border.width: Style.borderS + radius: Style.radiusS + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/NDivider.qml b/default/quickshell/omarchy-shell/Widgets/NDivider.qml new file mode 100644 index 00000000..7bbe406f --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NDivider.qml @@ -0,0 +1,8 @@ +import QtQuick +import qs.Commons + +Rectangle { + implicitHeight: 1 + color: Color.mOutline + opacity: 0.4 +} diff --git a/default/quickshell/omarchy-shell/Widgets/NIcon.qml b/default/quickshell/omarchy-shell/Widgets/NIcon.qml new file mode 100644 index 00000000..0f0710e2 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NIcon.qml @@ -0,0 +1,18 @@ +import QtQuick +import qs.Commons + +// Noctalia compat shim. Renders an icon by name through the Icons map. +Text { + id: root + property string icon: "" + property real pointSize: 14 + property bool applyUiScale: true + + text: Icons.get(icon) + color: Color.mOnSurface + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Math.round(pointSize * (applyUiScale ? Style.uiScaleRatio : 1)) + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + renderType: Text.NativeRendering +} diff --git a/default/quickshell/omarchy-shell/Widgets/NIconButton.qml b/default/quickshell/omarchy-shell/Widgets/NIconButton.qml new file mode 100644 index 00000000..5f7607b2 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NIconButton.qml @@ -0,0 +1,67 @@ +import QtQuick +import qs.Commons +import qs.Services.UI + +// Noctalia compat shim. The widget plugins lean on this heavily; it's the +// primary clickable bar element. We mimic Noctalia's surface area +// (icon/text/tooltip/colors/border) but skip the long tail of properties +// no observed plugin actually sets. +Rectangle { + id: root + + property string icon: "" + property string text: "" + property string tooltipText: "" + property string tooltipDirection: "" + property real baseSize: Style.baseWidgetSize + property bool applyUiScale: false + property real customRadius: -1 + property color colorBg: Style.capsuleColor + property color colorFg: Color.mOnSurface + property color colorBgHover: Color.mHover + property color colorFgHover: Color.mOnHover + property color colorBorder: Style.capsuleBorderColor + property color colorBorderHover: Style.capsuleBorderColor + property bool flat: false + + signal clicked() + signal rightClicked() + signal middleClicked() + signal wheel(int delta) + + readonly property real effectiveSize: Math.round(baseSize * (applyUiScale ? Style.uiScaleRatio : 1)) + + implicitWidth: effectiveSize + implicitHeight: effectiveSize + radius: customRadius >= 0 ? customRadius : Style.radiusM + color: mouse.containsMouse ? colorBgHover : colorBg + border.color: mouse.containsMouse ? colorBorderHover : colorBorder + border.width: Style.capsuleBorderWidth + + Behavior on color { ColorAnimation { duration: Style.animationFast } } + + Text { + anchors.centerIn: parent + text: root.icon ? Icons.get(root.icon) : root.text + color: mouse.containsMouse ? root.colorFgHover : root.colorFg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Math.round(Style.fontSizeM * (root.applyUiScale ? Style.uiScaleRatio : 1)) + Behavior on color { ColorAnimation { duration: Style.animationFast } } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onEntered: if (root.tooltipText) TooltipService.show(root, root.tooltipText, root.tooltipDirection) + onExited: TooltipService.hide(root) + onClicked: function(m) { + if (m.button === Qt.RightButton) root.rightClicked() + else if (m.button === Qt.MiddleButton) root.middleClicked() + else root.clicked() + } + onWheel: function(w) { root.wheel(w.angleDelta.y) } + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/NPopupContextMenu.qml b/default/quickshell/omarchy-shell/Widgets/NPopupContextMenu.qml new file mode 100644 index 00000000..498ccefa --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NPopupContextMenu.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons + +// Noctalia compat shim. Plugins instantiate this with a model of action +// objects and expect `triggered(action)` when a row is picked. +Menu { + id: root + + property var model: [] + signal triggered(string action) + + Instantiator { + model: root.model + delegate: MenuItem { + required property var modelData + text: modelData ? String(modelData.label || modelData.action || "") : "" + onTriggered: root.triggered(modelData ? String(modelData.action || "") : "") + } + onObjectAdded: function(index, object) { root.insertItem(index, object) } + onObjectRemoved: function(index, object) { root.removeItem(object) } + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/NScrollText.qml b/default/quickshell/omarchy-shell/Widgets/NScrollText.qml new file mode 100644 index 00000000..806e9aee --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NScrollText.qml @@ -0,0 +1,55 @@ +import QtQuick +import qs.Commons + +// Noctalia compat shim. Auto-scrolling label. +Item { + id: root + + property string text: "" + property real maxWidth: 200 + property real pointSize: Style.fontSizeM + property int scrollMode: NScrollText.ScrollMode.Hover + property bool forcedHover: false + property real fadeExtent: 0.1 + property real fadeCornerRadius: 0 + property bool fadeRoundLeftCorners: false + property bool fadeRoundRightCorners: false + property color textColor: Color.mOnSurface + + enum ScrollMode { Always, Hover, Never } + + implicitHeight: label.implicitHeight + implicitWidth: Math.min(maxWidth, label.implicitWidth) + clip: true + + readonly property bool overflowing: label.implicitWidth > width + readonly property bool shouldScroll: { + if (!overflowing) return false + if (scrollMode === NScrollText.ScrollMode.Always) return true + if (scrollMode === NScrollText.ScrollMode.Hover) return forcedHover + return false + } + + Text { + id: label + text: root.text + color: root.textColor + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: root.pointSize + verticalAlignment: Text.AlignVCenter + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + NumberAnimation on x { + id: scrollAnim + running: root.shouldScroll + loops: Animation.Infinite + duration: Math.max(5000, label.implicitWidth * 22) + from: root.width + to: -label.implicitWidth + easing.type: Easing.Linear + } + + onShouldScrollChanged: if (!root.shouldScroll) x = 0 + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/NSlider.qml b/default/quickshell/omarchy-shell/Widgets/NSlider.qml new file mode 100644 index 00000000..4cc311f9 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NSlider.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons + +Slider { + id: root + property string label: "" +} diff --git a/default/quickshell/omarchy-shell/Widgets/NSpinBox.qml b/default/quickshell/omarchy-shell/Widgets/NSpinBox.qml new file mode 100644 index 00000000..08ded63d --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NSpinBox.qml @@ -0,0 +1,10 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons + +SpinBox { + id: root + property string label: "" + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Style.fontSizeS +} diff --git a/default/quickshell/omarchy-shell/Widgets/NText.qml b/default/quickshell/omarchy-shell/Widgets/NText.qml new file mode 100644 index 00000000..84e4aebc --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NText.qml @@ -0,0 +1,19 @@ +import QtQuick +import qs.Commons + +// Noctalia compat shim. The original supports pointSize (interpreted as +// pixel size in Noctalia) plus an applyUiScale flag. We translate to +// font.pixelSize directly; uiScaleRatio comes from Style. +Text { + id: root + property real pointSize: 12 + property bool applyUiScale: true + property var customFont: null + + color: Color.mOnSurface + font.family: customFont || "JetBrainsMono Nerd Font" + font.pixelSize: Math.round(pointSize * (applyUiScale ? Style.uiScaleRatio : 1)) + font.weight: Style.fontWeightRegular + verticalAlignment: Text.AlignVCenter + renderType: Text.NativeRendering +} diff --git a/default/quickshell/omarchy-shell/Widgets/NTextInput.qml b/default/quickshell/omarchy-shell/Widgets/NTextInput.qml new file mode 100644 index 00000000..234b332d --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NTextInput.qml @@ -0,0 +1,20 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons + +TextField { + id: root + property string label: "" + property string description: "" + property string defaultValue: "" + + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Style.fontSizeS + color: Color.mOnSurface + background: Rectangle { + color: Color.mSurfaceVariant + border.color: root.focus ? Color.mPrimary : Color.mOutline + border.width: Style.borderS + radius: Style.radiusS + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/NToggle.qml b/default/quickshell/omarchy-shell/Widgets/NToggle.qml new file mode 100644 index 00000000..ef404608 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NToggle.qml @@ -0,0 +1,38 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons + +Switch { + id: root + property string label: "" + + indicator: Rectangle { + implicitWidth: 32 + implicitHeight: 18 + x: root.leftPadding + y: root.height / 2 - height / 2 + radius: height / 2 + color: root.checked ? Color.mPrimary : Color.mSurfaceVariant + border.color: Color.mOutline + border.width: Style.borderS + + Rectangle { + x: root.checked ? parent.width - width - 2 : 2 + y: 2 + width: parent.height - 4 + height: parent.height - 4 + radius: height / 2 + color: Color.mOnSurface + Behavior on x { NumberAnimation { duration: Style.animationFast } } + } + } + + contentItem: Text { + leftPadding: 38 + text: root.label || root.text + color: Color.mOnSurface + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Style.fontSizeS + verticalAlignment: Text.AlignVCenter + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/qmldir b/default/quickshell/omarchy-shell/Widgets/qmldir new file mode 100644 index 00000000..ee82b347 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/qmldir @@ -0,0 +1,15 @@ +module qs.Widgets +NText 1.0 NText.qml +NIcon 1.0 NIcon.qml +NIconButton 1.0 NIconButton.qml +NBox 1.0 NBox.qml +NButton 1.0 NButton.qml +NPopupContextMenu 1.0 NPopupContextMenu.qml +NScrollText 1.0 NScrollText.qml +NToggle 1.0 NToggle.qml +NSpinBox 1.0 NSpinBox.qml +NSlider 1.0 NSlider.qml +NTextInput 1.0 NTextInput.qml +NComboBox 1.0 NComboBox.qml +NCheckbox 1.0 NCheckbox.qml +NDivider 1.0 NDivider.qml diff --git a/default/quickshell/omarchy-shell/compat/noctalia/PluginApiFactory.qml b/default/quickshell/omarchy-shell/compat/noctalia/PluginApiFactory.qml new file mode 100644 index 00000000..47be96b3 --- /dev/null +++ b/default/quickshell/omarchy-shell/compat/noctalia/PluginApiFactory.qml @@ -0,0 +1,101 @@ +import QtQuick +import Quickshell + +// Builds the `pluginApi` QtObject that Noctalia plugins expect. The host +// (omarchy-shell or the bar) calls create() once per plugin and caches the +// returned object so the same handle is reused across the plugin's bar +// widget, panel, settings form, etc. +Item { + // create(pluginId, manifest, settingsProvider, hostBridge) -> QtObject + // + // settingsProvider: function() that returns the plugin's current settings + // merged with defaults. Called fresh on every read so the plugin always + // sees the live shell.json entry, not a stale snapshot from when create() + // was first called. + // hostBridge: object with these methods (all optional): + // - persistSettings(pluginId, settings): write back to shell.json via + // the shell's updateEntryInline helper. + // - openPanel(pluginId, screen, buttonItem): summon the plugin's panel + // entry point through the shell's plugin host. + // - closePanel(pluginId, screen): hide a previously-summoned panel. + // - currentScreen(): return the screen the plugin should anchor to. + // togglePanel is provided by the factory itself and routes through the + // open/closePanel bridge methods. Main.qml service instances are wired + // separately by the shell and set on api.mainInstance once instantiated. + function create(pluginId, manifest, settingsProvider, hostBridge) { + var api = pluginApiFactory.createObject(null, { + pluginId: pluginId, + pluginDir: manifest && manifest.__sourceDir ? manifest.__sourceDir : "", + manifest: manifest || ({}), + _settingsProvider: settingsProvider || (function() { return {} }), + _hostBridge: hostBridge || ({}) + }) + return api + } + + Component { + id: pluginApiFactory + + QtObject { + id: api + + property string pluginId: "" + property string pluginDir: "" + property var manifest: ({}) + property var _settingsProvider + property var _hostBridge: ({}) + property var mainInstance: null + + // Resolved every read so the plugin sees current shell.json state. + readonly property var pluginSettings: _settingsProvider ? _settingsProvider() : ({}) + + property var panelOpenScreen: null + property var ipcHandlers: ({}) + + // Noctalia i18n surface — v1 returns the key as-is. + readonly property string currentLanguage: "en" + readonly property var pluginTranslations: ({}) + readonly property var pluginFallbackTranslations: ({}) + readonly property int translationVersion: 0 + + function tr(key, interp) { return String(key === undefined ? "" : key) } + function trp(key, count, interp) { return String(key === undefined ? "" : key) } + function hasTranslation(key) { return false } + + function saveSettings() { + if (_hostBridge && typeof _hostBridge.persistSettings === "function") + _hostBridge.persistSettings(pluginId, pluginSettings) + } + + function openPanel(screen, buttonItem) { + panelOpenScreen = screen + if (_hostBridge && typeof _hostBridge.openPanel === "function") + _hostBridge.openPanel(pluginId, screen, buttonItem) + } + + function closePanel(screen) { + panelOpenScreen = null + if (_hostBridge && typeof _hostBridge.closePanel === "function") + _hostBridge.closePanel(pluginId, screen) + } + + function togglePanel(screen, buttonItem) { + if (panelOpenScreen) closePanel(screen) + else openPanel(screen, buttonItem) + } + + function withCurrentScreen(cb) { + if (typeof cb !== "function") return + var s = (_hostBridge && typeof _hostBridge.currentScreen === "function") + ? _hostBridge.currentScreen() : null + cb(s) + } + + function openLauncher(screen) { + console.warn("pluginApi.openLauncher is not supported in the Omarchy compat layer (plugin=" + pluginId + ")") + } + function closeLauncher(screen) { /* no-op */ } + function toggleLauncher(screen) { openLauncher(screen) } + } + } +} diff --git a/default/quickshell/omarchy-shell/compat/noctalia/README.md b/default/quickshell/omarchy-shell/compat/noctalia/README.md new file mode 100644 index 00000000..bf07fc51 --- /dev/null +++ b/default/quickshell/omarchy-shell/compat/noctalia/README.md @@ -0,0 +1,73 @@ +# Noctalia plugin compatibility (omarchy-shell) + +The omarchy-shell can load most bar widget plugins from the +[noctalia-dev/noctalia-plugins](https://github.com/noctalia-dev/noctalia-plugins) +ecosystem without any modification to the plugin code. + +## Install a Noctalia plugin + +```sh +git clone https://github.com/noctalia-dev/noctalia-plugins /tmp/noctalia-plugins +ln -s /tmp/noctalia-plugins/asus-um5606-fan-state \ + ~/.config/omarchy/plugins/asus-um5606-fan-state +omarchy-shell-ipc shell rescanPlugins +``` + +The plugin id you see inside Omarchy is prefixed: `noctalia.asus-um5606-fan-state`. +Add it via the bar customizer or by editing `~/.config/omarchy/shell.json` directly. + +## What's supported in v1 + +| Noctalia concept | Omarchy support | +|---|---| +| `entryPoints.barWidget` | yes — registered through `BarWidgetRegistry` | +| `entryPoints.panel` | yes — opened via `pluginApi.openPanel()` | +| `entryPoints.settings` | yes — embedded in the bar-settings dialog | +| `entryPoints.main` | yes — instantiated as a hidden service, exposed via `pluginApi.mainInstance` | +| `entryPoints.desktopWidget` | **no** — skipped with a console warning | +| `entryPoints.launcherProvider` | **no** — skipped with a console warning | +| `entryPoints.controlCenterWidget` | **no** — skipped with a console warning | +| Plugin i18n (`i18n/.json`) | **no** — `tr()` returns the raw key | +| Plugin install from git URL | **no** — manual drop into `~/.config/omarchy/plugins/` | +| Hot reload on plugin change | **no** — call `omarchy-shell-ipc shell rescanPlugins` | + +## Shim surface + +We ship just enough of Noctalia's QML namespace to render typical bar widgets. + +| Module | Symbols | +|---|---| +| `qs.Commons` | `Color`, `Style`, `Logger`, `Settings` (read-only), `I18n` (stub), `Time`, `Icons` (~50 entries), `ThemeIcons` (stub), `ShellState` (stub) | +| `qs.Widgets` | `NText`, `NIcon`, `NIconButton`, `NBox`, `NButton`, `NPopupContextMenu`, `NScrollText`, `NToggle`, `NSpinBox`, `NSlider`, `NTextInput`, `NComboBox`, `NCheckbox`, `NDivider` | +| `qs.Services.UI` | `BarService`, `TooltipService`, `PanelService` | +| `qs.Services.System` | `HostService` (reads `/etc/os-release`) | +| `qs.Services.Power` | `PowerProfileService` (returns `noctaliaPerformanceMode: false`) | + +Anything outside this surface logs a `console.warn` instead of crashing the +shell, but the plugin may not render correctly. + +## `pluginApi` surface + +A Noctalia plugin's `pluginApi` is built per-plugin and injected onto the bar +widget / panel / settings entry points. The implementation lives at +`compat/noctalia/PluginApiFactory.qml`. + +| Property / method | Behaviour | +|---|---| +| `pluginId`, `pluginDir`, `manifest` | Provided as expected. | +| `pluginSettings` | Live read of the plugin's entry in `~/.config/omarchy/shell.json`, merged with the manifest's `metadata.defaultSettings`. | +| `mainInstance` | Live instance of `Main.qml` when the plugin declares one. | +| `saveSettings()` | Persists the merged settings into the plugin's entry in `shell.json`. | +| `openPanel(screen, btn)` / `closePanel(screen)` / `togglePanel(screen, btn)` | Routes through `omarchy-shell-ipc shell summon/hide` against the plugin's panel entry point. | +| `withCurrentScreen(cb)` | Calls `cb` with the bar's currently-rendering screen. | +| `tr/trp/hasTranslation` | Returns the key as-is; no i18n in v1. | +| `openLauncher(...)` family | Stub — logs a warning. | + +## Known limitations + +- Plugin labels show raw translation keys for non-English locales. +- Icon names not present in our compact `Icons` map render as `?`. +- Plugins that declare only `desktopWidget` or `launcherProvider` and no + bar/panel/main do not appear in the Omarchy catalog. +- Some plugins call `Color.mPrimary` for accent colors that don't perfectly + match Omarchy's theme palette; results are close but not pixel-identical. diff --git a/default/quickshell/omarchy-shell/plugins/README.md b/default/quickshell/omarchy-shell/plugins/README.md index d3f270dd..5c764833 100644 --- a/default/quickshell/omarchy-shell/plugins/README.md +++ b/default/quickshell/omarchy-shell/plugins/README.md @@ -16,22 +16,25 @@ User-installed plugins live alongside these conceptually but on disk under ## Bar -The status bar. Mounted at startup, lives forever. Layout is configured -through `~/.config/omarchy/bar.json` (deep-merged over -[`bar/bar-defaults.json`](bar/bar-defaults.json)). Owns the `bar` IPC -target for refresh hooks fired by indicator scripts. See -[`bar/README.md`](bar/README.md) for the widget catalogue and customization -schema. +The status bar. Mounted at startup, lives forever. Layout lives in the +top-level `bar:` subtree of `~/.config/omarchy/shell.json` (with the shell +providing [`shell-defaults.json`](../shell-defaults.json) when the user has +no file). Owns the `bar` IPC target for refresh hooks fired by indicator +scripts. See [`bar/README.md`](bar/README.md) for the widget catalogue +and customization schema. ## Bar settings -Visual editor for the bar layout. Summoned by +Visual editor for the entire shell config. Summoned by `omarchy-shell-ipc shell summon omarchy.bar-settings "{}"` (which is what `omarchy launch bar-settings` ultimately calls). Provides: -- per-section add/move/remove/edit of widget entries -- a Plugin Manager tab for enabling/disabling third-party plugins -- a dynamic settings form driven by each widget's manifest schema +- per-section add/move/remove/edit of bar widget entries +- a separate "Other plugins" section for panels, overlays, services, + and menus (entries that live in `plugins[]` rather than the bar layout) +- a Plugin Manager tab listing every discovered plugin with its manifest +- a dynamic settings form driven by each widget's manifest schema, that + writes inline back to the corresponding shell.json entry ## Image picker diff --git a/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml b/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml index d2d440c0..1738cd56 100644 --- a/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml @@ -19,6 +19,9 @@ Item { // so the panel sees the same registry state the bar wrote into. property var barWidgetRegistry: null property var pluginRegistry: null + // The host shell. Used to look up pluginApi for Noctalia plugins so their + // Settings.qml can read/write via pluginApi.saveSettings(). + property var shell: null // Not `required` so the Loader-based instantiation can satisfy it via // onLoaded; we still gracefully fall back to deriving from shellDir for @@ -32,8 +35,8 @@ Item { return Quickshell.env("HOME") + "/.local/share/omarchy" } readonly property string home: Quickshell.env("HOME") - readonly property string userConfigPath: home + "/.config/omarchy/bar.json" - readonly property string defaultsPath: omarchyPath + "/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json" + readonly property string userConfigPath: home + "/.config/omarchy/shell.json" + readonly property string defaultsPath: omarchyPath + "/default/quickshell/omarchy-shell/shell-defaults.json" property color foreground: "#cacccc" property color background: "#101315" @@ -43,32 +46,41 @@ Item { property string fontFamily: "JetBrainsMono Nerd Font" property string activeTab: "layout" - // 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/omarchy-shell/plugins/bar/bar-defaults.json. - readonly property var builtinBarConfig: ({ - position: "top", - fontFamily: "JetBrainsMono Nerd Font", - centerAnchor: "calendar", - layout: { - left: [{ id: "omarchy" }, { id: "workspacesPro" }, { id: "activeWindow" }], - center: [ - { id: "media" }, - { id: "calendar", format: "dddd HH:mm", formatAlt: "dd MMMM 'W'ww yyyy", verticalFormat: "HH\n—\nmm" }, - { id: "weatherFlyout" }, { id: "update" }, { id: "voxtype" }, - { id: "screenRecording" }, { id: "idle" }, { id: "notifications" } - ], - right: [ - { id: "tray" }, { id: "systemStats" }, { id: "microphone" }, - { id: "bluetoothPanel" }, { id: "networkPanel" }, { id: "audioPanel" }, - { id: "nightLight" }, { id: "brightness" }, { id: "powerProfile" }, - { id: "battery" }, { id: "controlCenter" }, { id: "powerMenu" } - ] - } + // Bundled fallback so 'Reset to defaults' never produces an empty config + // even if shell-defaults.json fails to load. Keep in rough sync with the + // file shipped at default/quickshell/omarchy-shell/shell-defaults.json. + readonly property var builtinShellConfig: ({ + version: 1, + bar: { + position: "top", + fontFamily: "JetBrainsMono Nerd Font", + centerAnchor: "calendar", + layout: { + left: [{ id: "omarchy" }, { id: "workspacesPro" }, { id: "activeWindow" }], + center: [ + { id: "media" }, + { id: "calendar", format: "dddd HH:mm", formatAlt: "dd MMMM 'W'ww yyyy", verticalFormat: "HH\n—\nmm" }, + { id: "weatherFlyout" }, { id: "update" }, { id: "voxtype" }, + { id: "screenRecording" }, { id: "idle" }, { id: "notifications" } + ], + right: [ + { id: "tray" }, { id: "systemStats" }, { id: "microphone" }, + { id: "bluetoothPanel" }, { id: "networkPanel" }, { id: "audioPanel" }, + { id: "nightLight" }, { id: "brightness" }, { id: "powerProfile" }, + { id: "battery" }, { id: "controlCenter" }, { id: "powerMenu" } + ] + } + }, + plugins: [ + { id: "omarchy.bar-settings" }, + { id: "omarchy.image-picker" } + ] }) - property var defaultConfig: builtinBarConfig - property var draft: ({ position: "top", centerAnchor: "calendar", layout: { left: [], center: [], right: [] }, fontFamily: "JetBrainsMono Nerd Font" }) + property var defaultConfig: builtinShellConfig + // The draft mirrors the full shell.json on-disk shape. Editors operate on + // draft.bar.layout.* and draft.plugins; the file we persist is `draft`. + property var draft: ({ version: 1, bar: { position: "top", centerAnchor: "calendar", fontFamily: "JetBrainsMono Nerd Font", layout: { left: [], center: [], right: [] } }, plugins: [] }) property var registry: ({}) property int draftRevision: 0 property bool suppressReload: false @@ -116,32 +128,53 @@ Item { } function loadConfig() { - var defaults = builtinBarConfig + var defaults = builtinShellConfig var diskText = defaultsFile.text() if (diskText) { try { - defaults = JSON.parse(diskText) + var parsed = JSON.parse(diskText) + if (isPlainObject(parsed) && parsed.version === 1) defaults = parsed } catch (e) { - console.warn("Bad defaults JSON, falling back to builtin:", e) - defaults = builtinBarConfig + console.warn("Bad shell-defaults JSON, falling back to builtin:", e) + defaults = builtinShellConfig } } defaultConfig = defaults - var userText = userFile.text() || "{}" - var user = {} - try { user = JSON.parse(userText) } catch (e) { user = {} } - - var merged = mergeConfig(defaultConfig, user) - draft = { - position: String(merged.position || "top"), - centerAnchor: String(merged.centerAnchor || ""), - fontFamily: String(merged.fontFamily || "JetBrainsMono Nerd Font"), - layout: normalizeLayout(merged.layout || {}) + // shell.json is canonical when present and valid; otherwise we fall back + // to defaults. We do NOT deep-merge — once the user has a shell.json, the + // file's contents are authoritative. + var userText = userFile.text() || "" + var source = defaults + if (userText.trim()) { + try { + var u = JSON.parse(userText) + if (isPlainObject(u) && u.version === 1) source = u + } catch (e) { + console.warn("shell.json parse failed in panel:", e) + } } + draft = normalizeDraft(source) draftRevision++ } + function normalizeDraft(source) { + var bar = isPlainObject(source.bar) ? source.bar : {} + var plugins = Array.isArray(source.plugins) ? source.plugins.slice() : [] + return { + version: 1, + bar: { + position: String(bar.position || "top"), + centerAnchor: String(bar.centerAnchor || ""), + fontFamily: String(bar.fontFamily || "JetBrainsMono Nerd Font"), + layout: normalizeLayout(bar.layout || {}) + }, + plugins: plugins + .map(normalizeLayoutEntry) + .filter(function(e) { return !!e }) + } + } + function persistDraft() { // Suppress the inotify callback that this write triggers so the FileView // reload doesn't race with rapid edits and clobber them. @@ -154,19 +187,14 @@ Item { // (path resolution failed or defaultsFile hasn't finished loading), so // Reset never zeroes the bar out. var source = defaultConfig - if (!isPlainObject(source) || !isPlainObject(source.layout)) { - source = builtinBarConfig + if (!isPlainObject(source) || !isPlainObject(source.bar) || !isPlainObject(source.bar.layout)) { + source = builtinShellConfig } else { - var l = source.layout + var l = source.bar.layout var anyEntries = (l.left && l.left.length) || (l.center && l.center.length) || (l.right && l.right.length) - if (!anyEntries) source = builtinBarConfig - } - var payload = { - position: String(source.position || "top"), - centerAnchor: String(source.centerAnchor || ""), - fontFamily: String(source.fontFamily || "JetBrainsMono Nerd Font"), - layout: normalizeLayout(source.layout || {}) + if (!anyEntries) source = builtinShellConfig } + var payload = normalizeDraft(source) // Update the GUI synchronously — the suppressed file-watch callback won't // fire loadConfig, so the draft would otherwise stay stale. draft = payload @@ -180,44 +208,45 @@ Item { persistDraft() } - // Replace the whole `layout` object so any binding that reads `draft.layout` - // is invalidated. Mutating `draft.layout[section]` alone does not notify QML. - function mutateLayout(section, mutator) { - var nextLayout = { - left: draft.layout.left.slice(), - center: draft.layout.center.slice(), - right: draft.layout.right.slice() - } - mutator(nextLayout[section]) - var nextDraft = { - position: draft.position, - centerAnchor: draft.centerAnchor, - fontFamily: draft.fontFamily, - layout: nextLayout - } + // Section list helpers operate on a virtual section name. "left" / "center" + // / "right" address draft.bar.layout.
; "plugins" addresses + // draft.plugins. The mutators replace the whole `draft` so any binding that + // reads it re-evaluates. + function sectionArray(section) { + if (section === "plugins") return draft.plugins || [] + return (draft.bar && draft.bar.layout && draft.bar.layout[section]) || [] + } + + function mutateSection(section, mutator) { + var arr = sectionArray(section).slice() + mutator(arr) + var nextDraft = cloneJson(draft) + if (section === "plugins") nextDraft.plugins = arr + else nextDraft.bar.layout[section] = arr draft = nextDraft markDirty() } function moveEntry(section, fromIndex, toIndex) { - if (toIndex < 0 || toIndex >= draft.layout[section].length) return - mutateLayout(section, function(arr) { - var item = arr[fromIndex] - arr.splice(fromIndex, 1) - arr.splice(toIndex, 0, item) + var arr = sectionArray(section) + if (toIndex < 0 || toIndex >= arr.length) return + mutateSection(section, function(a) { + var item = a[fromIndex] + a.splice(fromIndex, 1) + a.splice(toIndex, 0, item) }) } function removeEntry(section, index) { - mutateLayout(section, function(arr) { arr.splice(index, 1) }) + mutateSection(section, function(a) { a.splice(index, 1) }) } function addEntry(section, id) { - mutateLayout(section, function(arr) { arr.push({ id: id }) }) + mutateSection(section, function(a) { a.push({ id: id }) }) } function updateEntry(section, index, newEntry) { - mutateLayout(section, function(arr) { arr[index] = cloneJson(newEntry) }) + mutateSection(section, function(a) { a[index] = cloneJson(newEntry) }) } function loadTheme(raw) { @@ -297,12 +326,23 @@ Item { var rev = catalogRevision var meta = widgetMetadata(id) if (meta.settingsForm) return true - return widgetSchema(id).length > 0 + if (widgetSchema(id).length > 0) return true + // Noctalia plugins ship their own Settings.qml — surface the gear icon + // even though we don't render a built-in form for them. + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null + if (manifest && manifest.__noctaliaCompat && manifest.entryPoints && manifest.entryPoints.settings) + return true + return false } function widgetIsPlugin(id) { var meta = widgetMetadata(id) - return meta.source === "plugin" || String(id).indexOf("plugin:") === 0 + return meta.source === "plugin" + } + + function widgetIsNoctaliaPlugin(id) { + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null + return !!(manifest && manifest.__noctaliaCompat) } function widgetAllowsMultiple(id) { @@ -322,18 +362,24 @@ Item { return Object.keys(ids) } + // section is one of "left" / "center" / "right" for bar widgets, or + // "plugins" for the non-bar plugin list. The catalogue we offer differs: + // bar sections accept anything with kind `bar-widget` (or anything in the + // legacy widget metadata); the plugins section accepts panel/overlay/menu/ + // service kinds. function availableToAdd(section) { var rev = catalogRevision - var existingByOther = {} - var sections = ["left", "center", "right"] - for (var s = 0; s < sections.length; s++) { - if (sections[s] === section) continue - var list = draft.layout[sections[s]] || [] - for (var i = 0; i < list.length; i++) existingByOther[list[i].id] = true + var isBarSection = section === "left" || section === "center" || section === "right" + var barSections = ["left", "center", "right"] + + var existingInBar = {} + for (var s = 0; s < barSections.length; s++) { + var list = sectionArray(barSections[s]) + for (var i = 0; i < list.length; i++) existingInBar[list[i].id] = true } - var existingHere = {} - var here = draft.layout[section] || [] - for (var j = 0; j < here.length; j++) existingHere[here[j].id] = true + var existingInPlugins = {} + var pluginList = sectionArray("plugins") + for (var p = 0; p < pluginList.length; p++) existingInPlugins[pluginList[p].id] = true var ids = catalogIds().sort(function(a, b) { return widgetName(a).localeCompare(widgetName(b)) @@ -342,8 +388,35 @@ Item { var result = [] for (var k = 0; k < ids.length; k++) { var id = ids[k] - if (!widgetAllowsMultiple(id) && existingHere[id]) continue - result.push({ id: id, name: widgetName(id), description: widgetDescription(id), elsewhere: !!existingByOther[id] }) + var meta = widgetMetadata(id) + var isBarWidget = !!(meta && meta.source !== "plugin") + || (meta && meta.kinds && meta.kinds.indexOf && meta.kinds.indexOf("bar-widget") !== -1) + if (isBarSection) { + if (!isBarWidget && !legacyWidgetMeta[id]) continue + var inSection = sectionArray(section) + var existsHere = false + for (var x = 0; x < inSection.length; x++) if (inSection[x].id === id) { existsHere = true; break } + var allowsMultiple = widgetAllowsMultiple(id) + // Hard block: if it's already placed in any bar section and the + // widget doesn't permit multiple instances, drop it from the menu + // entirely rather than offer an "(elsewhere)" entry that would + // silently move/duplicate state. + if (!allowsMultiple && existingInBar[id]) continue + // For widgets that do allow multiple (spacer), the `elsewhere` + // hint is informational only. + result.push({ id: id, name: widgetName(id), description: widgetDescription(id), + elsewhere: allowsMultiple && !!existingInBar[id] && !existsHere, + isNoctalia: widgetIsNoctaliaPlugin(id) }) + } else { + // Plugins section: accept anything that has a manifest in the + // registry (plugins[] holds non-bar plugins — panel/overlay/menu/ + // service — keyed by manifest id). + var hasManifest = !!(root.pluginRegistry && root.pluginRegistry.installedPlugins[id]) + if (!hasManifest) continue + if (existingInPlugins[id]) continue + result.push({ id: id, name: widgetName(id), description: widgetDescription(id), elsewhere: false, + isNoctalia: widgetIsNoctaliaPlugin(id) }) + } } return result } @@ -432,7 +505,7 @@ Item { anchors.verticalCenter: parent.verticalCenter Text { - text: "Auto-saving to ~/.config/omarchy/bar.json" + text: "Auto-saving to ~/.config/omarchy/shell.json" color: Qt.darker(root.foreground, 1.5) font.family: root.fontFamily font.pixelSize: 11 @@ -453,25 +526,29 @@ Item { OptionDropdown { label: "Position" - value: root.draft.position + value: root.draft.bar.position options: ["top", "right", "bottom", "left"] onChanged: function(v) { - root.draft.position = v + var next = root.cloneJson(root.draft) + next.bar.position = v + root.draft = next root.markDirty() } } OptionDropdown { label: "Center anchor" - value: root.draft.centerAnchor + value: root.draft.bar.centerAnchor options: { var list = ["(none)"] - var entries = root.draft.layout.center || [] + var entries = root.draft.bar.layout.center || [] for (var i = 0; i < entries.length; i++) list.push(entries[i].id) return list } onChanged: function(v) { - root.draft.centerAnchor = v === "(none)" ? "" : v + var next = root.cloneJson(root.draft) + next.bar.centerAnchor = v === "(none)" ? "" : v + root.draft = next root.markDirty() } } @@ -525,9 +602,17 @@ Item { Layout.fillWidth: true spacing: 14 - SectionEditor { sectionKey: "left"; sectionLabel: "Left" } - SectionEditor { sectionKey: "center"; sectionLabel: "Center" } - SectionEditor { sectionKey: "right"; sectionLabel: "Right" } + SectionEditor { sectionKey: "left"; sectionLabel: "Bar · Left" } + SectionEditor { sectionKey: "center"; sectionLabel: "Bar · Center" } + SectionEditor { sectionKey: "right"; sectionLabel: "Bar · Right" } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + } + + SectionEditor { sectionKey: "plugins"; sectionLabel: "Other plugins" } } PluginManager { @@ -638,13 +723,13 @@ Item { property string sectionKey: "" property string sectionLabel: "" - property var entries: (root.draft.layout && root.draft.layout[section.sectionKey]) || [] + property var entries: root.sectionArray(section.sectionKey) Layout.fillWidth: true spacing: 8 Connections { target: root - function onDraftRevisionChanged() { section.entries = (root.draft.layout && root.draft.layout[section.sectionKey]) || [] } + function onDraftRevisionChanged() { section.entries = root.sectionArray(section.sectionKey) } } Row { @@ -681,7 +766,11 @@ Item { model: root.availableToAdd(section.sectionKey) delegate: MenuItem { required property var modelData - text: modelData.name + (modelData.elsewhere ? " (elsewhere)" : "") + // Suffix order matches what users skim left-to-right: + // name first, origin badge, then "elsewhere" if shared across sections. + text: modelData.name + + (modelData.isNoctalia ? " (Noctalia)" : "") + + (modelData.elsewhere ? " (elsewhere)" : "") onTriggered: root.addEntry(section.sectionKey, modelData.id) } } @@ -934,6 +1023,11 @@ Item { } } + // Resolution order: + // 1) First-party inline forms (id-keyed switch). + // 2) Noctalia plugin's own Settings.qml — we load it inside a Loader + // so the plugin renders its native UI and writes via pluginApi. + // 3) Generic DynamicSettingsForm built from manifest schema entries. function formComponent(id) { var meta = widgetMetadata(id) if (meta && meta.settingsForm) { @@ -943,6 +1037,9 @@ Item { case "brightnessSettings": return brightnessSettingsComponent } } + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null + if (manifest && manifest.__noctaliaCompat && manifest.entryPoints && manifest.entryPoints.settings) + return noctaliaSettingsComponent if (widgetSchema(id).length > 0) return dynamicSettingsComponent return null } @@ -956,6 +1053,51 @@ Item { } } + // Loader stub for Noctalia plugins that bundle a Settings.qml. We load the + // plugin's form and inject pluginApi so the plugin's own "save" button + // routes through pluginApi.saveSettings() — which lands in shell.json via + // shell.updateEntryInline. The plugin form doesn't emit `fieldChanged`, + // so the dialog's Apply/Cancel buttons are mostly decorative for these + // (writes already happened by the time you click Apply). + Component { + id: noctaliaSettingsComponent + + Item { + id: noctaliaForm + property var entry: ({}) + property string pluginId: entry && entry.id ? String(entry.id) : "" + property var manifest: pluginId && root.pluginRegistry + ? root.pluginRegistry.installedPlugins[pluginId] : null + + implicitHeight: settingsLoader.item ? settingsLoader.item.implicitHeight : 0 + implicitWidth: settingsLoader.item ? settingsLoader.item.implicitWidth : 0 + + Loader { + id: settingsLoader + anchors.fill: parent + source: { + if (!noctaliaForm.manifest) return "" + return root.pluginRegistry.entryPointUrl(noctaliaForm.manifest, "settings") + } + asynchronous: false + onLoaded: { + if (!item) return + var api = (root.shell && typeof root.shell.noctaliaPluginApiFor === "function") + ? root.shell.noctaliaPluginApiFor(noctaliaForm.pluginId) : null + if (api && "pluginApi" in item) item.pluginApi = api + if ("screen" in item && root.QsWindow && root.QsWindow.window) + item.screen = root.QsWindow.window.screen + } + onStatusChanged: { + if (status === Loader.Error) { + console.warn("noctalia Settings.qml failed for " + noctaliaForm.pluginId + ":", + sourceComponent ? sourceComponent.errorString() : "") + } + } + } + } + } + Component { id: spacerSettingsComponent diff --git a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml index 0099580c..85c77a86 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml @@ -19,29 +19,26 @@ Item { // Injected by the host shell. Shared with the bar-settings panel so both // see the same widget catalogue. required property var barWidgetRegistry + // Injected by the host shell every time shell.json is reloaded. Holds the + // `bar:` subtree: position, centerAnchor, fontFamily, layout. The host owns + // file IO; the bar just renders whatever it's handed. + required property var barConfig + // Injected by the host shell so the bar can detect Noctalia-compat plugins + // and look up manifests when wiring per-widget Noctalia pluginApi. + property var pluginRegistry: null + // Injected by the host shell. Used so Noctalia plugins that reach for + // shell-wide APIs (openPanel, currentScreen) can do so via pluginApi. + property var shell: null property string home: Quickshell.env("HOME") property string omarchyConfigDir: home + "/.config/omarchy" - property var builtinBarConfig: ({ + property var fallbackBarConfig: ({ position: "top", fontFamily: "JetBrainsMono Nerd Font", - centerAnchor: "clock", - layout: { - left: [{ id: "omarchy" }, { id: "workspaces" }], - center: [ - { id: "clock", format: "dddd HH:mm", formatAlt: "dd MMMM 'W'ww yyyy", verticalFormat: "HH\n—\nmm" }, - { id: "weather" }, { id: "update" }, { id: "voxtype" }, - { id: "screenRecording" }, { id: "idle" }, { id: "notifications" } - ], - right: [ - { id: "tray" }, { id: "bluetooth" }, { id: "network" }, - { id: "audio" }, { id: "cpu" }, { id: "battery" } - ] - } + centerAnchor: "calendar", + layout: { left: [], center: [], right: [] } }) - property var defaultBarConfig: builtinBarConfig - property var userBarConfig: ({}) - property var layoutConfig: builtinBarConfig.layout - property string centerAnchor: "clock" + property var layoutConfig: fallbackBarConfig.layout + property string centerAnchor: "" property int barConfigSerial: 0 property string position: "top" property string fontFamily: "JetBrainsMono Nerd Font" @@ -80,6 +77,40 @@ Item { if (activePopout === owner) activePopout = null } + // -------------------------------------------------- Noctalia compat helpers + // + // The shell owns pluginApi creation and Main.qml service instantiation now; + // bar widgets just look up their api by moduleName. Keeping section/index + // helpers here because they're needed for Noctalia bar-widget injection + // (widgetId, section, sectionWidgetIndex, sectionWidgetsCount). + + function sectionOfEntry(entry) { + var sections = ["left", "center", "right"] + for (var s = 0; s < sections.length; s++) { + var list = layoutConfig[sections[s]] || [] + for (var i = 0; i < list.length; i++) { + if (list[i] === entry) return sections[s] + } + } + return "" + } + + function indexOfEntry(entry) { + var section = sectionOfEntry(entry) + var list = section ? (layoutConfig[section] || []) : [] + for (var i = 0; i < list.length; i++) if (list[i] === entry) return i + return -1 + } + + function entriesOfSection(section) { + return Array.isArray(layoutConfig[section]) ? layoutConfig[section] : [] + } + + function noctaliaPluginApiFor(moduleName) { + if (!shell || typeof shell.noctaliaPluginApiFor !== "function") return null + return shell.noctaliaPluginApiFor(moduleName) + } + readonly property bool vertical: position === "left" || position === "right" readonly property int barSize: vertical ? 28 : 26 @@ -104,51 +135,6 @@ Item { return value !== null && typeof value === "object" && !Array.isArray(value) } - function cloneConfig(value) { - if (Array.isArray(value)) { - var arrayCopy = [] - for (var i = 0; i < value.length; i++) - arrayCopy.push(cloneConfig(value[i])) - return arrayCopy - } - - if (isPlainObject(value)) { - var objectCopy = {} - for (var key in value) - objectCopy[key] = cloneConfig(value[key]) - return objectCopy - } - - return value - } - - function mergeConfig(base, override) { - var result = cloneConfig(base || {}) - if (!isPlainObject(override)) return result - - for (var key in override) { - if (isPlainObject(result[key]) && isPlainObject(override[key])) - result[key] = mergeConfig(result[key], override[key]) - else - result[key] = cloneConfig(override[key]) - } - - return result - } - - function parseConfig(raw, label) { - var text = String(raw || "").trim() - if (!text) return {} - - try { - var parsed = JSON.parse(text) - return isPlainObject(parsed) ? parsed : {} - } catch (error) { - console.warn("Failed to parse " + label + ": " + error) - return {} - } - } - function normalizeLayoutEntry(entry) { if (typeof entry === "string") return { id: entry } if (isPlainObject(entry) && entry.id) return entry @@ -166,7 +152,7 @@ Item { } function normalizeLayout(layout) { - if (!isPlainObject(layout)) layout = builtinBarConfig.layout + if (!isPlainObject(layout)) layout = fallbackBarConfig.layout return { left: normalizeLayoutSection(layout.left), center: normalizeLayoutSection(layout.center), @@ -175,24 +161,16 @@ Item { } function applyBarConfig() { - var config = mergeConfig(defaultBarConfig, userBarConfig) + var config = isPlainObject(barConfig) ? barConfig : fallbackBarConfig position = normalizePosition(config.position) fontFamily = String(config.fontFamily || "JetBrainsMono Nerd Font") - centerAnchor = String(config.centerAnchor || "clock") + centerAnchor = String(config.centerAnchor || "") layoutConfig = normalizeLayout(config.layout) barConfigSerial++ } - function loadDefaultBarConfig(raw) { - defaultBarConfig = mergeConfig(builtinBarConfig, parseConfig(raw, "bar defaults")) - applyBarConfig() - } - - function loadUserBarConfig(raw) { - userBarConfig = parseConfig(raw, "bar config") - applyBarConfig() - } + onBarConfigChanged: applyBarConfig() function layoutEntries(region) { var serial = barConfigSerial @@ -327,7 +305,10 @@ Item { property var registeredFirstPartyComponents: ({}) - Component.onCompleted: registerFirstPartyWidgets() + Component.onCompleted: { + registerFirstPartyWidgets() + applyBarConfig() + } function registerFirstPartyWidgets() { var ids = Object.keys(firstPartyWidgetMetadata) @@ -713,22 +694,8 @@ Item { onTriggered: root.tooltipShown = true } - FileView { - path: root.omarchyPath + "/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json" - watchChanges: true - printErrors: false - onLoaded: root.loadDefaultBarConfig(text()) - onFileChanged: reload() - } - - FileView { - path: root.omarchyConfigDir + "/bar.json" - watchChanges: true - printErrors: false - onLoaded: root.loadUserBarConfig(text()) - onFileChanged: reload() - } - + // The host owns shell.json loading and injects `barConfig`. Bar still keeps + // its own theme FileView since theme colors are independent of shell.json. FileView { path: root.home + "/.config/omarchy/current/theme/colors.toml" watchChanges: true @@ -1230,6 +1197,20 @@ Item { if ("bar" in target) target.bar = root if ("moduleName" in target) target.moduleName = moduleName if ("settings" in target) target.settings = moduleSettings + + // Noctalia compat injection. Only kicks in for plugins whose manifest + // was translated from Noctalia shape; for our first-party widgets these + // properties don't exist on the target item so nothing happens. + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[moduleName] : null + if (manifest && manifest.__noctaliaCompat) { + if ("pluginApi" in target) target.pluginApi = root.noctaliaPluginApiFor(moduleName) + if ("widgetId" in target) target.widgetId = moduleName + if ("section" in target) target.section = root.sectionOfEntry(entry) + if ("sectionWidgetIndex" in target) target.sectionWidgetIndex = root.indexOfEntry(entry) + if ("sectionWidgetsCount" in target) target.sectionWidgetsCount = root.entriesOfSection(root.sectionOfEntry(entry)).length + if ("screen" in target && root.QsWindow && root.QsWindow.window) + target.screen = root.QsWindow.window.screen + } } Component { diff --git a/default/quickshell/omarchy-shell/plugins/bar/README.md b/default/quickshell/omarchy-shell/plugins/bar/README.md index 0317ca7b..999d4209 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/README.md +++ b/default/quickshell/omarchy-shell/plugins/bar/README.md @@ -7,41 +7,43 @@ the shell for its whole session. - `manifest.json` declares the plugin (`id: omarchy.bar`, `kind: bar`, `activation: persistent`) and points at `Bar.qml` as the entry point. - `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). -- User overrides live in `~/.config/omarchy/bar.json` and are merged over defaults at runtime. -- `omarchy-style-bar-position` updates only the user override file. +- The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/omarchy/shell.json` (or `shell-defaults.json` when the user has no file). +- `omarchy-style-bar-position` updates only the user shell.json file. ## Customizing -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. +The bar config lives under the `bar:` key of [`~/.config/omarchy/shell.json`](../../README.md#shelljson-shape). Out of the box the shell uses [`shell-defaults.json`](../../shell-defaults.json). Once you customize anything via `omarchy launch bar-settings` or by editing shell.json directly, your file is canonical — there is no deep-merge. 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. -Example `bar.json`: +Example `shell.json` (bar subtree only shown): ```json { - "position": "top", - "centerAnchor": "calendar", - "layout": { - "left": [ - { "id": "omarchy" }, - { "id": "spacer", "size": 12 }, - { "id": "workspacesPro" } - ], - "center": [ - { "id": "media" }, - { "id": "calendar", "format": "HH:mm" } - ], - "right": [ - { "id": "systemStats" }, - { "id": "audioPanel" }, - { "id": "battery" }, - { "id": "controlCenter" }, - { "id": "powerMenu" } - ] + "version": 1, + "bar": { + "position": "top", + "centerAnchor": "calendar", + "layout": { + "left": [ + { "id": "omarchy" }, + { "id": "spacer", "size": 12 }, + { "id": "workspacesPro" } + ], + "center": [ + { "id": "media" }, + { "id": "calendar", "format": "HH:mm" } + ], + "right": [ + { "id": "systemStats" }, + { "id": "audioPanel" }, + { "id": "battery" }, + { "id": "controlCenter" }, + { "id": "powerMenu" } + ] + } } } ``` @@ -81,18 +83,21 @@ All widgets work in `top`, `bottom`, `left`, and `right` positions. Popups ancho ## Custom user modules -The schema accepts arbitrary module ids that you provide. Set `type` to `command` for shell-driven output or `qml` for a custom QML widget. +The schema accepts arbitrary module ids that you provide. Set `type` to `command` for shell-driven output or `qml` for a custom QML widget. Both still go under `bar.layout.
` in `shell.json`. Command module: ```json { - "layout": { - "right": [ - { "id": "tray" }, - { "id": "vpn", "type": "command", "exec": "~/.config/omarchy/bar/scripts/vpn-status", "interval": 5, "tooltip": "VPN", "onClick": "nm-connection-editor" }, - { "id": "audioPanel" } - ] + "version": 1, + "bar": { + "layout": { + "right": [ + { "id": "tray" }, + { "id": "vpn", "type": "command", "exec": "~/.config/omarchy/bar/scripts/vpn-status", "interval": 5, "tooltip": "VPN", "onClick": "nm-connection-editor" }, + { "id": "audioPanel" } + ] + } } } ``` @@ -107,11 +112,14 @@ QML module: ```json { - "layout": { - "right": [ - { "id": "gpu", "type": "qml" }, - { "id": "audioPanel" } - ] + "version": 1, + "bar": { + "layout": { + "right": [ + { "id": "gpu", "type": "qml" }, + { "id": "audioPanel" } + ] + } } } ``` diff --git a/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json b/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json deleted file mode 100644 index 82c9a6d1..00000000 --- a/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "position": "top", - "fontFamily": "JetBrainsMono Nerd Font", - "centerAnchor": "calendar", - "layout": { - "left": [ - { "id": "omarchy" }, - { "id": "workspacesPro" }, - { "id": "activeWindow" } - ], - "center": [ - { "id": "media" }, - { "id": "calendar", "format": "dddd HH:mm", "formatAlt": "dd MMMM 'W'ww yyyy", "verticalFormat": "HH\n—\nmm" }, - { "id": "weatherFlyout" }, - { "id": "update" }, - { "id": "voxtype" }, - { "id": "screenRecording" }, - { "id": "idle" }, - { "id": "notifications" } - ], - "right": [ - { "id": "tray" }, - { "id": "systemStats" }, - { "id": "microphone" }, - { "id": "bluetoothPanel" }, - { "id": "networkPanel" }, - { "id": "audioPanel" }, - { "id": "nightLight" }, - { "id": "brightness" }, - { "id": "powerProfile" }, - { "id": "battery" }, - { "id": "controlCenter" }, - { "id": "powerMenu" } - ] - } -} diff --git a/default/quickshell/omarchy-shell/services/PluginRegistry.qml b/default/quickshell/omarchy-shell/services/PluginRegistry.qml index 23d137d3..1e3ccc27 100644 --- a/default/quickshell/omarchy-shell/services/PluginRegistry.qml +++ b/default/quickshell/omarchy-shell/services/PluginRegistry.qml @@ -8,15 +8,19 @@ QtObject { 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: "" + // Wired by shell.qml so the registry can read the canonical shell.json + // without owning file IO itself. shellConfigProvider returns the current + // effective shell config; shellConfigMutator takes a function that receives + // a deep-cloned config it can mutate in place and persists the result. + property var shellConfigProvider: null + property var shellConfigMutator: null + // { 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 @@ -40,11 +44,99 @@ QtObject { return true } + // Detect a Noctalia-shape manifest (https://github.com/noctalia-dev/noctalia-plugins). + // Noctalia manifests don't carry our schemaVersion; we recognise them by the + // presence of `minNoctaliaVersion` or `metadata.defaultSettings`, or an + // `entryPoints` map that names roles Noctalia owns (main/barWidget/panel). + function isNoctaliaShape(manifest) { + if (!isPlainObject(manifest)) return false + if (manifest.schemaVersion !== undefined) return false + if (manifest.minNoctaliaVersion !== undefined) return true + if (isPlainObject(manifest.metadata) && isPlainObject(manifest.metadata.defaultSettings)) return true + if (isPlainObject(manifest.entryPoints)) { + var ep = manifest.entryPoints + if (ep.barWidget || ep.main || ep.panel || ep.desktopWidget + || ep.launcherProvider || ep.controlCenterWidget) return true + } + return false + } + + // Translate a Noctalia manifest into the shape we validate normally. We do + // not modify the on-disk file; the in-memory copy gets a __noctaliaCompat + // marker so downstream code can branch where the semantics diverge. + function translateNoctaliaManifest(src) { + var ep = isPlainObject(src.entryPoints) ? src.entryPoints : {} + var kinds = [] + var unsupported = [] + if (ep.main) kinds.push("service") + if (ep.barWidget) kinds.push("bar-widget") + if (ep.panel) kinds.push("panel") + // Out of scope for v1 — warn loudly so we don't silently strand the plugin. + if (ep.desktopWidget) unsupported.push("desktopWidget") + if (ep.launcherProvider) unsupported.push("launcherProvider") + if (ep.controlCenterWidget) unsupported.push("controlCenterWidget") + + if (kinds.length === 0) { + console.warn("PluginRegistry: noctalia plugin '" + src.id + "' has no supported entryPoints" + + (unsupported.length ? " (unsupported in v1: " + unsupported.join(", ") + ")" : "")) + return null + } + if (unsupported.length) { + console.warn("PluginRegistry: noctalia plugin '" + src.id + + "' uses entryPoints not supported in v1, ignoring: " + unsupported.join(", ")) + } + + var translated = { + schemaVersion: 1, + id: "noctalia." + String(src.id), + name: src.name || src.id, + version: src.version || "0.0.0", + author: src.author || "", + description: src.description || "", + kinds: kinds, + activation: kinds.indexOf("service") !== -1 ? "persistent" : "on-demand", + entryPoints: {}, + __noctaliaCompat: true, + __noctaliaOriginal: src + } + + if (ep.barWidget) translated.entryPoints.barWidget = ep.barWidget + if (ep.main) translated.entryPoints.service = ep.main + if (ep.panel) translated.entryPoints.panel = ep.panel + if (ep.settings) translated.entryPoints.settings = ep.settings + + var defaults = (isPlainObject(src.metadata) && isPlainObject(src.metadata.defaultSettings)) + ? src.metadata.defaultSettings : {} + if (translated.entryPoints.barWidget) { + translated.barWidget = { + displayName: translated.name, + description: translated.description, + category: "Noctalia", + allowMultiple: false, + defaults: defaults, + schema: [] + } + } + if (defaults && Object.keys(defaults).length > 0) translated.defaults = defaults + + return translated + } + function validateManifest(manifest, sourcePath) { if (!isPlainObject(manifest)) { console.warn("PluginRegistry: manifest is not an object at " + sourcePath) return null } + // Noctalia shape — translate before validation. The translated manifest + // goes through the standard schemaVersion=1 path below. + if (isNoctaliaShape(manifest)) { + var preservedSourceDir = manifest.__sourceDir + var preservedFirstParty = manifest.__isFirstParty + manifest = translateNoctaliaManifest(manifest) + if (!manifest) return null + manifest.__sourceDir = preservedSourceDir + manifest.__isFirstParty = preservedFirstParty + } if (manifest.schemaVersion !== 1) { console.warn("PluginRegistry: unsupported schemaVersion at " + sourcePath) return null @@ -99,18 +191,74 @@ QtObject { return fileUrl(resolved) } + // Enabled = the plugin id is referenced somewhere in shell.json. That can + // be either a layout entry inside `bar.layout.*` (bar widgets) or a top-level + // entry in `plugins[]` (panels, overlays, services). + // + // Special case: plugins with `kinds` containing "bar" are directly mounted + // by the shell host rather than loaded through plugins[], so they're + // implicitly always enabled. function isEnabled(id) { - var state = pluginStates[String(id)] - return !!(state && state.enabled) + var key = String(id) + var manifest = installedPlugins[key] + if (manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1) return true + var config = shellConfigProvider ? shellConfigProvider() : null + return findEntryLocation(config, key).found } + function findEntryLocation(config, id) { + if (!isPlainObject(config)) return { found: false } + if (isPlainObject(config.bar) && isPlainObject(config.bar.layout)) { + var sections = ["left", "center", "right"] + for (var s = 0; s < sections.length; s++) { + var arr = config.bar.layout[sections[s]] + if (!Array.isArray(arr)) continue + for (var i = 0; i < arr.length; i++) { + if (arr[i] && arr[i].id === id) return { found: true, kind: "bar", section: sections[s], index: i } + } + } + } + if (Array.isArray(config.plugins)) { + for (var j = 0; j < config.plugins.length; j++) { + if (config.plugins[j] && config.plugins[j].id === id) return { found: true, kind: "plugin", index: j } + } + } + return { found: false } + } + + // Adding a plugin places it in the right section based on its declared + // kinds. Bar widgets default to the right section; panels/overlays/menus/ + // services go into the plugins[] array. 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() + if (!shellConfigMutator) { + console.warn("PluginRegistry.setEnabled called before shellConfigMutator wired") + return + } + var manifest = installedPlugins[key] + var isBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1 + shellConfigMutator(function(config) { + // Ensure shape exists. + if (!isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } } + if (!isPlainObject(config.bar.layout)) config.bar.layout = { left: [], center: [], right: [] } + if (!Array.isArray(config.plugins)) config.plugins = [] + var location = findEntryLocation(config, key) + if (value && !location.found) { + var entry = { id: key } + if (isBarWidget) { + if (!Array.isArray(config.bar.layout.right)) config.bar.layout.right = [] + config.bar.layout.right.push(entry) + } else { + config.plugins.push(entry) + } + } else if (!value && location.found) { + if (location.kind === "bar") { + config.bar.layout[location.section].splice(location.index, 1) + } else { + config.plugins.splice(location.index, 1) + } + } + }) registryRevision++ pluginsChanged() } @@ -124,36 +272,6 @@ QtObject { 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: @@ -207,16 +325,6 @@ QtObject { } 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] // Third-party plugins never shadow a first-party one with the same id. @@ -229,7 +337,6 @@ QtObject { merged[tk] = thirdParty[tk] } - pluginStates = nextStates installedPlugins = merged registryRevision++ scanning = false diff --git a/default/quickshell/omarchy-shell/shell-defaults.json b/default/quickshell/omarchy-shell/shell-defaults.json new file mode 100644 index 00000000..8f04f90d --- /dev/null +++ b/default/quickshell/omarchy-shell/shell-defaults.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "bar": { + "position": "top", + "fontFamily": "JetBrainsMono Nerd Font", + "centerAnchor": "calendar", + "layout": { + "left": [ + { "id": "omarchy" }, + { "id": "workspacesPro" }, + { "id": "activeWindow" } + ], + "center": [ + { "id": "media" }, + { "id": "calendar", "format": "dddd HH:mm", "formatAlt": "dd MMMM 'W'ww yyyy", "verticalFormat": "HH\n—\nmm" }, + { "id": "weatherFlyout" }, + { "id": "update" }, + { "id": "voxtype" }, + { "id": "screenRecording" }, + { "id": "idle" }, + { "id": "notifications" } + ], + "right": [ + { "id": "tray" }, + { "id": "systemStats" }, + { "id": "microphone" }, + { "id": "bluetoothPanel" }, + { "id": "networkPanel" }, + { "id": "audioPanel" }, + { "id": "nightLight" }, + { "id": "brightness" }, + { "id": "powerProfile" }, + { "id": "battery" }, + { "id": "controlCenter" }, + { "id": "powerMenu" } + ] + } + }, + "plugins": [ + { "id": "omarchy.bar-settings" }, + { "id": "omarchy.image-picker" } + ] +} diff --git a/default/quickshell/omarchy-shell/shell.qml b/default/quickshell/omarchy-shell/shell.qml index 911ded62..48390368 100644 --- a/default/quickshell/omarchy-shell/shell.qml +++ b/default/quickshell/omarchy-shell/shell.qml @@ -5,6 +5,9 @@ import Quickshell.Io import "plugins/bar" import "services" +import "compat/noctalia" as Compat +import qs.Services.UI as NoctaliaUI +import qs.Commons as NoctaliaCommons ShellRoot { id: shell @@ -32,23 +35,363 @@ ShellRoot { } property string omarchyPath: deriveOmarchyPath() readonly property string firstPartyPluginsDir: omarchyPath + "/default/quickshell/omarchy-shell/plugins" + readonly property string defaultsPath: omarchyPath + "/default/quickshell/omarchy-shell/shell-defaults.json" + readonly property string userConfigPath: home + "/.config/omarchy/shell.json" + + // Bundled fallback so the shell can start even when shell-defaults.json is + // missing or unreadable. The bar config here mirrors the on-disk defaults + // closely enough to render a usable bar; not authoritative. + readonly property var builtinShellConfig: ({ + version: 1, + bar: { + position: "top", + fontFamily: "JetBrainsMono Nerd Font", + centerAnchor: "calendar", + layout: { + left: [{ id: "omarchy" }, { id: "workspacesPro" }], + center: [{ id: "calendar", format: "dddd HH:mm" }], + right: [{ id: "audioPanel" }, { id: "controlCenter" }, { id: "powerMenu" }] + } + }, + plugins: [ + { id: "omarchy.bar-settings" }, + { id: "omarchy.image-picker" } + ] + }) + + property var defaultsConfig: builtinShellConfig + property var shellConfig: builtinShellConfig + property bool suppressUserReload: false + + function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) + } + + function applyShellConfig() { + // Decide which source is canonical: a valid user shell.json overrides + // defaults entirely; otherwise fall back to defaults. We do not deep-merge. + var defaults = isPlainObject(defaultsConfig) ? defaultsConfig : builtinShellConfig + var user = null + var userText = userConfigFile.text() || "" + if (userText.trim()) { + try { + var parsed = JSON.parse(userText) + if (isPlainObject(parsed) && parsed.version === 1) user = parsed + else if (isPlainObject(parsed)) console.warn("shell.json missing version: 1, using defaults") + } catch (e) { + console.warn("shell.json parse failed, using defaults:", e) + } + } + shellConfig = user || defaults + } + + function loadDefaults(raw) { + var text = String(raw || "").trim() + if (!text) { + defaultsConfig = builtinShellConfig + applyShellConfig() + return + } + try { + var parsed = JSON.parse(text) + if (isPlainObject(parsed) && parsed.version === 1) defaultsConfig = parsed + else defaultsConfig = builtinShellConfig + } catch (e) { + console.warn("shell-defaults.json parse failed, using builtin:", e) + defaultsConfig = builtinShellConfig + } + applyShellConfig() + } + + function persistShellConfig(nextConfig) { + suppressUserReload = true + var payload = JSON.parse(JSON.stringify(nextConfig)) + payload.version = 1 + shellConfig = payload + userConfigFile.setText(JSON.stringify(payload, null, 2) + "\n") + } + + readonly property var barConfig: shellConfig && isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar + readonly property var pluginsConfig: shellConfig && Array.isArray(shellConfig.plugins) ? shellConfig.plugins : [] + + FileView { + id: defaultsFile + path: shell.defaultsPath + watchChanges: true + printErrors: false + onLoaded: shell.loadDefaults(text()) + onLoadFailed: function(error) { + console.warn("shell-defaults load failed: " + error + " path=" + shell.defaultsPath) + shell.loadDefaults("") + } + onFileChanged: reload() + } + + FileView { + id: userConfigFile + path: shell.userConfigPath + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: { + if (shell.suppressUserReload) { + shell.suppressUserReload = false + return + } + shell.applyShellConfig() + } + onLoadFailed: function(error) { shell.applyShellConfig() } + onFileChanged: reload() + } Component.onCompleted: { console.log("omarchy-shell paths", "omarchyPath=" + shell.omarchyPath, "shellDir=" + Quickshell.shellDir, - "firstPartyPluginsDir=" + shell.firstPartyPluginsDir) + "firstPartyPluginsDir=" + shell.firstPartyPluginsDir, + "defaultsPath=" + shell.defaultsPath, + "userConfigPath=" + shell.userConfigPath) pluginRegistry.firstPartyDir = shell.firstPartyPluginsDir + pluginRegistry.shellConfigProvider = function() { return shell.shellConfig } + pluginRegistry.shellConfigMutator = function(mutate) { shell.mutateShellConfig(mutate) } // 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. pluginRegistry.rescan() + + // Wire Noctalia compat singletons. Plugins read state from these globals; + // we hand them references to the host so they can route into the right + // popup, tooltip, etc. + NoctaliaUI.BarService.bar = bar + NoctaliaUI.TooltipService.bar = bar + NoctaliaUI.PanelService.bar = bar + NoctaliaUI.PanelService.shell = shell + NoctaliaCommons.Settings.shellConfig = shell.shellConfig + } + + // Keep Noctalia.Settings.data in sync with whatever shell.json currently is. + onShellConfigChanged: NoctaliaCommons.Settings.shellConfig = shell.shellConfig + + function mutateShellConfig(mutator) { + var copy = JSON.parse(JSON.stringify(shellConfig || builtinShellConfig)) + mutator(copy) + persistShellConfig(copy) } Bar { id: bar omarchyPath: shell.omarchyPath barWidgetRegistry: shell.barWidgetRegistry + barConfig: shell.barConfig + pluginRegistry: shell.pluginRegistry + shell: shell + } + + // ------------------------------------------------- Noctalia plugin API + // + // One pluginApi instance per plugin, cached for the life of the shell. The + // factory is host-owned (shell, not bar) so non-bar Noctalia plugins + // (panels, services) can call into it too. Settings lookups walk the live + // shell.json on every read so reorders/edits never leave a stale entry + // captured in the closure. + Compat.PluginApiFactory { id: noctaliaApiFactory } + + property var _noctaliaApis: ({}) + property var _noctaliaServices: ({}) + + function findShellEntry(pluginId) { + var config = shellConfig + if (!isPlainObject(config)) return null + if (isPlainObject(config.bar) && isPlainObject(config.bar.layout)) { + var sections = ["left", "center", "right"] + for (var s = 0; s < sections.length; s++) { + var arr = config.bar.layout[sections[s]] + if (!Array.isArray(arr)) continue + for (var i = 0; i < arr.length; i++) { + if (arr[i] && arr[i].id === pluginId) return arr[i] + } + } + } + if (Array.isArray(config.plugins)) { + for (var p = 0; p < config.plugins.length; p++) { + if (config.plugins[p] && config.plugins[p].id === pluginId) return config.plugins[p] + } + } + return null + } + + function noctaliaPluginApiFor(pluginId) { + var key = String(pluginId) + if (!key) return null + var existing = _noctaliaApis[key] + if (existing) return existing + var manifest = pluginRegistry && pluginRegistry.installedPlugins + ? pluginRegistry.installedPlugins[key] : null + if (!manifest || !manifest.__noctaliaCompat) return null + + var api = noctaliaApiFactory.create( + key, + manifest, + function() { + // Compute effective settings on every read. We can't capture an entry + // reference because mutateSection rebuilds the layout objects and the + // captured reference would point at a snapshot. + var defaults = (manifest.barWidget && manifest.barWidget.defaults) || manifest.defaults || {} + var merged = {} + for (var k in defaults) merged[k] = defaults[k] + var entry = findShellEntry(key) + if (entry) { + for (var ek in entry) if (ek !== "id") merged[ek] = entry[ek] + } + return merged + }, + { + persistSettings: function(_pluginId, settings) { + if (typeof shell.updateEntryInline === "function") + shell.updateEntryInline(key, settings) + }, + openPanel: function(_pluginId, _screen, _btn) { + shell.summon(key, JSON.stringify({ source: "noctalia" })) + }, + closePanel: function(_pluginId, _screen) { shell.hide(key) }, + currentScreen: function() { + var screens = Quickshell.screens + return screens && screens.length > 0 ? screens[0] : null + } + } + ) + + var next = ({}) + for (var existingKey in _noctaliaApis) next[existingKey] = _noctaliaApis[existingKey] + next[key] = api + _noctaliaApis = next + return api + } + + // -------------------------------------------------- noctalia service main + // + // Noctalia plugins with `entryPoints.main` (translated to kind="service") + // are instantiated once per shell session when enabled. The Main.qml is + // typically headless — a data source the plugin's bar widget reads from. + // We rely on the shared pluginApi so the service and any bar widgets see + // the same handle. + function ensureNoctaliaService(pluginId) { + var key = String(pluginId) + if (_noctaliaServices[key]) return _noctaliaServices[key] + var manifest = pluginRegistry && pluginRegistry.installedPlugins + ? pluginRegistry.installedPlugins[key] : null + if (!manifest || !manifest.__noctaliaCompat) return null + if (!manifest.entryPoints || !manifest.entryPoints.service) return null + var url = pluginRegistry.entryPointUrl(manifest, "service") + if (!url) return null + var api = noctaliaPluginApiFor(key) + if (!api) return null + + var comp = Qt.createComponent(url, Component.PreferSynchronous) + function finalize() { + if (comp.status !== Component.Ready) { + console.warn("noctalia service load failed for " + key + ": " + comp.errorString()) + return + } + var inst = comp.createObject(shell, { pluginApi: api }) + if (!inst) { + console.warn("noctalia service createObject returned null for", key) + return + } + var snext = ({}) + for (var sk in _noctaliaServices) snext[sk] = _noctaliaServices[sk] + snext[key] = inst + _noctaliaServices = snext + api.mainInstance = inst + } + if (comp.status === Component.Loading) { + comp.statusChanged.connect(finalize) + return null + } + finalize() + return _noctaliaServices[key] || null + } + + function _syncNoctaliaServices() { + if (!pluginRegistry || !pluginRegistry.installedPlugins) return + var plugins = pluginRegistry.installedPlugins + for (var id in plugins) { + var m = plugins[id] + if (!m || !m.__noctaliaCompat) continue + if (!m.entryPoints || !m.entryPoints.service) continue + if (!pluginRegistry.isEnabled(id)) continue + if (_noctaliaServices[id]) continue + ensureNoctaliaService(id) + } + // Drop services for plugins that have been disabled or removed. + for (var existingId in _noctaliaServices) { + var stillThere = plugins[existingId] + var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId) + if (stillThere && stillEnabled) continue + var inst = _noctaliaServices[existingId] + if (inst && typeof inst.destroy === "function") inst.destroy() + var next = ({}) + for (var k in _noctaliaServices) if (k !== existingId) next[k] = _noctaliaServices[k] + _noctaliaServices = next + var apis = ({}) + for (var ak in _noctaliaApis) apis[ak] = _noctaliaApis[ak] + if (apis[existingId]) apis[existingId].mainInstance = null + _noctaliaApis = apis + } + } + + Connections { + target: shell.pluginRegistry + function onPluginsChanged() { shell._syncNoctaliaServices() } + } + + // Used by Noctalia compat: pluginApi.saveSettings() ends up writing inline + // settings to the plugin's entry in shell.json. moduleName is the entry id + // (the bare manifest id, e.g. "noctalia.air-quality"); settings is the + // merged plugin state. Returns true if anything actually changed. + // Compute the proposed new shellConfig in a local clone, and only persist + // if anything actually changed. Lets Noctalia plugins call saveSettings() + // repeatedly with identical values (common for reactive QML bindings) + // without dirtying shell.json or thrashing the file watcher. + function updateEntryInline(moduleName, settings) { + var stripped = String(moduleName) + var copy = JSON.parse(JSON.stringify(shellConfig || builtinShellConfig)) + if (!isPlainObject(copy.bar)) copy.bar = { layout: { left: [], center: [], right: [] } } + if (!isPlainObject(copy.bar.layout)) copy.bar.layout = { left: [], center: [], right: [] } + if (!Array.isArray(copy.plugins)) copy.plugins = [] + + var sections = ["left", "center", "right"] + var foundInLayout = false + var dirty = false + for (var s = 0; s < sections.length; s++) { + var arr = copy.bar.layout[sections[s]] || [] + for (var i = 0; i < arr.length; i++) { + if (arr[i] && arr[i].id === stripped) { + var next = { id: stripped } + for (var k in settings) if (k !== "id") next[k] = settings[k] + if (JSON.stringify(arr[i]) !== JSON.stringify(next)) { + arr[i] = next + dirty = true + } + foundInLayout = true + } + } + } + if (!foundInLayout) { + for (var j = 0; j < copy.plugins.length; j++) { + if (copy.plugins[j] && copy.plugins[j].id === stripped) { + var pnext = { id: stripped } + for (var pk in settings) if (pk !== "id") pnext[pk] = settings[pk] + if (JSON.stringify(copy.plugins[j]) !== JSON.stringify(pnext)) { + copy.plugins[j] = pnext + dirty = true + } + } + } + } + if (!dirty) return false + persistShellConfig(copy) + return true } // ---------------------------------------------------------- on-demand panels @@ -206,6 +549,11 @@ ShellRoot { if ("manifest" in item) item.manifest = panelEntry.manifest if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry + // Noctalia panel/overlay/menu plugins expect a pluginApi just like + // their bar widgets do. First-party Omarchy plugins don't declare + // the property so the `in target` check skips them. + if (panelEntry.manifest && panelEntry.manifest.__noctaliaCompat && "pluginApi" in item) + item.pluginApi = shell.noctaliaPluginApiFor(panelEntry.pluginId) shell.registerPanelLoader(panelEntry.pluginId, this) } onStatusChanged: { @@ -224,7 +572,10 @@ ShellRoot { // 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:". + // its manifest entry point and registered under its plain manifest id. + // First-party widget ids (calendar, weather, etc.) are short and don't + // collide with namespaced plugin ids like noctalia.air-quality, so we don't + // need a separate "plugin:" namespace anymore. Connections { target: shell.pluginRegistry function onPluginsChanged() { shell.syncPluginWidgets() } @@ -241,7 +592,7 @@ ShellRoot { if (!manifest || !manifest.kinds || manifest.kinds.indexOf("bar-widget") === -1) continue if (!shell.pluginRegistry.isEnabled(pluginId)) continue - var registryKey = "plugin:" + manifest.id + var registryKey = String(manifest.id) seen[registryKey] = true // Already loaded with matching source — leave it alone. @@ -272,7 +623,7 @@ ShellRoot { var allIds = shell.barWidgetRegistry.availableIds() for (var i = 0; i < allIds.length; i++) { var id = allIds[i] - if (id.indexOf("plugin:") !== 0) continue + if (!pluginWidgetComponents[id]) continue if (!seen[id]) { shell.barWidgetRegistry.unregister(id) var next = ({}) @@ -335,6 +686,13 @@ ShellRoot { return JSON.stringify(out) } + // Returns the effective shell.json content as JSON. Useful for debugging + // and for CLI tools that want to inspect the merged state without + // re-implementing the load logic. + function listShellConfig(): string { + return JSON.stringify(shell.shellConfig || {}) + } + function summon(id: string, payloadJson: string): string { return shell.summon(id, payloadJson) ? "ok" : "unknown" }