Move bar-settings and background-switcher into the shell

This commit is contained in:
Ryan Hughes
2026-05-14 02:21:48 -04:00
parent c72a8756b3
commit 714e8a453b
14 changed files with 863 additions and 116 deletions
+1 -11
View File
@@ -5,14 +5,4 @@
# omarchy:name=bar-settings
# omarchy:examples=omarchy launch bar-settings
OMARCHY_PATH="${OMARCHY_PATH:-$HOME/.local/share/omarchy}"
CONFIG_DIR="$OMARCHY_PATH/default/quickshell/bar-settings"
if quickshell list -p "$CONFIG_DIR" 2>/dev/null | grep -q '^Instance '; then
if ! hyprctl dispatch focuswindow 'title:Omarchy bar settings' 2>/dev/null | grep -q ok; then
hyprctl dispatch focuswindow 'initialTitle:Omarchy bar settings' >/dev/null 2>&1
fi
exit 0
fi
setsid uwsm-app -- env OMARCHY_PATH="$OMARCHY_PATH" quickshell -p "$CONFIG_DIR" >/dev/null 2>&1 &
exec omarchy-shell-ipc shell summon omarchy.bar-settings "{}"
+5 -8
View File
@@ -84,7 +84,7 @@ done_file=$(mktemp)
rm -f "$done_file"
trap 'rm -f "$selection_file" "$done_file"' EXIT
socket_path="${XDG_RUNTIME_DIR:-/run/user/$UID}/omarchy-image-selector.sock"
selector_qml="$OMARCHY_PATH/default/quickshell/background-switcher.qml"
shell_dir="$OMARCHY_PATH/default/quickshell/omarchy-shell"
image_dirs_env=""
for dir in "${image_dirs[@]}"; do
@@ -230,14 +230,11 @@ if [[ $cache_only == true || $prepare_only == true ]]; then
fi
ensure_selector() {
if [[ -S $socket_path && $selector_qml -nt $socket_path ]]; then
quickshell kill -p "$selector_qml" >/dev/null 2>&1 || true
rm -f "$socket_path"
fi
# The image-selector socket is owned by omarchy-shell. Make sure the shell is
# alive; once it is, the BackgroundSwitcher plugin keeps the socket bound for
# the lifetime of the shell.
if [[ ! -S $socket_path ]]; then
quickshell kill -p "$selector_qml" >/dev/null 2>&1 || true
quickshell -d -p "$selector_qml" >/dev/null
omarchy-shell-ipc shell ping >/dev/null 2>&1 || true
for ((i = 0; i < 100; i++)); do
if [[ -S $socket_path ]]; then
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# omarchy:summary=Send an IPC call to omarchy-shell, starting it if not running
# omarchy:hidden=true
OMARCHY_PATH="${OMARCHY_PATH:-$HOME/.local/share/omarchy}"
SHELL_DIR="$OMARCHY_PATH/default/quickshell/omarchy-shell"
if ! quickshell list -p "$SHELL_DIR" 2>/dev/null | grep -q '^Instance '; then
setsid uwsm-app -- env OMARCHY_PATH="$OMARCHY_PATH" quickshell -p "$SHELL_DIR" >/dev/null 2>&1 &
for _ in 1 2 3 4 5 6 7 8 9 10; do
sleep 0.2
quickshell list -p "$SHELL_DIR" 2>/dev/null | grep -q '^Instance ' && break
done
fi
exec quickshell ipc -p "$SHELL_DIR" call "$@"
@@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"id": "omarchy.background-switcher",
"name": "Background switcher",
"version": "1.0.0",
"author": "Omarchy",
"description": "Wallpaper / image picker overlay",
"kinds": ["overlay"],
"activation": "on-demand",
"keepLoaded": true,
"entryPoints": { "overlay": "BackgroundSwitcher.qml" },
"ipc": {
"summon": "background-switcher",
"legacySocket": "omarchy-image-selector.sock"
}
}
@@ -4,19 +4,28 @@ import QtQuick.Layouts
import Quickshell
import Quickshell.Io
ShellRoot {
import "../../ui/settings" as SettingsUi
Item {
id: root
property string home: Quickshell.env("HOME")
function deriveOmarchyPath() {
// Injected by the host shell when the panel is summoned. Shared instances
// so the panel sees the same registry state the bar wrote into.
property var barWidgetRegistry: null
property var pluginRegistry: null
// Not `required` so the Loader-based instantiation can satisfy it via
// onLoaded; we still gracefully fall back to deriving from shellDir for
// standalone QML tooling.
property string omarchyPath: {
var env = Quickshell.env("OMARCHY_PATH")
if (env) return env
var dir = String(Quickshell.shellDir || "")
if (dir.indexOf("/default/quickshell/bar-settings") !== -1)
return dir.substring(0, dir.indexOf("/default/quickshell/bar-settings"))
return home + "/.local/share/omarchy"
if (dir.indexOf("/default/quickshell/omarchy-shell") !== -1)
return dir.substring(0, dir.indexOf("/default/quickshell/omarchy-shell"))
return Quickshell.env("HOME") + "/.local/share/omarchy"
}
property string omarchyPath: deriveOmarchyPath()
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"
@@ -26,6 +35,7 @@ ShellRoot {
property color urgent: "#a55555"
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
@@ -216,59 +226,98 @@ ShellRoot {
}
}
// Catalog of available widgets id display metadata + settings schema path.
// settingsForm is an inline-defined Component name that opens when editing.
readonly property var catalog: ({
"omarchy": { name: "Omarchy menu", description: "Launches the Omarchy menu" },
"workspaces": { name: "Workspaces (legacy)", description: "Workspace numbers, no animation" },
"workspacesPro": { name: "Workspaces", description: "Animated workspace switcher" },
"activeWindow": { name: "Active window", description: "Title of the focused window" },
"clock": { name: "Clock", description: "Date / time text" },
"calendar": { name: "Calendar", description: "Clock with month-grid popup", settingsForm: "calendarSettings" },
"media": { name: "Media", description: "MPRIS now-playing with controls" },
"weather": { name: "Weather (legacy)", description: "Tiny weather pill" },
"weatherFlyout": { name: "Weather", description: "Weather pill with detail popup" },
"update": { name: "Updates", description: "Indicates available system updates" },
"voxtype": { name: "Voxtype", description: "Voxtype dictation state" },
"screenRecording": { name: "Screen recording", description: "Active recording indicator" },
"idle": { name: "Idle (legacy)", description: "Inhibitor indicator" },
"idleInhibitor": { name: "Keep awake", description: "Idle inhibitor toggle" },
"notifications": { name: "DND (mako)", description: "Notification silencing indicator" },
"notificationCenter": { name: "Notification center", description: "Recent notifications + DND (replaces mako)" },
"tray": { name: "System tray", description: "Status notifier items" },
"bluetooth": { name: "Bluetooth (legacy)", description: "Bluetooth status icon" },
"bluetoothPanel": { name: "Bluetooth", description: "Bluetooth devices popup" },
"network": { name: "Network (legacy)", description: "Wi-Fi/ethernet status" },
"networkPanel": { name: "Network", description: "Wi-Fi list and connect" },
"audio": { name: "Volume (legacy)", description: "Speaker icon, scroll for volume" },
"audioPanel": { name: "Volume", description: "Volume slider, output picker, mixer" },
"microphone": { name: "Microphone", description: "Mic input state" },
"nightLight": { name: "Night light", description: "hyprsunset toggle" },
"brightness": { name: "Brightness", description: "Screen brightness slider", settingsForm: "brightnessSettings" },
"powerProfile": { name: "Power profile", description: "power-profiles-daemon selector" },
"battery": { name: "Battery", description: "Battery percent and ETA" },
"cpu": { name: "CPU (legacy)", description: "btop launcher" },
"systemStats": { name: "System stats", description: "Inline CPU + RAM graphs" },
"controlCenter": { name: "Quick settings", description: "Volume/brightness/DND/etc in one popup" },
"powerMenu": { name: "Power menu", description: "Lock/suspend/reboot/shutdown" },
"keyboardLayout": { name: "Keyboard layout", description: "Current xkb layout, click cycles" },
"lockKeys": { name: "Lock keys", description: "Caps/Num/Scroll lock indicators" },
"spacer": { name: "Spacer", description: "Configurable blank space", settingsForm: "spacerSettings" }
// Catalog is derived live from BarWidgetRegistry (first-party + third-party
// plugin widgets registered at runtime) plus a small legacy descriptor map
// for builtins that Bar.qml renders inline via builtinModuleComponent and
// hasn't migrated into the registry yet. The merged catalog is rebuilt
// whenever the registry revision changes.
readonly property var legacyWidgetMeta: ({
"omarchy": { name: "Omarchy menu", description: "Launches the Omarchy menu", category: "Compositor" },
"workspaces": { name: "Workspaces (legacy)", description: "Workspace numbers, no animation", category: "Compositor" },
"clock": { name: "Clock", description: "Date / time text", category: "Time" },
"weather": { name: "Weather (legacy)", description: "Tiny weather pill", category: "Info" },
"update": { name: "Updates", description: "Indicates available system updates", category: "System" },
"voxtype": { name: "Voxtype", description: "Voxtype dictation state", category: "Status" },
"screenRecording": { name: "Screen recording", description: "Active recording indicator", category: "Status" },
"idle": { name: "Idle (legacy)", description: "Inhibitor indicator", category: "Status" },
"notifications": { name: "DND (mako)", description: "Notification silencing indicator", category: "Status" },
"tray": { name: "System tray", description: "Status notifier items", category: "Status" },
"bluetooth": { name: "Bluetooth (legacy)", description: "Bluetooth status icon", category: "Network" },
"network": { name: "Network (legacy)", description: "Wi-Fi / ethernet status", category: "Network" },
"audio": { name: "Volume (legacy)", description: "Speaker icon, scroll for volume", category: "Audio" },
"cpu": { name: "CPU (legacy)", description: "btop launcher", category: "System" },
"battery": { name: "Battery", description: "Battery percent and ETA", category: "System" }
})
property int catalogRevision: 0
// Bump on every registry assignment (including the initial null instance
// injection from Loader.onLoaded) so bindings that derive from
// widgetMetadata pick up the new state.
onBarWidgetRegistryChanged: catalogRevision++
Connections {
target: root.barWidgetRegistry
function onChanged() {
root.catalogRevision++
}
}
function widgetMetadata(id) {
var key = String(id || "")
if (root.barWidgetRegistry && root.barWidgetRegistry.has(key))
return root.barWidgetRegistry.metadataFor(key) || {}
if (legacyWidgetMeta[key]) return legacyWidgetMeta[key]
return {}
}
function widgetName(id) {
return catalog[id] ? catalog[id].name : id
var rev = catalogRevision
var meta = widgetMetadata(id)
return meta.displayName || meta.name || id
}
function widgetDescription(id) {
return catalog[id] ? (catalog[id].description || "") : ""
var rev = catalogRevision
var meta = widgetMetadata(id)
return meta.description || ""
}
function widgetSchema(id) {
var meta = widgetMetadata(id)
return Array.isArray(meta.schema) ? meta.schema : []
}
function widgetHasSettings(id) {
return !!(catalog[id] && catalog[id].settingsForm)
var rev = catalogRevision
var meta = widgetMetadata(id)
if (meta.settingsForm) return true
return widgetSchema(id).length > 0
}
function widgetIsPlugin(id) {
var meta = widgetMetadata(id)
return meta.source === "plugin" || String(id).indexOf("plugin:") === 0
}
function widgetAllowsMultiple(id) {
var meta = widgetMetadata(id)
if (meta.allowMultiple === true) return true
return String(id) === "spacer"
}
function catalogIds() {
var rev = catalogRevision
var ids = {}
if (root.barWidgetRegistry) {
var registered = root.barWidgetRegistry.availableIds()
for (var i = 0; i < registered.length; i++) ids[registered[i]] = true
}
for (var key in legacyWidgetMeta) ids[key] = true
return Object.keys(ids)
}
function availableToAdd(section) {
var rev = catalogRevision
var existingByOther = {}
var sections = ["left", "center", "right"]
for (var s = 0; s < sections.length; s++) {
@@ -280,26 +329,26 @@ ShellRoot {
var here = draft.layout[section] || []
for (var j = 0; j < here.length; j++) existingHere[here[j].id] = true
var ids = Object.keys(catalog).sort(function(a, b) {
var ids = catalogIds().sort(function(a, b) {
return widgetName(a).localeCompare(widgetName(b))
})
var result = []
for (var k = 0; k < ids.length; k++) {
var id = ids[k]
// Allow multiple instances of `spacer` only.
var allowMultiple = id === "spacer"
if (!allowMultiple && existingHere[id]) continue
if (!widgetAllowsMultiple(id) && existingHere[id]) continue
result.push({ id: id, name: widgetName(id), description: widgetDescription(id), elsewhere: !!existingByOther[id] })
}
return result
}
Component.onCompleted: {
console.log("bar-settings paths",
"omarchyPath=" + root.omarchyPath,
console.log("bar-settings open. omarchyPath=" + root.omarchyPath,
"defaultsPath=" + root.defaultsPath,
"userConfigPath=" + root.userConfigPath)
"userConfigPath=" + root.userConfigPath,
"registry has",
root.barWidgetRegistry ? root.barWidgetRegistry.availableIds().length : "(null)",
"widgets")
}
FileView {
@@ -424,24 +473,57 @@ ShellRoot {
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
}
Row {
Layout.fillWidth: true
spacing: 0
TabButton {
label: "Layout"
selected: root.activeTab === "layout"
onClicked: root.activeTab = "layout"
}
TabButton {
label: "Plugins"
selected: root.activeTab === "plugins"
onClicked: root.activeTab = "plugins"
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
}
Flickable {
id: bodyScroll
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
contentWidth: width
contentHeight: bodyColumn.implicitHeight
contentHeight: activeTabContent.implicitHeight
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ColumnLayout {
id: bodyColumn
id: activeTabContent
width: bodyScroll.width
spacing: 14
SectionEditor { sectionKey: "left"; sectionLabel: "Left" }
SectionEditor { sectionKey: "center"; sectionLabel: "Center" }
SectionEditor { sectionKey: "right"; sectionLabel: "Right" }
ColumnLayout {
visible: root.activeTab === "layout"
Layout.fillWidth: true
spacing: 14
SectionEditor { sectionKey: "left"; sectionLabel: "Left" }
SectionEditor { sectionKey: "center"; sectionLabel: "Center" }
SectionEditor { sectionKey: "right"; sectionLabel: "Right" }
}
PluginManager {
visible: root.activeTab === "plugins"
Layout.fillWidth: true
}
}
}
}
@@ -843,13 +925,24 @@ ShellRoot {
}
function formComponent(id) {
var cat = catalog[id]
if (!cat || !cat.settingsForm) return null
switch (cat.settingsForm) {
case "spacerSettings": return spacerSettingsComponent
case "calendarSettings": return calendarSettingsComponent
case "brightnessSettings": return brightnessSettingsComponent
default: return null
var meta = widgetMetadata(id)
if (meta && meta.settingsForm) {
switch (meta.settingsForm) {
case "spacerSettings": return spacerSettingsComponent
case "calendarSettings": return calendarSettingsComponent
case "brightnessSettings": return brightnessSettingsComponent
}
}
if (widgetSchema(id).length > 0) return dynamicSettingsComponent
return null
}
Component {
id: dynamicSettingsComponent
SettingsUi.DynamicSettingsForm {
schema: root.widgetSchema(entry.id || "")
foregroundColor: root.foreground
fontFamilyName: root.fontFamily
}
}
@@ -960,4 +1053,249 @@ ShellRoot {
}
}
}
component TabButton: Rectangle {
id: tab
property string label: ""
property bool selected: false
signal clicked()
implicitWidth: tabLabel.implicitWidth + 28
implicitHeight: 32
color: tabArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
id: tabLabel
anchors.centerIn: parent
text: tab.label
color: tab.selected ? root.foreground : Qt.darker(root.foreground, 1.6)
font.family: root.fontFamily
font.pixelSize: 12
font.bold: tab.selected
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
color: tab.selected ? root.foreground : "transparent"
}
MouseArea {
id: tabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: tab.clicked()
}
}
component PluginManager: Column {
id: pm
property int registryRevision: root.pluginRegistry ? root.pluginRegistry.registryRevision : 0
Connections {
target: root.pluginRegistry
function onPluginsChanged() { pm.registryRevision = root.pluginRegistry.registryRevision }
}
function pluginList() {
var rev = pm.registryRevision
if (!root.pluginRegistry) return []
var plugins = root.pluginRegistry.installedPlugins
var ids = Object.keys(plugins).sort(function(a, b) {
var fa = !!plugins[a].__isFirstParty, fb = !!plugins[b].__isFirstParty
if (fa !== fb) return fa ? -1 : 1
return String(plugins[a].name || a).localeCompare(String(plugins[b].name || b))
})
var rows = []
for (var i = 0; i < ids.length; i++) {
var id = ids[i]
var m = plugins[id]
rows.push({
id: id,
manifest: m,
enabled: root.pluginRegistry.isEnabled(id),
firstParty: !!m.__isFirstParty
})
}
return rows
}
spacing: 10
width: parent ? parent.width : 0
Row {
spacing: 8
width: parent.width
Text {
text: "Plugins"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: 14
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "· " + (root.pluginRegistry ? Object.keys(root.pluginRegistry.installedPlugins).length : 0) + " installed"
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
Item { width: parent.width - 280; height: 1 }
ActionPill {
text: "Rescan"
onClicked: root.pluginRegistry.rescan()
}
}
Text {
text: "Drop plugins at ~/.config/omarchy/plugins/<plugin-id>/"
color: Qt.darker(root.foreground, 1.6)
font.family: root.fontFamily
font.pixelSize: 10
}
Repeater {
model: pm.pluginList()
delegate: PluginRow {
required property var modelData
width: pm.width
manifest: modelData.manifest
pluginId: modelData.id
pluginEnabled: modelData.enabled
firstParty: modelData.firstParty
}
}
Text {
visible: pm.pluginList().length === 0
text: "No plugins discovered yet."
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: 11
}
}
component PluginRow: Rectangle {
id: row
property var manifest: ({})
property string pluginId: ""
property bool pluginEnabled: false
property bool firstParty: false
property bool expanded: false
radius: 4
color: rowArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03)
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
border.width: 1
implicitHeight: rowContent.implicitHeight + 16
Behavior on color { ColorAnimation { duration: 100 } }
Column {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
spacing: 4
Row {
spacing: 8
width: parent.width
Column {
spacing: 2
width: parent.width - 110
Text {
text: row.manifest && row.manifest.name ? row.manifest.name : row.pluginId
color: root.foreground
font.family: root.fontFamily
font.pixelSize: 12
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
text: {
var bits = []
if (row.manifest && row.manifest.version) bits.push("v" + row.manifest.version)
if (row.manifest && row.manifest.author) bits.push(row.manifest.author)
bits.push(row.firstParty ? "first-party" : "third-party")
return bits.join(" · ")
}
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: 10
}
Text {
visible: !!(row.manifest && row.manifest.description)
text: row.manifest ? (row.manifest.description || "") : ""
color: Qt.darker(root.foreground, 1.3)
font.family: root.fontFamily
font.pixelSize: 10
wrapMode: Text.WordWrap
width: parent.width
}
}
Item { width: 8; height: 1 }
Switch {
checked: row.pluginEnabled
enabled: !row.firstParty
opacity: row.firstParty ? 0.5 : 1
anchors.verticalCenter: parent.verticalCenter
onToggled: root.pluginRegistry.setEnabled(row.pluginId, checked)
}
}
Row {
visible: !!(row.manifest && row.manifest.barWidget && Array.isArray(row.manifest.barWidget.schema) && row.manifest.barWidget.schema.length > 0)
spacing: 6
Text {
text: row.expanded ? "▾ Options" : "▸ Options"
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: 10
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: row.expanded = !row.expanded
}
}
}
Repeater {
model: row.expanded && row.manifest && row.manifest.barWidget && Array.isArray(row.manifest.barWidget.schema) ? row.manifest.barWidget.schema : []
delegate: Text {
required property var modelData
text: "• " + (modelData.label || modelData.key) + " (" + (modelData.type || "string") + ")"
color: Qt.darker(root.foreground, 1.3)
font.family: root.fontFamily
font.pixelSize: 10
leftPadding: 12
}
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
}
}
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"id": "omarchy.bar-settings",
"name": "Bar settings",
"version": "1.0.0",
"author": "Omarchy",
"description": "Visual customizer for the Omarchy bar",
"kinds": ["panel"],
"activation": "on-demand",
"entryPoints": { "panel": "BarSettingsPanel.qml" }
}
@@ -10,14 +10,15 @@ import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
import "../../services" as Services
Item {
id: root
// The omarchy-shell host injects omarchyPath when it instantiates this Bar.
// Default fallback keeps the file loadable in isolation (e.g. for QML tooling).
required property string omarchyPath
// Injected by the host shell. Shared with the bar-settings panel so both
// see the same widget catalogue.
required property var barWidgetRegistry
property string home: Quickshell.env("HOME")
property string omarchyConfigDir: home + "/.config/omarchy"
property var builtinBarConfig: ({
@@ -306,9 +307,9 @@ Item {
"audioPanel": { displayName: "Audio", description: "Volume slider, output picker, per-app mixer", category: "Audio", allowMultiple: false },
"networkPanel": { displayName: "Network", description: "Wi-Fi list and connection state", category: "Network", allowMultiple: false },
"bluetoothPanel": { displayName: "Bluetooth", description: "Bluetooth device list with connect/disconnect", category: "Network", allowMultiple: false },
"calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false },
"calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false, settingsForm: "calendarSettings" },
"notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND (replaces mako)", category: "Status", allowMultiple: false },
"brightness": { displayName: "Brightness", description: "Screen brightness slider", category: "System", allowMultiple: false },
"brightness": { displayName: "Brightness", description: "Screen brightness slider", category: "System", allowMultiple: false, settingsForm: "brightnessSettings" },
"powerProfile": { displayName: "Power profile", description: "power-profiles-daemon selector", category: "System", allowMultiple: false },
"systemStats": { displayName: "System stats", description: "Inline CPU + memory sparklines", category: "System", allowMultiple: false },
"weatherFlyout": { displayName: "Weather", description: "Weather pill with detail popup", category: "Info", allowMultiple: false },
@@ -320,7 +321,7 @@ Item {
"nightLight": { displayName: "Night light", description: "hyprsunset toggle", category: "System", allowMultiple: false },
"keyboardLayout": { displayName: "Keyboard layout", description: "Current xkb layout, click cycles", category: "Compositor", allowMultiple: false },
"lockKeys": { displayName: "Lock keys", description: "Caps / Num / Scroll lock indicators", category: "System", allowMultiple: false },
"spacer": { displayName: "Spacer", description: "Configurable blank space", category: "Layout", allowMultiple: true },
"spacer": { displayName: "Spacer", description: "Configurable blank space", category: "Layout", allowMultiple: true, settingsForm: "spacerSettings" },
"controlCenter": { displayName: "Quick settings", description: "Volume / brightness / DND in one popup", category: "System", allowMultiple: false }
})
@@ -332,7 +333,7 @@ Item {
var ids = Object.keys(firstPartyWidgetMetadata)
for (var i = 0; i < ids.length; i++) {
var id = ids[i]
if (Services.BarWidgetRegistry.has(id)) continue
if (barWidgetRegistry.has(id)) continue
registerOneFirstPartyWidget(id)
}
}
@@ -345,12 +346,13 @@ Item {
description: meta.description || "",
category: meta.category || "Misc",
allowMultiple: meta.allowMultiple === true,
settingsForm: meta.settingsForm || "",
source: "first-party"
}
var comp = Qt.createComponent(url, Component.Asynchronous)
function finalize() {
if (comp.status === Component.Ready) {
Services.BarWidgetRegistry.register(id, comp, enrichedMeta)
barWidgetRegistry.register(id, comp, enrichedMeta)
var next = ({})
for (var k in registeredFirstPartyComponents) next[k] = registeredFirstPartyComponents[k]
next[id] = comp
@@ -1179,7 +1181,7 @@ Item {
// plugin enabled/disabled, etc.). Reading the `widgets` property creates
// the binding dependency — the wrapped function call alone wouldn't.
readonly property var registryComponent: {
var w = Services.BarWidgetRegistry.widgets
var w = root.barWidgetRegistry.widgets
if (customType || builtinComponent) return null
return w[moduleName] ? w[moduleName].component : null
}
@@ -1,7 +1,9 @@
pragma Singleton
import QtQuick
// Instance, not a singleton — instantiated once by shell.qml and injected into
// plugins that need to read or extend the widget catalogue. Relative-path
// singleton imports were creating per-importer instances which prevented the
// settings panel from seeing what the bar registered.
QtObject {
id: registry
@@ -1,9 +1,8 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
// Instance, not a singleton — see BarWidgetRegistry for rationale.
QtObject {
id: registry
@@ -1,3 +1,3 @@
module omarchy.services
singleton PluginRegistry 1.0 PluginRegistry.qml
singleton BarWidgetRegistry 1.0 BarWidgetRegistry.qml
PluginRegistry 1.0 PluginRegistry.qml
BarWidgetRegistry 1.0 BarWidgetRegistry.qml
+201 -16
View File
@@ -1,13 +1,21 @@
import QtQuick
import QtQml.Models
import Quickshell
import Quickshell.Io
import "plugins/bar"
import "services" as Services
import "services"
ShellRoot {
id: shell
// Shared service instances. Plugins receive these via property injection
// rather than re-importing them as singletons — relative-path imports do
// not share singleton state, which silently leaves consumers with their
// own empty copies.
property PluginRegistry pluginRegistry: PluginRegistry { }
property BarWidgetRegistry barWidgetRegistry: BarWidgetRegistry { }
property string home: Quickshell.env("HOME")
// The omarchy-shell host is the long-running entry point. Plugins live in
@@ -30,16 +38,181 @@ ShellRoot {
"omarchyPath=" + shell.omarchyPath,
"shellDir=" + Quickshell.shellDir,
"firstPartyPluginsDir=" + shell.firstPartyPluginsDir)
Services.PluginRegistry.firstPartyDir = shell.firstPartyPluginsDir
pluginRegistry.firstPartyDir = shell.firstPartyPluginsDir
// PluginRegistry.ensureUserDir() runs in its own Component.onCompleted and
// chains rescan() once the directory exists. We also kick a scan here in
// case the user dir already existed at startup.
Services.PluginRegistry.rescan()
pluginRegistry.rescan()
}
Bar {
id: bar
omarchyPath: shell.omarchyPath
barWidgetRegistry: shell.barWidgetRegistry
}
// ---------------------------------------------------------- on-demand panels
// openPanelIds is a plain object treated as a set. A plugin id maps to
// `true` while the panel is summoned; deleting the key (well, building a new
// object without it) hides it. Reassigning the whole object is required for
// QML to notice the change.
property var openPanelIds: ({})
property var panelCache: ({})
function isPanelOpen(id) { return openPanelIds[id] === true }
// Pending payloads to deliver to a plugin's open() once its loader resolves.
// Keyed by plugin id; consumed by the Loader.onLoaded handler below.
property var pendingPayloads: ({})
function summon(pluginId, payloadJson) {
var id = String(pluginId || "")
if (!id) return false
var plugins = shell.pluginRegistry.installedPlugins
if (!plugins[id]) {
console.warn("summon: unknown plugin", id)
return false
}
var next = ({})
for (var k in openPanelIds) next[k] = openPanelIds[k]
next[id] = true
openPanelIds = next
// Stash payload so the Loader.onLoaded handler can hand it to open().
var pending = ({})
for (var p in pendingPayloads) pending[p] = pendingPayloads[p]
pending[id] = payloadJson || ""
pendingPayloads = pending
// If the plugin is keepLoaded and already mounted, deliver immediately.
deliverIfLoaded(id)
return true
}
function hide(pluginId) {
var id = String(pluginId || "")
if (!id) return false
invokeIfLoaded(id, "close", null)
if (!openPanelIds[id]) return true
var next = ({})
for (var k in openPanelIds) if (k !== id) next[k] = openPanelIds[k]
openPanelIds = next
return true
}
function toggle(pluginId, payloadJson) {
var id = String(pluginId || "")
return openPanelIds[id] ? hide(id) : summon(id, payloadJson)
}
// Map of pluginId -> Loader, populated by the Instantiator delegate below.
property var panelLoaders: ({})
function registerPanelLoader(pluginId, loader) {
var next = ({})
for (var k in panelLoaders) next[k] = panelLoaders[k]
next[pluginId] = loader
panelLoaders = next
deliverIfLoaded(pluginId)
}
function unregisterPanelLoader(pluginId) {
if (!panelLoaders[pluginId]) return
var next = ({})
for (var k in panelLoaders) if (k !== pluginId) next[k] = panelLoaders[k]
panelLoaders = next
}
function deliverIfLoaded(pluginId) {
var loader = panelLoaders[pluginId]
if (!loader || !loader.item) return
var payload = pendingPayloads[pluginId]
if (payload === undefined) return
if (typeof loader.item.open === "function") {
try { loader.item.open(payload) } catch (e) {
console.warn("plugin " + pluginId + " open() threw:", e)
}
}
var next = ({})
for (var k in pendingPayloads) if (k !== pluginId) next[k] = pendingPayloads[k]
pendingPayloads = next
}
function invokeIfLoaded(pluginId, method, arg) {
var loader = panelLoaders[pluginId]
if (!loader || !loader.item) return
if (typeof loader.item[method] !== "function") return
try { loader.item[method](arg) } catch (e) {
console.warn("plugin " + pluginId + " " + method + "() threw:", e)
}
}
// One Loader per discoverable panel/overlay/menu plugin. Active when the
// host marks it open. The Loader holds onto the instance while active so the
// plugin's FloatingWindow + state survive between summons within a session.
property var panelEntries: []
function computePanelEntries() {
var out = []
var plugins = shell.pluginRegistry.installedPlugins
var panelKinds = ["panel", "overlay", "menu"]
for (var id in plugins) {
var m = plugins[id]
if (!m || !Array.isArray(m.kinds)) continue
var matched = false
for (var i = 0; i < panelKinds.length; i++)
if (m.kinds.indexOf(panelKinds[i]) !== -1) { matched = true; break }
if (!matched) continue
if (!shell.pluginRegistry.isEnabled(id)) continue
var kind = m.kinds.indexOf("panel") !== -1 ? "panel"
: (m.kinds.indexOf("overlay") !== -1 ? "overlay" : "menu")
out.push({ id: id, manifest: m, kind: kind, keepLoaded: m.keepLoaded === true })
}
return out
}
Connections {
target: shell.pluginRegistry
function onPluginsChanged() { shell.panelEntries = shell.computePanelEntries() }
}
Instantiator {
model: shell.panelEntries
active: true
delegate: QtObject {
id: panelEntry
required property var modelData
readonly property string pluginId: modelData.id
readonly property var manifest: modelData.manifest
readonly property string entryKind: modelData.kind
readonly property bool keepLoaded: modelData.keepLoaded === true
readonly property string sourceUrl: shell.pluginRegistry.entryPointUrl(manifest, entryKind)
property Loader panelLoader: Loader {
source: panelEntry.sourceUrl
active: panelEntry.sourceUrl !== "" && (panelEntry.keepLoaded || shell.openPanelIds[panelEntry.pluginId] === true)
asynchronous: true
onLoaded: {
if (!item) return
if ("omarchyPath" in item) item.omarchyPath = shell.omarchyPath
if ("shell" in item) item.shell = shell
if ("manifest" in item) item.manifest = panelEntry.manifest
if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry
if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry
shell.registerPanelLoader(panelEntry.pluginId, this)
}
onStatusChanged: {
if (status === Loader.Error) {
console.warn("panel plugin " + panelEntry.pluginId + " failed:",
sourceComponent ? sourceComponent.errorString() : "")
shell.hide(panelEntry.pluginId)
}
}
Component.onDestruction: shell.unregisterPanelLoader(panelEntry.pluginId)
}
}
}
// ---------------------------------------------------------- plugin loader
@@ -48,32 +221,32 @@ ShellRoot {
// Each enabled plugin with kind "bar-widget" gets a Component created from
// its manifest entry point and registered under the id "plugin:<id>".
Connections {
target: Services.PluginRegistry
target: shell.pluginRegistry
function onPluginsChanged() { shell.syncPluginWidgets() }
}
property var pluginWidgetComponents: ({})
function syncPluginWidgets() {
var plugins = Services.PluginRegistry.installedPlugins
var plugins = shell.pluginRegistry.installedPlugins
var seen = ({})
for (var pluginId in plugins) {
var manifest = plugins[pluginId]
if (!manifest || !manifest.kinds || manifest.kinds.indexOf("bar-widget") === -1) continue
if (!Services.PluginRegistry.isEnabled(pluginId)) continue
if (!shell.pluginRegistry.isEnabled(pluginId)) continue
var registryKey = "plugin:" + manifest.id
seen[registryKey] = true
// Already loaded with matching source — leave it alone.
var existing = pluginWidgetComponents[registryKey]
var url = Services.PluginRegistry.entryPointUrl(manifest, "barWidget")
var url = shell.pluginRegistry.entryPointUrl(manifest, "barWidget")
if (!url) {
console.warn("Plugin " + manifest.id + " has no barWidget entry point")
continue
}
if (existing && existing.url === url && Services.BarWidgetRegistry.has(registryKey)) continue
if (existing && existing.url === url && shell.barWidgetRegistry.has(registryKey)) continue
var meta = manifest.barWidget || {}
meta = {
@@ -91,12 +264,12 @@ ShellRoot {
}
// Drop registrations for plugins that are no longer present or enabled.
var allIds = Services.BarWidgetRegistry.availableIds()
var allIds = shell.barWidgetRegistry.availableIds()
for (var i = 0; i < allIds.length; i++) {
var id = allIds[i]
if (id.indexOf("plugin:") !== 0) continue
if (!seen[id]) {
Services.BarWidgetRegistry.unregister(id)
shell.barWidgetRegistry.unregister(id)
var next = ({})
for (var k in pluginWidgetComponents) if (k !== id) next[k] = pluginWidgetComponents[k]
pluginWidgetComponents = next
@@ -108,14 +281,14 @@ ShellRoot {
var comp = Qt.createComponent(url, Component.Asynchronous)
function finalize() {
if (comp.status === Component.Ready) {
Services.BarWidgetRegistry.register(registryKey, comp, meta)
shell.barWidgetRegistry.register(registryKey, comp, meta)
var next = ({})
for (var k in pluginWidgetComponents) next[k] = pluginWidgetComponents[k]
next[registryKey] = { url: url, component: comp }
pluginWidgetComponents = next
} else if (comp.status === Component.Error) {
console.warn("Plugin widget " + registryKey + " failed: " + comp.errorString())
Services.PluginRegistry.pluginLoadFailed(registryKey, comp.errorString())
shell.pluginRegistry.pluginLoadFailed(registryKey, comp.errorString())
}
}
if (comp.status === Component.Loading) {
@@ -135,26 +308,38 @@ ShellRoot {
}
function rescanPlugins(): void {
Services.PluginRegistry.rescan()
shell.pluginRegistry.rescan()
}
function setPluginEnabled(id: string, enabled: string): void {
Services.PluginRegistry.setEnabled(id, enabled === "true")
shell.pluginRegistry.setEnabled(id, enabled === "true")
}
function listPlugins(): string {
var out = []
var plugins = Services.PluginRegistry.installedPlugins
var plugins = shell.pluginRegistry.installedPlugins
for (var id in plugins) {
out.push({
id: id,
name: plugins[id].name,
kinds: plugins[id].kinds,
enabled: Services.PluginRegistry.isEnabled(id),
enabled: shell.pluginRegistry.isEnabled(id),
firstParty: !!plugins[id].__isFirstParty
})
}
return JSON.stringify(out)
}
function summon(id: string, payloadJson: string): string {
return shell.summon(id, payloadJson) ? "ok" : "unknown"
}
function hide(id: string): void {
shell.hide(id)
}
function toggle(id: string, payloadJson: string): void {
shell.toggle(id, payloadJson)
}
}
}
@@ -0,0 +1,188 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Generic schema-driven settings form. The host panel passes:
// - schema: array of { key, label, type, defaultValue?, options?, min?, max?, step?, description? }
// - entry: current widget entry object (set by the loader); fields override defaults
// - signal fieldChanged(string key, var value) — emitted on user edits
//
// Supported types: boolean, enum, integer, number, string, path, command, color.
Column {
id: root
signal fieldChanged(string key, var value)
property var schema: []
property var entry: ({})
property color foregroundColor: "#cacccc"
property string fontFamilyName: "JetBrainsMono Nerd Font"
spacing: 10
width: parent ? parent.width : 0
function currentValue(field) {
if (entry && entry[field.key] !== undefined) return entry[field.key]
if (field.defaultValue !== undefined) return field.defaultValue
switch (field.type) {
case "boolean": return false
case "integer":
case "number": return field.min !== undefined ? field.min : 0
case "enum": return field.options && field.options.length > 0 ? field.options[0] : ""
default: return ""
}
}
Repeater {
model: root.schema
Column {
required property var modelData
width: root.width
spacing: 4
Text {
text: modelData && modelData.label ? modelData.label : (modelData && modelData.key ? modelData.key : "")
color: Qt.darker(root.foregroundColor, 1.3)
font.family: root.fontFamilyName
font.pixelSize: 11
font.bold: true
visible: text !== ""
}
Text {
visible: !!(modelData && modelData.description)
text: modelData ? (modelData.description || "") : ""
color: Qt.darker(root.foregroundColor, 1.6)
font.family: root.fontFamilyName
font.pixelSize: 10
wrapMode: Text.WordWrap
width: parent.width
}
Loader {
sourceComponent: {
if (!modelData || !modelData.type) return stringField
switch (String(modelData.type)) {
case "boolean": return booleanField
case "enum": return enumField
case "integer": return integerField
case "number": return numberField
case "color":
case "string":
case "path":
case "command":
default: return stringField
}
}
onLoaded: if (item && "fieldKey" in item) {
item.fieldKey = modelData.key
item.field = modelData
}
}
Component {
id: stringField
TextField {
property string fieldKey: ""
property var field: ({})
width: parent.width
font.family: root.fontFamilyName
font.pixelSize: 12
text: root.currentValue(field) === undefined ? "" : String(root.currentValue(field))
onEditingFinished: if (fieldKey) root.fieldChanged(fieldKey, text)
}
}
Component {
id: booleanField
CheckBox {
property string fieldKey: ""
property var field: ({})
font.family: root.fontFamilyName
font.pixelSize: 12
text: field && field.label ? "" : ""
checked: !!root.currentValue(field)
onToggled: if (fieldKey) root.fieldChanged(fieldKey, checked)
}
}
Component {
id: enumField
ComboBox {
property string fieldKey: ""
property var field: ({})
width: parent.width
font.family: root.fontFamilyName
font.pixelSize: 12
model: field && field.options ? field.options : []
currentIndex: {
var v = root.currentValue(field)
for (var i = 0; i < (field && field.options ? field.options.length : 0); i++)
if (field.options[i] === v) return i
return 0
}
onActivated: function(index) {
if (fieldKey && field && field.options) root.fieldChanged(fieldKey, field.options[index])
}
}
}
Component {
id: integerField
SpinBox {
property string fieldKey: ""
property var field: ({})
from: field && field.min !== undefined ? field.min : 0
to: field && field.max !== undefined ? field.max : 9999
stepSize: field && field.step !== undefined ? field.step : 1
value: {
var v = root.currentValue(field)
var n = typeof v === "number" ? v : parseInt(String(v || 0), 10)
return isFinite(n) ? n : 0
}
onValueModified: if (fieldKey) root.fieldChanged(fieldKey, value)
}
}
Component {
id: numberField
Row {
property string fieldKey: ""
property var field: ({})
width: parent.width
spacing: 8
property real currentNumber: {
var v = root.currentValue(field)
var n = typeof v === "number" ? v : parseFloat(String(v || 0))
return isFinite(n) ? n : 0
}
Slider {
id: slider
width: parent.width - readout.width - 8
from: field && field.min !== undefined ? field.min : 0
to: field && field.max !== undefined ? field.max : 1
stepSize: field && field.step !== undefined ? field.step : 0.01
value: parent.currentNumber
onMoved: if (parent.fieldKey) root.fieldChanged(parent.fieldKey, value)
}
Text {
id: readout
text: slider.value.toFixed(2)
color: root.foregroundColor
font.family: root.fontFamilyName
font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
Text {
visible: !root.schema || root.schema.length === 0
text: "No settings."
color: Qt.darker(root.foregroundColor, 1.5)
font.family: root.fontFamilyName
font.pixelSize: 11
}
}
@@ -0,0 +1,2 @@
module omarchy.ui.settings
DynamicSettingsForm 1.0 DynamicSettingsForm.qml