Unify config into shell.json and add Noctalia plugin support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user