Unify config into shell.json and add Noctalia plugin support

This commit is contained in:
Ryan Hughes
2026-05-14 02:21:48 -04:00
parent 5c0f5669c9
commit bcbe12b075
49 changed files with 2178 additions and 350 deletions
+5 -4
View File
@@ -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
+7 -2
View File
@@ -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)
+10 -7
View File
@@ -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
+1
View File
@@ -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 ""
+7 -3
View File
@@ -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)
@@ -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()
}
}
@@ -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()
}
@@ -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()]
}
}
@@ -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)) }
}
@@ -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 */ }
}
@@ -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
}
@@ -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
}
}
@@ -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 "" }
}
@@ -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")
}
}
@@ -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
+60 -7
View File
@@ -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/<id>/` | user | manifest + entry points + assets |
| `~/.config/omarchy/plugins/<id>/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/<id>/` | 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.<section>`
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.
@@ -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
}
@@ -0,0 +1,2 @@
module qs.Services.Power
singleton PowerProfileService 1.0 PowerProfileService.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
}
@@ -0,0 +1,2 @@
module qs.Services.System
singleton HostService 1.0 HostService.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 : "" }))
}
}
}
@@ -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
}
}
@@ -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)
}
}
}
@@ -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
@@ -0,0 +1,9 @@
import QtQuick
import qs.Commons
Rectangle {
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Style.borderS
radius: Style.radiusM
}
@@ -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
}
}
@@ -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
}
@@ -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
}
}
@@ -0,0 +1,8 @@
import QtQuick
import qs.Commons
Rectangle {
implicitHeight: 1
color: Color.mOutline
opacity: 0.4
}
@@ -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
}
@@ -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) }
}
}
@@ -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) }
}
}
@@ -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
}
}
@@ -0,0 +1,8 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
Slider {
id: root
property string label: ""
}
@@ -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
}
@@ -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
}
@@ -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
}
}
@@ -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
}
}
@@ -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
@@ -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) }
}
}
}
@@ -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/<lang>.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.
@@ -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
@@ -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.<section>; "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
@@ -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 {
@@ -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": "<widget>" }`, 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.<section>` 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" }
]
}
}
}
```
@@ -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" }
]
}
}
@@ -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
@@ -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" }
]
}
+362 -4
View File
@@ -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:<id>".
// 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"
}