From 714e8a453b7059e52d0d93ffcebf4173277340d9 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Wed, 13 May 2026 18:03:01 -0400 Subject: [PATCH] Move bar-settings and background-switcher into the shell --- bin/omarchy-launch-bar-settings | 12 +- bin/omarchy-menu-images | 13 +- bin/omarchy-shell-ipc | 17 + .../BackgroundSwitcher.qml} | 0 .../plugins/background-switcher/manifest.json | 16 + .../bar-settings/BarSettingsPanel.qml} | 472 +++++++++++++++--- .../plugins/bar-settings/manifest.json | 11 + .../omarchy-shell/plugins/bar/Bar.qml | 18 +- .../services/BarWidgetRegistry.qml | 6 +- .../omarchy-shell/services/PluginRegistry.qml | 3 +- .../quickshell/omarchy-shell/services/qmldir | 4 +- default/quickshell/omarchy-shell/shell.qml | 217 +++++++- .../ui/settings/DynamicSettingsForm.qml | 188 +++++++ .../omarchy-shell/ui/settings/qmldir | 2 + 14 files changed, 863 insertions(+), 116 deletions(-) create mode 100755 bin/omarchy-shell-ipc rename default/quickshell/{background-switcher.qml => omarchy-shell/plugins/background-switcher/BackgroundSwitcher.qml} (100%) create mode 100644 default/quickshell/omarchy-shell/plugins/background-switcher/manifest.json rename default/quickshell/{bar-settings/shell.qml => omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml} (64%) create mode 100644 default/quickshell/omarchy-shell/plugins/bar-settings/manifest.json create mode 100644 default/quickshell/omarchy-shell/ui/settings/DynamicSettingsForm.qml create mode 100644 default/quickshell/omarchy-shell/ui/settings/qmldir diff --git a/bin/omarchy-launch-bar-settings b/bin/omarchy-launch-bar-settings index 531d803b..1e85c073 100755 --- a/bin/omarchy-launch-bar-settings +++ b/bin/omarchy-launch-bar-settings @@ -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 "{}" diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images index 970f9392..44b2dd66 100755 --- a/bin/omarchy-menu-images +++ b/bin/omarchy-menu-images @@ -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 diff --git a/bin/omarchy-shell-ipc b/bin/omarchy-shell-ipc new file mode 100755 index 00000000..a58644bd --- /dev/null +++ b/bin/omarchy-shell-ipc @@ -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 "$@" diff --git a/default/quickshell/background-switcher.qml b/default/quickshell/omarchy-shell/plugins/background-switcher/BackgroundSwitcher.qml similarity index 100% rename from default/quickshell/background-switcher.qml rename to default/quickshell/omarchy-shell/plugins/background-switcher/BackgroundSwitcher.qml diff --git a/default/quickshell/omarchy-shell/plugins/background-switcher/manifest.json b/default/quickshell/omarchy-shell/plugins/background-switcher/manifest.json new file mode 100644 index 00000000..c335c7fc --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/background-switcher/manifest.json @@ -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" + } +} diff --git a/default/quickshell/bar-settings/shell.qml b/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml similarity index 64% rename from default/quickshell/bar-settings/shell.qml rename to default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml index a27145da..a5652978 100644 --- a/default/quickshell/bar-settings/shell.qml +++ b/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml @@ -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//" + 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 + } + } } diff --git a/default/quickshell/omarchy-shell/plugins/bar-settings/manifest.json b/default/quickshell/omarchy-shell/plugins/bar-settings/manifest.json new file mode 100644 index 00000000..95407a48 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/bar-settings/manifest.json @@ -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" } +} diff --git a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml index ae0d0ec8..c93e10da 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/Bar.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 } diff --git a/default/quickshell/omarchy-shell/services/BarWidgetRegistry.qml b/default/quickshell/omarchy-shell/services/BarWidgetRegistry.qml index 7903da54..bee81680 100644 --- a/default/quickshell/omarchy-shell/services/BarWidgetRegistry.qml +++ b/default/quickshell/omarchy-shell/services/BarWidgetRegistry.qml @@ -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 diff --git a/default/quickshell/omarchy-shell/services/PluginRegistry.qml b/default/quickshell/omarchy-shell/services/PluginRegistry.qml index d7a39810..ec453683 100644 --- a/default/quickshell/omarchy-shell/services/PluginRegistry.qml +++ b/default/quickshell/omarchy-shell/services/PluginRegistry.qml @@ -1,9 +1,8 @@ -pragma Singleton - import QtQuick import Quickshell import Quickshell.Io +// Instance, not a singleton — see BarWidgetRegistry for rationale. QtObject { id: registry diff --git a/default/quickshell/omarchy-shell/services/qmldir b/default/quickshell/omarchy-shell/services/qmldir index 8a925b66..e7678c8f 100644 --- a/default/quickshell/omarchy-shell/services/qmldir +++ b/default/quickshell/omarchy-shell/services/qmldir @@ -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 diff --git a/default/quickshell/omarchy-shell/shell.qml b/default/quickshell/omarchy-shell/shell.qml index 0552f995..26f5e4b7 100644 --- a/default/quickshell/omarchy-shell/shell.qml +++ b/default/quickshell/omarchy-shell/shell.qml @@ -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:". 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) + } } } diff --git a/default/quickshell/omarchy-shell/ui/settings/DynamicSettingsForm.qml b/default/quickshell/omarchy-shell/ui/settings/DynamicSettingsForm.qml new file mode 100644 index 00000000..c5f4417a --- /dev/null +++ b/default/quickshell/omarchy-shell/ui/settings/DynamicSettingsForm.qml @@ -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 + } +} diff --git a/default/quickshell/omarchy-shell/ui/settings/qmldir b/default/quickshell/omarchy-shell/ui/settings/qmldir new file mode 100644 index 00000000..341bb0e2 --- /dev/null +++ b/default/quickshell/omarchy-shell/ui/settings/qmldir @@ -0,0 +1,2 @@ +module omarchy.ui.settings +DynamicSettingsForm 1.0 DynamicSettingsForm.qml