diff --git a/bin/omarchy-voxtype-status b/bin/omarchy-voxtype-status index 9576efaa..31769092 100755 --- a/bin/omarchy-voxtype-status +++ b/bin/omarchy-voxtype-status @@ -2,12 +2,23 @@ # omarchy:summary=Clean up the voxtype --follow child when Waybar reloads -trap 'kill 0' EXIT - if omarchy-cmd-present voxtype; then - voxtype status --follow --extended --format json | while read -r line; do - echo "$line" | jq -c '. + {alt: .class}' + coproc VOXTYPE_STATUS { voxtype status --follow --extended --format json; } + voxtype_pid=$VOXTYPE_STATUS_PID + + cleanup() { + kill "$voxtype_pid" 2>/dev/null || true + } + + trap cleanup EXIT INT TERM + + while IFS= read -r line <&"${VOXTYPE_STATUS[0]}"; do + if omarchy-cmd-present jq; then + jq -c '. + {alt: (.class // "")}' <<<"$line" || printf '%s\n' "$line" + else + printf '%s\n' "$line" + fi done else - echo '{"alt": "", "tooltip": ""}' + echo '{"alt": "", "class": "idle", "tooltip": ""}' fi diff --git a/default/quickshell/omarchy-shell/Commons/Icons.qml b/default/quickshell/omarchy-shell/Commons/Icons.qml index 07211565..eae19991 100644 --- a/default/quickshell/omarchy-shell/Commons/Icons.qml +++ b/default/quickshell/omarchy-shell/Commons/Icons.qml @@ -50,6 +50,8 @@ QtObject { "lock": "\udb80\udd3e", // mdi-lock "unlock": "\udb80\udd3f", "refresh": "\udb81\udc50", // mdi-refresh + "ai": "󰚩", // mdi-robot + "robot": "󰚩", "x": "\udb80\udd56", // mdi-close "check": "\udb80\udc12", // mdi-check "chevron-down": "\udb80\udd40", diff --git a/default/quickshell/omarchy-shell/Commons/Style.qml b/default/quickshell/omarchy-shell/Commons/Style.qml index de84185a..37d987e2 100644 --- a/default/quickshell/omarchy-shell/Commons/Style.qml +++ b/default/quickshell/omarchy-shell/Commons/Style.qml @@ -10,6 +10,7 @@ QtObject { id: root // Radii. + readonly property real radiusXXS: 1 readonly property real radiusXS: 2 readonly property real radiusS: 3 readonly property real radiusM: 4 @@ -22,6 +23,7 @@ QtObject { // Margins / paddings. Noctalia uses two parallel scales for margins (one // tighter, one looser); we map them to the same values. + readonly property real marginXXS: 1 readonly property real marginXS: 2 readonly property real marginS: 4 readonly property real marginM: 6 diff --git a/default/quickshell/omarchy-shell/README.md b/default/quickshell/omarchy-shell/README.md index 39dd0d69..f6b4e5cb 100644 --- a/default/quickshell/omarchy-shell/README.md +++ b/default/quickshell/omarchy-shell/README.md @@ -163,8 +163,7 @@ rewrites `shell.json` from the current `shell-defaults.json`. "center": [ { "id": "calendar", "format": "HH:mm" } ], "right": [ { "id": "audioPanel" }, - { "id": "controlCenter" }, - { "id": "powerMenu" } + { "id": "controlCenter" } ] } }, diff --git a/default/quickshell/omarchy-shell/Services/UI/TooltipService.qml b/default/quickshell/omarchy-shell/Services/UI/TooltipService.qml index 28c3554d..6d9ba26d 100644 --- a/default/quickshell/omarchy-shell/Services/UI/TooltipService.qml +++ b/default/quickshell/omarchy-shell/Services/UI/TooltipService.qml @@ -18,8 +18,13 @@ QtObject { function hide(item) { if (!bar) return - if (typeof bar.hideTooltip === "function") { - bar.hideTooltip(item || null) + if (item && typeof bar.hideTooltip === "function") { + bar.hideTooltip(item) + } else if (bar.tooltipTarget) { + // Noctalia plugins often call TooltipService.hide() without the source + // item on click. Native bar.hideTooltip(target) ignores null targets, so + // clear the currently visible tooltip explicitly. + bar.hideTooltip(bar.tooltipTarget) } } } diff --git a/default/quickshell/omarchy-shell/Widgets/NComboBox.qml b/default/quickshell/omarchy-shell/Widgets/NComboBox.qml index c45008e3..5fabb37a 100644 --- a/default/quickshell/omarchy-shell/Widgets/NComboBox.qml +++ b/default/quickshell/omarchy-shell/Widgets/NComboBox.qml @@ -4,10 +4,57 @@ import qs.Commons ComboBox { id: root - property string label: "" + property string label: "" + // Noctalia's NComboBox API commonly uses a model of { key, name } objects, + // a currentKey property, and an onSelected(key, item) handler. Qt's ComboBox + // only knows currentIndex/currentText, so bridge the small API surface here. + property string currentKey: "" + + signal selected(var key, var item) + + textRole: "name" + valueRole: "key" font.family: "JetBrainsMono Nerd Font" font.pixelSize: Style.fontSizeS + + function itemAt(index) { + if (index < 0) return null + if (Array.isArray(root.model)) return root.model[index] || null + return null + } + + function keyAt(index) { + var item = itemAt(index) + if (!item) return "" + if (item.key !== undefined && item.key !== null) return String(item.key) + if (item.value !== undefined && item.value !== null) return String(item.value) + if (item.name !== undefined && item.name !== null) return String(item.name) + return String(item) + } + + function syncCurrentIndex() { + if (!Array.isArray(root.model)) return + for (var i = 0; i < root.model.length; i++) { + if (keyAt(i) === String(root.currentKey || "")) { + if (root.currentIndex !== i) root.currentIndex = i + return + } + } + if (root.model.length > 0 && root.currentIndex < 0) root.currentIndex = 0 + } + + Component.onCompleted: syncCurrentIndex() + onCurrentKeyChanged: syncCurrentIndex() + onModelChanged: syncCurrentIndex() + + onActivated: function(index) { + var item = itemAt(index) + var key = keyAt(index) + root.currentKey = key + root.selected(key, item) + } + background: Rectangle { color: Color.mSurfaceVariant border.color: Color.mOutline diff --git a/default/quickshell/omarchy-shell/Widgets/NPopupContextMenu.qml b/default/quickshell/omarchy-shell/Widgets/NPopupContextMenu.qml index 498ccefa..4df8a9e5 100644 --- a/default/quickshell/omarchy-shell/Widgets/NPopupContextMenu.qml +++ b/default/quickshell/omarchy-shell/Widgets/NPopupContextMenu.qml @@ -8,14 +8,18 @@ Menu { id: root property var model: [] - signal triggered(string action) + // Noctalia passes the owning ShellScreen through here. Qt Quick Controls + // Menu doesn't need it, but accepting the property keeps plugin QML from + // failing at load time. + property var screen: null + signal triggered(var action, var item) Instantiator { model: root.model delegate: MenuItem { required property var modelData text: modelData ? String(modelData.label || modelData.action || "") : "" - onTriggered: root.triggered(modelData ? String(modelData.action || "") : "") + onTriggered: root.triggered(modelData ? String(modelData.action || "") : "", modelData) } onObjectAdded: function(index, object) { root.insertItem(index, object) } onObjectRemoved: function(index, object) { root.removeItem(object) } diff --git a/default/quickshell/omarchy-shell/Widgets/NTabBar.qml b/default/quickshell/omarchy-shell/Widgets/NTabBar.qml new file mode 100644 index 00000000..0df85cde --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NTabBar.qml @@ -0,0 +1,26 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons + +Rectangle { + id: root + + property int currentIndex: 0 + property int margins: Style.marginXS + property bool distributeEvenly: false + + implicitHeight: 32 + radius: Style.radiusS + color: Color.mSurfaceVariant + border.color: Color.mOutline + border.width: Style.borderS + + default property alias content: row.children + + RowLayout { + id: row + anchors.fill: parent + anchors.margins: root.margins + spacing: Style.marginXS + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/NTabButton.qml b/default/quickshell/omarchy-shell/Widgets/NTabButton.qml new file mode 100644 index 00000000..86e094f3 --- /dev/null +++ b/default/quickshell/omarchy-shell/Widgets/NTabButton.qml @@ -0,0 +1,46 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons + +Rectangle { + id: root + + property string text: "" + property int tabIndex: -1 + property bool checked: false + + signal clicked() + + Layout.fillWidth: true + implicitWidth: label.implicitWidth + Style.marginL * 2 + implicitHeight: 26 + radius: Style.radiusXS + color: area.containsMouse + ? Color.mHover + : (checked ? Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.18) : "transparent") + border.color: checked ? Color.mPrimary : "transparent" + border.width: checked ? 1 : 0 + + Behavior on color { ColorAnimation { duration: Style.animationFast } } + + Text { + id: label + anchors.centerIn: parent + text: root.text + color: checked ? Color.mPrimary : Color.mOnSurface + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: Style.fontSizeS + font.bold: checked + elide: Text.ElideRight + width: Math.max(0, parent.width - Style.marginM * 2) + horizontalAlignment: Text.AlignHCenter + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/default/quickshell/omarchy-shell/Widgets/NToggle.qml b/default/quickshell/omarchy-shell/Widgets/NToggle.qml index ef404608..deb7ea93 100644 --- a/default/quickshell/omarchy-shell/Widgets/NToggle.qml +++ b/default/quickshell/omarchy-shell/Widgets/NToggle.qml @@ -1,16 +1,26 @@ import QtQuick -import QtQuick.Controls import qs.Commons -Switch { +Rectangle { id: root - property string label: "" - indicator: Rectangle { - implicitWidth: 32 - implicitHeight: 18 - x: root.leftPadding - y: root.height / 2 - height / 2 + property string label: "" + property string text: "" + property bool checked: false + + signal toggled(var value) + signal clicked() + + implicitWidth: labelItem.visible ? indicator.width + 8 + labelItem.implicitWidth : indicator.width + implicitHeight: Math.max(indicator.height, labelItem.implicitHeight) + color: "transparent" + opacity: enabled ? 1 : 0.4 + + Rectangle { + id: indicator + width: 32 + height: 18 + anchors.verticalCenter: parent.verticalCenter radius: height / 2 color: root.checked ? Color.mPrimary : Color.mSurfaceVariant border.color: Color.mOutline @@ -27,12 +37,28 @@ Switch { } } - contentItem: Text { - leftPadding: 38 + Text { + id: labelItem + visible: (root.label || root.text) !== "" + anchors.left: indicator.right + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter text: root.label || root.text color: Color.mOnSurface font.family: "JetBrainsMono Nerd Font" font.pixelSize: Style.fontSizeS verticalAlignment: Text.AlignVCenter } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onClicked: { + root.checked = !root.checked + root.clicked() + root.toggled(root.checked) + } + } } diff --git a/default/quickshell/omarchy-shell/Widgets/qmldir b/default/quickshell/omarchy-shell/Widgets/qmldir index ee82b347..5366b1ce 100644 --- a/default/quickshell/omarchy-shell/Widgets/qmldir +++ b/default/quickshell/omarchy-shell/Widgets/qmldir @@ -13,3 +13,5 @@ NTextInput 1.0 NTextInput.qml NComboBox 1.0 NComboBox.qml NCheckbox 1.0 NCheckbox.qml NDivider 1.0 NDivider.qml +NTabBar 1.0 NTabBar.qml +NTabButton 1.0 NTabButton.qml diff --git a/default/quickshell/omarchy-shell/compat/noctalia/PanelWrapper.qml b/default/quickshell/omarchy-shell/compat/noctalia/PanelWrapper.qml new file mode 100644 index 00000000..8c04f95e --- /dev/null +++ b/default/quickshell/omarchy-shell/compat/noctalia/PanelWrapper.qml @@ -0,0 +1,146 @@ +import QtQuick +import Quickshell +import qs.Commons + +// Wraps Noctalia panel entry points, which are plain Item content intended to +// be hosted inside Noctalia's SmartPanel. We host them as anchored PopupWindows +// so clicking a Noctalia bar widget behaves like native Omarchy popups instead +// of opening a tiled app window. +Item { + id: root + + property string panelSource: "" + property var pluginApi: null + property var manifest: ({}) + property string omarchyPath: "" + property var shell: null + property var pluginRegistry: null + property var barWidgetRegistry: null + property bool popupOpen: false + property bool closingFromShell: false + + readonly property var anchorItem: pluginApi ? pluginApi.panelAnchorItem : null + readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null + readonly property string barPosition: { + if (shell && shell.barConfig && shell.barConfig.position) return String(shell.barConfig.position) + return "top" + } + + function open(payloadJson) { + popupOpen = true + } + + function close() { + closingFromShell = true + popupOpen = false + if (pluginApi) { + pluginApi.panelOpenScreen = null + pluginApi.panelAnchorItem = null + } + closingFromShell = false + } + + function dismiss() { + if (!popupOpen) return + if (pluginApi && typeof pluginApi.closePanel === "function") pluginApi.closePanel(null) + else if (shell && manifest && manifest.id && typeof shell.hide === "function") shell.hide(manifest.id) + else close() + } + + PopupWindow { + id: popup + visible: root.popupOpen + color: "transparent" + implicitWidth: Math.max(320, panelLoader.item && panelLoader.item.contentPreferredWidth + ? panelLoader.item.contentPreferredWidth : 420) + implicitHeight: Math.max(260, panelLoader.item && panelLoader.item.contentPreferredHeight + ? panelLoader.item.contentPreferredHeight : 520) + + onVisibleChanged: { + if (!visible && root.popupOpen && !root.closingFromShell) root.dismiss() + } + + // Do not use HyprlandFocusGrab here. Noctalia panels are larger, + // interactive surfaces with tabs and nested Flickables; focus grabs made + // internal clicks (notably tab switches) look like outside clicks and also + // interfered with wheel scrolling. Native Omarchy micro-popups can keep + // focus-grab dismissal, but compat panels behave more like pinned panels. + anchor { + id: popupAnchor + window: root.anchorWindow + adjustment: PopupAdjustment.Slide + edges: Edges.Top | Edges.Left + gravity: Edges.Bottom | Edges.Right + rect.width: 1 + rect.height: 1 + + onAnchoring: { + var target = root.anchorItem + var window = root.anchorWindow + if (!target || !window) { + popupAnchor.rect.x = 0 + popupAnchor.rect.y = 0 + return + } + + var popupWidth = popup.implicitWidth + var popupHeight = popup.implicitHeight + var margin = 8 + var localX = target.width / 2 - popupWidth / 2 + var localY = target.height + margin + + if (root.barPosition === "bottom") { + localY = -popupHeight - margin + } else if (root.barPosition === "left") { + localX = target.width + margin + localY = target.height / 2 - popupHeight / 2 + } else if (root.barPosition === "right") { + localX = -popupWidth - margin + localY = target.height / 2 - popupHeight / 2 + } + + var point = window.contentItem.mapFromItem(target, localX, localY) + popupAnchor.rect.x = Math.round(point.x) + popupAnchor.rect.y = Math.round(point.y) + } + } + + Rectangle { + anchors.fill: parent + color: Color.mSurface + border.color: Color.mOutline + border.width: 1 + radius: 0 + + Loader { + id: panelLoader + anchors.fill: parent + source: root.panelSource + onLoaded: root.injectPanelProps() + onStatusChanged: { + if (status === Loader.Error) { + console.warn("noctalia panel failed for " + (root.manifest ? root.manifest.id : "") + ":", errorString()) + } + } + } + } + } + + onPluginApiChanged: injectPanelProps() + onManifestChanged: injectPanelProps() + + function injectPanelProps() { + var item = panelLoader.item + if (!item) return + if ("pluginApi" in item) item.pluginApi = root.pluginApi + if ("manifest" in item) item.manifest = root.manifest + if ("omarchyPath" in item) item.omarchyPath = root.omarchyPath + if ("shell" in item) item.shell = root.shell + if ("pluginRegistry" in item) item.pluginRegistry = root.pluginRegistry + if ("barWidgetRegistry" in item) item.barWidgetRegistry = root.barWidgetRegistry + if ("screen" in item) { + var screens = Quickshell.screens + item.screen = screens && screens.length > 0 ? screens[0] : null + } + } +} diff --git a/default/quickshell/omarchy-shell/compat/noctalia/PluginApiFactory.qml b/default/quickshell/omarchy-shell/compat/noctalia/PluginApiFactory.qml index 47be96b3..dcb3d591 100644 --- a/default/quickshell/omarchy-shell/compat/noctalia/PluginApiFactory.qml +++ b/default/quickshell/omarchy-shell/compat/noctalia/PluginApiFactory.qml @@ -46,10 +46,14 @@ Item { property var _hostBridge: ({}) property var mainInstance: null - // Resolved every read so the plugin sees current shell.json state. - readonly property var pluginSettings: _settingsProvider ? _settingsProvider() : ({}) + // Writable because Noctalia Settings.qml components commonly stage edits + // with `pluginApi.pluginSettings = ...; pluginApi.saveSettings()`. + // The initial binding resolves shell.json + defaults; assignment during + // save intentionally replaces it with the staged value to persist. + property var pluginSettings: _settingsProvider ? _settingsProvider() : ({}) property var panelOpenScreen: null + property var panelAnchorItem: null property var ipcHandlers: ({}) // Noctalia i18n surface — v1 returns the key as-is. @@ -62,19 +66,28 @@ Item { function trp(key, count, interp) { return String(key === undefined ? "" : key) } function hasTranslation(key) { return false } - function saveSettings() { + function saveSettings(settings) { + if (settings !== undefined) pluginSettings = settings if (_hostBridge && typeof _hostBridge.persistSettings === "function") _hostBridge.persistSettings(pluginId, pluginSettings) + // Re-read after persisting so mainInstance/bar widgets see the + // canonical merged settings (defaults + saved overrides) immediately. + if (_settingsProvider) pluginSettings = _settingsProvider() + if (mainInstance && typeof mainInstance.refresh === "function") mainInstance.refresh() } function openPanel(screen, buttonItem) { - panelOpenScreen = screen + panelOpenScreen = screen || true + panelAnchorItem = buttonItem || null + if (_hostBridge && _hostBridge.tooltipService && typeof _hostBridge.tooltipService.hide === "function") + _hostBridge.tooltipService.hide(buttonItem) if (_hostBridge && typeof _hostBridge.openPanel === "function") _hostBridge.openPanel(pluginId, screen, buttonItem) } function closePanel(screen) { panelOpenScreen = null + panelAnchorItem = null if (_hostBridge && typeof _hostBridge.closePanel === "function") _hostBridge.closePanel(pluginId, screen) } diff --git a/default/quickshell/omarchy-shell/compat/noctalia/README.md b/default/quickshell/omarchy-shell/compat/noctalia/README.md index bf07fc51..ca03f1c8 100644 --- a/default/quickshell/omarchy-shell/compat/noctalia/README.md +++ b/default/quickshell/omarchy-shell/compat/noctalia/README.md @@ -38,7 +38,7 @@ 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.Widgets` | `NText`, `NIcon`, `NIconButton`, `NBox`, `NButton`, `NPopupContextMenu`, `NScrollText`, `NToggle`, `NSpinBox`, `NSlider`, `NTextInput`, `NComboBox`, `NCheckbox`, `NDivider`, `NTabBar`, `NTabButton` | | `qs.Services.UI` | `BarService`, `TooltipService`, `PanelService` | | `qs.Services.System` | `HostService` (reads `/etc/os-release`) | | `qs.Services.Power` | `PowerProfileService` (returns `noctaliaPerformanceMode: false`) | diff --git a/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml b/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml index c1aca8e7..5eb2cd24 100644 --- a/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml @@ -10,10 +10,20 @@ Item { id: root // Plugin lifecycle hooks. omarchy-shell calls open(payloadJson) on summon - // and close() on hide. We don't consume payloads yet, and visibility is - // driven by the host Loader's `active`, so both are no-ops for now. - function open(payloadJson) { /* no payload schema yet; reserved for future use */ } - function close() { /* visibility handled by parent Loader; nothing to clean up */ } + // and close() on hide. The Loader stays mounted while shell thinks the panel + // is open, so reopening after a WM close must explicitly re-show the window. + property bool closingFromHost: false + + function open(payloadJson) { + closingFromHost = false + window.visible = true + } + + function close() { + closingFromHost = true + window.visible = false + closingFromHost = false + } // Injected by the host shell when the panel is summoned. Shared instances // so the panel sees the same registry state the bar wrote into. @@ -66,8 +76,7 @@ Item { 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" } + { id: "battery" }, { id: "controlCenter" } ] } }, @@ -317,6 +326,25 @@ Item { if (root.barWidgetRegistry && root.barWidgetRegistry.has(key)) return root.barWidgetRegistry.metadataFor(key) || {} if (legacyWidgetMeta[key]) return legacyWidgetMeta[key] + + // If a plugin widget failed to instantiate, it may not be in + // BarWidgetRegistry yet. Still use the manifest metadata so cards/dialogs + // show "Model Usage" instead of the raw id "noctalia.model-usage" and the + // settings gear can still expose the plugin's Settings.qml. + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[key] : null + if (manifest) { + var meta = manifest.barWidget || {} + return { + displayName: meta.displayName || manifest.name || key, + name: meta.displayName || manifest.name || key, + description: meta.description || manifest.description || "", + category: meta.category || (manifest.__noctaliaCompat ? "Noctalia" : "Plugin"), + allowMultiple: meta.allowMultiple === true, + settingsForm: meta.settingsForm || "", + schema: Array.isArray(meta.schema) ? meta.schema : [], + source: "plugin" + } + } return {} } @@ -373,6 +401,14 @@ Item { var registered = root.barWidgetRegistry.availableIds() for (var i = 0; i < registered.length; i++) ids[registered[i]] = true } + if (root.pluginRegistry && root.pluginRegistry.installedPlugins) { + var plugins = root.pluginRegistry.installedPlugins + for (var pid in plugins) { + var manifest = plugins[pid] + if (manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1) + ids[pid] = true + } + } for (var key in legacyWidgetMeta) ids[key] = true return Object.keys(ids) } @@ -404,8 +440,9 @@ Item { for (var k = 0; k < ids.length; k++) { var id = ids[k] var meta = widgetMetadata(id) - var isBarWidget = !!(meta && meta.source !== "plugin") - || (meta && meta.kinds && meta.kinds.indexOf && meta.kinds.indexOf("bar-widget") !== -1) + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null + var manifestIsBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1 + var isBarWidget = !!(meta && meta.source !== "plugin") || manifestIsBarWidget if (isBarSection) { if (!isBarWidget && !legacyWidgetMeta[id]) continue var inSection = sectionArray(section) @@ -426,7 +463,6 @@ Item { // Plugins section: accept third-party plugins with any non-bar kind. // First-party panels/overlays (bar-settings, image-picker) are // shell infrastructure and don't belong in user-editable lists. - var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null if (!manifest) continue if (manifest.__isFirstParty) continue if (existingInPlugins[id]) continue @@ -479,6 +515,11 @@ Item { implicitHeight: 720 minimumSize: Qt.size(560, 500) + onVisibleChanged: { + if (!visible && !root.closingFromHost && root.shell && typeof root.shell.hide === "function") + root.shell.hide("omarchy.bar-settings") + } + Rectangle { anchors.fill: parent color: root.background @@ -953,7 +994,15 @@ Item { } function commit() { - root.updateEntry(sectionKey, entryIndex, workingEntry) + // Native forms update workingEntry through fieldChanged(). Noctalia + // Settings.qml components keep their own editSettings state and expose a + // saveSettings() method that writes via pluginApi.saveSettings(). Avoid + // overwriting that freshly-saved entry with the stale workingEntry shell. + if (formLoader.item && typeof formLoader.item.saveSettings === "function") { + formLoader.item.saveSettings() + } else { + root.updateEntry(sectionKey, entryIndex, workingEntry) + } win.visible = false } @@ -1037,7 +1086,6 @@ Item { switch (meta.settingsForm) { case "spacerSettings": return spacerSettingsComponent case "calendarSettings": return calendarSettingsComponent - case "brightnessSettings": return brightnessSettingsComponent } } var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null @@ -1057,11 +1105,9 @@ 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). + // plugin's form and inject pluginApi. The outer Omarchy dialog owns the + // Apply button, so this wrapper must forward saveSettings() to the loaded + // Noctalia form. Component { id: noctaliaSettingsComponent @@ -1072,6 +1118,14 @@ Item { property var manifest: pluginId && root.pluginRegistry ? root.pluginRegistry.installedPlugins[pluginId] : null + function saveSettings() { + if (settingsLoader.item && typeof settingsLoader.item.saveSettings === "function") { + settingsLoader.item.saveSettings() + } else { + console.warn("Noctalia settings form has no saveSettings():", pluginId) + } + } + implicitHeight: settingsLoader.item ? settingsLoader.item.implicitHeight : 0 implicitWidth: settingsLoader.item ? settingsLoader.item.implicitWidth : 0 @@ -1183,32 +1237,6 @@ Item { } } - Component { - id: brightnessSettingsComponent - - Column { - id: brightForm - signal fieldChanged(string key, var value) - property var entry: ({}) - - spacing: 8 - width: parent ? parent.width : 0 - - Text { - text: "Scroll step (% per notch)" - color: Qt.darker(root.foreground, 1.4) - font.family: root.fontFamily - font.pixelSize: 11 - } - SpinBox { - from: 1 - to: 25 - value: brightForm.entry.step !== undefined ? brightForm.entry.step : 5 - onValueModified: brightForm.fieldChanged("step", value) - } - } - } - component TabButton: Rectangle { id: tab property string label: "" diff --git a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml index c318ac11..520923b4 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml @@ -287,15 +287,11 @@ Item { "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, 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, 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 }, - "powerMenu": { displayName: "Power menu", description: "Lock / suspend / reboot / shutdown", category: "System", allowMultiple: false }, "idleInhibitor": { displayName: "Keep awake", description: "Toggle idle inhibitor", category: "System", allowMultiple: false }, "microphone": { displayName: "Microphone", description: "Mic input state and mute toggle", category: "Audio", allowMultiple: false }, "activeWindow": { displayName: "Active window", description: "Title of the focused window", category: "Compositor", allowMultiple: false }, - "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, settingsForm: "spacerSettings" }, @@ -445,7 +441,7 @@ Item { function updateVoxtype(raw) { var data = parseModuleJson(raw) - var state = data.alt || data.class || "idle" + var state = String(data.alt || data.class || "idle") voxtypeClass = state if (state === "recording") voxtypeIcon = "󰍬" @@ -739,7 +735,7 @@ Item { Process { id: voxtypeProc - command: ["bash", "-lc", "omarchy-voxtype-status"] + command: ["bash", "-lc", root.commandWithOmarchyPath("omarchy-voxtype-status")] running: true stdout: SplitParser { onRead: function(data) { diff --git a/default/quickshell/omarchy-shell/plugins/bar/README.md b/default/quickshell/omarchy-shell/plugins/bar/README.md index d82ed387..82a0cf19 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/README.md +++ b/default/quickshell/omarchy-shell/plugins/bar/README.md @@ -40,8 +40,7 @@ Example `shell.json` (bar subtree only shown): { "id": "systemStats" }, { "id": "audioPanel" }, { "id": "battery" }, - { "id": "controlCenter" }, - { "id": "powerMenu" } + { "id": "controlCenter" } ] } } @@ -62,11 +61,8 @@ Example `shell.json` (bar subtree only shown): | `bluetoothPanel` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI | | `calendar` | Clock + popup with month-grid calendar | left = popup · right = tz selector | | `notificationCenter` | Bell with badge + popup with recent notifications, DND toggle | left = popup · right = toggle DND | -| `brightness` | Brightness slider + scroll | scroll = adjust · left = popup · middle = reset to 80% | -| `powerProfile` | Current power profile + popup picker | left = popup | | `systemStats` | Inline CPU + memory sparklines, popup with detail | left = popup · right = terminal | | `weatherFlyout` | Weather icon + popup with forecast | left = popup · right = full notification | -| `powerMenu` | Power icon → popup with lock/suspend/log out/reboot/shutdown | left = popup | | `idleInhibitor` | Coffee-cup that toggles `omarchy-toggle-idle` | left = toggle | | `microphone` | Mic icon + scroll volume | left = mute toggle · middle = audio TUI · scroll = source volume | diff --git a/default/quickshell/omarchy-shell/plugins/bar/common/WidgetButton.qml b/default/quickshell/omarchy-shell/plugins/bar/common/WidgetButton.qml index 6920e6e6..547fbe79 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/common/WidgetButton.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/common/WidgetButton.qml @@ -63,7 +63,10 @@ Item { hoverEnabled: true onEntered: if (root.bar) root.bar.showTooltip(root, root.tooltipText) onExited: if (root.bar) root.bar.hideTooltip(root) - onClicked: function(mouse) { root.pressed(mouse.button) } + onClicked: function(mouse) { + if (root.bar) root.bar.hideTooltip(root) + root.pressed(mouse.button) + } onWheel: function(wheel) { root.wheelMoved(wheel.angleDelta.y) } } } diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml index f297eb94..196ce135 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Controls import Quickshell import Quickshell.Services.Pipewire import "../common" as Common @@ -21,8 +22,21 @@ Item { readonly property var candidateSinks: { var list = [] for (var i = 0; i < nodes.length; i++) { - var node = nodes[i] - if (node && node.isSink && !node.isStream) list.push(node) + var n = nodes[i] + if (n && n.isSink && !n.isStream) list.push(n) + } + return list + } + + readonly property var candidateSources: { + var list = [] + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i] + if (n && !n.isSink && !n.isStream && n.audio) { + var name = n.name || "" + if (name === "quickshell") continue + list.push(n) + } } return list } @@ -30,12 +44,23 @@ Item { readonly property var candidateStreams: { var list = [] for (var i = 0; i < nodes.length; i++) { - var node = nodes[i] - if (node && node.isStream && !node.isSink) list.push(node) + var n = nodes[i] + if (n && n.isStream && isPlaybackStream(n)) list.push(n) } return list } + // Identify true playback streams without reading node.properties here: + // PwNode.properties is invalid until the node is bound, and reading it while + // capture streams are appearing (for example, when Voxtype starts recording) + // can destabilize Quickshell's Pipewire service. `type` mirrors media.class + // and is safe enough for pre-bind filtering. + function isPlaybackStream(node) { + if (!node) return false + var mediaClass = String(node.type || "") + return mediaClass.indexOf("Output") !== -1 + } + readonly property var audioSinks: { var list = [] for (var i = 0; i < candidateSinks.length; i++) @@ -43,6 +68,8 @@ Item { return list } + readonly property var audioSources: candidateSources + readonly property var audioStreams: { var list = [] for (var i = 0; i < candidateStreams.length; i++) @@ -50,54 +77,115 @@ Item { return list } - readonly property real currentVolume: sink && sink.audio ? sink.audio.volume : 0 - readonly property bool muted: sink && sink.audio ? sink.audio.muted : false + readonly property real outputVolume: sink && sink.audio ? sink.audio.volume : 0 + readonly property bool outputMuted: sink && sink.audio ? sink.audio.muted : false + readonly property real inputVolume: source && source.audio ? source.audio.volume : 0 + readonly property bool inputMuted: source && source.audio ? source.audio.muted : false - readonly property string volumeIcon: { - if (!sink || !sink.audio) return "" - if (muted) return "󰸈" - var v = currentVolume - if (v >= 0.67) return "󰕾" - if (v >= 0.34) return "󰖀" - if (v > 0) return "󰕿" - return "󰸈" + function outputIcon() { + // Match the old Waybar pulseaudio glyph set. The Material Design speaker + // icons render visually smaller in JetBrainsMono Nerd Font. + if (!sink || !sink.audio) return "" + if (outputMuted) return "" + var v = outputVolume + if (v >= 0.67) return "" + if (v >= 0.34) return "" + if (v > 0) return "" + return "" } - function setVolume(v) { + function inputIcon() { + if (!source || !source.audio) return "󰍭" + return inputMuted ? "󰍭" : "󰍬" + } + + function setOutputVolume(v) { if (!sink || !sink.audio) return sink.audio.volume = Math.max(0, Math.min(1, v)) } - function toggleMute() { + function setInputVolume(v) { + if (!source || !source.audio) return + source.audio.volume = Math.max(0, Math.min(1, v)) + } + + function toggleOutputMute() { if (sink && sink.audio) sink.audio.muted = !sink.audio.muted } - function setDefaultSink(node) { - Pipewire.preferredDefaultAudioSink = node + function toggleInputMute() { + if (source && source.audio) source.audio.muted = !source.audio.muted + } + + function setDefaultSink(node) { Pipewire.preferredDefaultAudioSink = node } + function setDefaultSource(node) { Pipewire.preferredDefaultAudioSource = node } + + function nodeLabel(node) { + if (!node) return "Unknown" + return node.description || node.nickname || node.name || "Unknown" + } + + function nodeProps(node) { + return node && node.ready && node.properties ? node.properties : {} + } + + function sinkGlyph(node) { + if (!node) return "󰓃" + var p = nodeProps(node) + var blob = String([ + node.name, node.description, node.nickname, + p["device.icon-name"] || "", + p["device.product.name"] || "" + ].join(" ")).toLowerCase() + if (blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1) return "󰋋" + if (blob.indexOf("bluetooth") !== -1) return "󰂯" + if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹" + return "󰓃" + } + + function sourceGlyph(node) { + if (!node) return "󰍬" + var p = nodeProps(node) + var blob = String([ + node.name, node.description, node.nickname, + p["device.icon-name"] || "" + ].join(" ")).toLowerCase() + if (blob.indexOf("headset") !== -1) return "󰋋" + if (blob.indexOf("bluetooth") !== -1) return "󰂯" + if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "󰄀" + return "󰍬" + } + + function streamLabel(node) { + if (!node) return "Stream" + var p = nodeProps(node) + return p["application.name"] || node.description || p["media.name"] || p["node.name"] || node.name || "Stream" } implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight PwObjectTracker { objects: root.candidateSinks } - PwObjectTracker { objects: root.candidateStreams } + PwObjectTracker { objects: root.candidateSources } + PwObjectTracker { objects: root.audioStreams } Common.WidgetButton { id: button anchors.fill: parent bar: root.bar - text: root.volumeIcon - tooltipText: root.sink ? (root.sink.description || root.sink.nickname || "Audio") + " · " + Math.round(root.currentVolume * 100) + "%" : "No audio" + text: root.outputIcon() + fontSize: 14 + tooltipText: root.sink ? root.nodeLabel(root.sink) + " · " + Math.round(root.outputVolume * 100) + "%" : "No audio" onPressed: function(b) { - if (b === Qt.RightButton) root.toggleMute() + if (b === Qt.RightButton) root.toggleOutputMute() else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-audio") else root.popupOpen = !root.popupOpen } onWheelMoved: function(delta) { var step = 0.05 - root.setVolume(root.currentVolume + (delta > 0 ? step : -step)) + root.setOutputVolume(root.outputVolume + (delta > 0 ? step : -step)) } } @@ -106,123 +194,411 @@ Item { owner: root bar: root.bar open: root.popupOpen - contentWidth: 340 - contentHeight: panelColumn.implicitHeight + 28 + contentWidth: 380 + contentHeight: Math.min(560, panelColumn.implicitHeight + 28) - Column { - id: panelColumn + ScrollView { + id: scrollArea anchors.fill: parent - spacing: 12 + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded - // Master volume - Row { - width: parent.width - spacing: 10 - - Text { - text: root.volumeIcon - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 18 - anchors.verticalCenter: parent.verticalCenter - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.toggleMute() - } - } - - Common.Slider { - bar: root.bar - width: parent.width - 50 - anchors.verticalCenter: parent.verticalCenter - minimum: 0 - maximum: 1 - step: 0.05 - value: root.currentVolume - opacity: root.muted ? 0.5 : 1.0 - - onMoved: function(v) { root.setVolume(v) } - } - } - - // Output device picker Column { - spacing: 4 - width: parent.width - visible: root.audioSinks.length > 0 + id: panelColumn + width: scrollArea.availableWidth + spacing: 14 - Text { - text: "Output" - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: 11 - font.bold: true - } - - Repeater { - model: root.audioSinks - - Common.PillButton { - required property var modelData - - width: parent.width - text: modelData ? (modelData.description || modelData.nickname || modelData.name || "Unknown") : "" - iconText: root.sinkGlyph(modelData) - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 6 - active: root.sink && modelData && root.sink.id === modelData.id - onClicked: { root.setDefaultSink(modelData); } - } - } - } - - // Per-app streams - Column { - spacing: 4 - width: parent.width - visible: root.audioStreams.length > 0 - - Text { - text: "Playing" - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: 11 - font.bold: true - } - - Repeater { - model: root.audioStreams + // ---- Output ---- + Column { + width: parent.width + spacing: 6 Row { - required property var modelData - width: parent.width spacing: 8 Text { - text: modelData && modelData.properties ? (modelData.properties["application.name"] || modelData.properties["node.name"] || "Stream") : "Stream" - color: root.bar.foreground + text: "Output" + color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: 11 - elide: Text.ElideRight - width: 110 + font.bold: true anchors.verticalCenter: parent.verticalCenter } + Text { + text: root.sink ? "· " + root.nodeLabel(root.sink) : "" + color: Qt.darker(root.bar.foreground, 1.8) + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width - 70 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + width: parent.width + spacing: 8 + + Text { + id: outputIconText + text: root.outputIcon() + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 16 + width: 22 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + opacity: root.outputMuted ? 0.5 : 1.0 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleOutputMute() + } + } + Common.Slider { + id: outputSlider bar: root.bar - width: parent.width - 124 + width: parent.width - outputIconText.width - outputPercent.width - 16 anchors.verticalCenter: parent.verticalCenter minimum: 0 - maximum: 1.5 + maximum: 1 step: 0.05 - value: modelData && modelData.audio ? modelData.audio.volume : 0 + value: root.outputVolume + opacity: root.outputMuted ? 0.5 : 1.0 + enabled: !!root.sink - onMoved: function(v) { - if (modelData && modelData.audio) modelData.audio.volume = v + onMoved: function(v) { root.setOutputVolume(v) } + } + + Text { + id: outputPercent + text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + width: 36 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + opacity: root.outputMuted ? 0.5 : 1.0 + } + } + + Repeater { + model: root.audioSinks + + Rectangle { + required property var modelData + + readonly property bool active: root.sink && modelData && root.sink.id === modelData.id + + width: panelColumn.width + height: deviceRow.implicitHeight + 10 + radius: 4 + color: deviceArea.pressed + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.22) + : deviceArea.containsMouse + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + : (active ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.18) : "transparent") + + Behavior on color { ColorAnimation { duration: 120 } } + + Row { + id: deviceRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + text: root.sinkGlyph(modelData) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 14 + width: 18 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.nodeLabel(modelData) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width - 18 - 14 - 16 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: active ? "󰄬" : "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 13 + width: 14 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: deviceArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.setDefaultSink(modelData) + } + } + } + } + + // ---- Input ---- + Column { + width: parent.width + spacing: 6 + visible: root.audioSources.length > 0 || !!root.source + + Row { + width: parent.width + spacing: 8 + + Text { + text: "Input" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.source ? "· " + root.nodeLabel(root.source) : "" + color: Qt.darker(root.bar.foreground, 1.8) + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width - 56 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + width: parent.width + spacing: 8 + visible: !!root.source + + Text { + id: inputIconText + text: root.inputIcon() + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 16 + width: 22 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + opacity: root.inputMuted ? 0.5 : 1.0 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleInputMute() + } + } + + Common.Slider { + id: inputSlider + bar: root.bar + width: parent.width - inputIconText.width - inputPercent.width - 16 + anchors.verticalCenter: parent.verticalCenter + minimum: 0 + maximum: 1 + step: 0.05 + value: root.inputVolume + opacity: root.inputMuted ? 0.5 : 1.0 + enabled: !!root.source + + onMoved: function(v) { root.setInputVolume(v) } + } + + Text { + id: inputPercent + text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + width: 36 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + opacity: root.inputMuted ? 0.5 : 1.0 + } + } + + Repeater { + model: root.audioSources + + Rectangle { + required property var modelData + + readonly property bool active: root.source && modelData && root.source.id === modelData.id + + width: panelColumn.width + height: sourceRow.implicitHeight + 10 + radius: 4 + color: sourceArea.pressed + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.22) + : sourceArea.containsMouse + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + : (active ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.18) : "transparent") + + Behavior on color { ColorAnimation { duration: 120 } } + + Row { + id: sourceRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + text: root.sourceGlyph(modelData) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 14 + width: 18 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.nodeLabel(modelData) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width - 18 - 14 - 16 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: active ? "󰄬" : "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 13 + width: 14 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: sourceArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.setDefaultSource(modelData) + } + } + } + } + + // ---- Per-app streams ---- + Column { + width: parent.width + spacing: 6 + visible: root.audioStreams.length > 0 + + Text { + text: "Playing" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: true + } + + Repeater { + model: root.audioStreams + + Item { + required property var modelData + + readonly property real streamVolume: modelData && modelData.audio ? modelData.audio.volume : 0 + readonly property bool streamMuted: modelData && modelData.audio ? modelData.audio.muted : false + + width: panelColumn.width + height: streamColumn.implicitHeight + 4 + + Column { + id: streamColumn + width: parent.width + spacing: 2 + + Row { + width: parent.width + spacing: 6 + + Text { + id: streamMuteIcon + text: streamMuted ? "󰝟" : "󰕾" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + width: 14 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + opacity: streamMuted ? 0.5 : 1.0 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData && modelData.audio) modelData.audio.muted = !modelData.audio.muted + } + } + } + + Text { + text: root.streamLabel(modelData) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width - streamMuteIcon.width - streamPct.width - 12 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: streamPct + text: Math.round(streamVolume * 100) + "%" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + width: 36 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + Common.Slider { + bar: root.bar + width: parent.width + minimum: 0 + maximum: 1.5 + step: 0.05 + value: streamVolume + opacity: streamMuted ? 0.5 : 1.0 + + onMoved: function(v) { + if (modelData && modelData.audio) modelData.audio.volume = v + } + } } } } @@ -230,17 +606,4 @@ Item { } } } - - function sinkGlyph(node) { - if (!node) return "" - var blob = String([ - node.name, node.description, node.nickname, - node.properties ? node.properties["device.icon-name"] : "", - node.properties ? node.properties["device.product.name"] : "" - ].join(" ")).toLowerCase() - if (blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1) return "󰋋" - if (blob.indexOf("bluetooth") !== -1) return "󰂯" - if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹" - return "󰓃" - } } diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/brightness.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/brightness.qml deleted file mode 100644 index 8e0fbca3..00000000 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/brightness.qml +++ /dev/null @@ -1,165 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import "../common" as Common - -Item { - id: root - - property QtObject bar: null - property string moduleName: "brightness" - property var settings: ({}) - - function setting(name, fallback) { - var value = settings ? settings[name] : undefined - return value === undefined || value === null ? fallback : value - } - - property int currentPercent: -1 - property bool popupOpen: false - - function closePopout() { popupOpen = false } - - readonly property string iconGlyph: { - if (currentPercent < 0) return "" - if (currentPercent > 66) return "󰃠" - if (currentPercent > 33) return "󰃟" - return "󰃞" - } - - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - visible: currentPercent >= 0 - - function refresh() { - if (!readProc.running) readProc.running = true - } - - property int pendingPercent: -1 - - function setBrightness(percent) { - var clamped = Math.max(1, Math.min(100, Math.round(percent))) - currentPercent = clamped - pendingPercent = clamped - writeTimer.restart() - } - - Timer { - id: writeTimer - interval: 60 - repeat: false - onTriggered: { - if (writeProc.running) { - writeTimer.restart() - return - } - if (pendingPercent < 0) return - writeProc.command = ["bash", "-lc", "brightnessctl set " + pendingPercent + "% >/dev/null"] - pendingPercent = -1 - writeProc.running = true - } - } - - Component.onCompleted: refresh() - - Process { - id: readProc - command: ["bash", "-lc", "if command -v brightnessctl >/dev/null; then echo $(( 100 * $(brightnessctl get) / $(brightnessctl max) )); fi"] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - var n = parseInt(String(text || "").trim(), 10) - if (!isNaN(n)) root.currentPercent = n - } - } - } - - Process { id: writeProc } - - Timer { - interval: 5000 - running: true - repeat: true - onTriggered: root.refresh() - } - - Common.WidgetButton { - id: button - anchors.fill: parent - bar: root.bar - text: root.iconGlyph - horizontalMargin: 6.5 - tooltipText: root.currentPercent >= 0 ? "Brightness " + root.currentPercent + "%" : "" - - onPressed: function(b) { - if (b === Qt.MiddleButton) { - root.popupOpen = false - root.setBrightness(80) - } else { - root.popupOpen = !root.popupOpen - } - } - - onWheelMoved: function(delta) { - var step = Number(root.setting("step", 5)) - root.setBrightness(root.currentPercent + (delta > 0 ? step : -step)) - } - } - - Common.PopupCard { - anchorItem: button - owner: root - bar: root.bar - open: root.popupOpen - contentWidth: 280 - contentHeight: 80 - - Column { - anchors.fill: parent - spacing: 10 - - Row { - spacing: 10 - width: parent.width - - Text { - text: root.iconGlyph - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 18 - anchors.verticalCenter: parent.verticalCenter - } - - Text { - text: "Brightness" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 12 - anchors.verticalCenter: parent.verticalCenter - } - - Item { width: 10; height: 1 } - - Text { - text: root.currentPercent + "%" - color: Qt.darker(root.bar.foreground, 1.3) - font.family: root.bar.fontFamily - font.pixelSize: 12 - anchors.verticalCenter: parent.verticalCenter - } - } - - Common.Slider { - bar: root.bar - width: parent.width - minimum: 1 - maximum: 100 - step: 5 - integer: true - value: root.currentPercent - - onMoved: function(v) { root.setBrightness(v) } - } - } - } -} diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml index 0ec7f530..d04bf39b 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml @@ -322,6 +322,43 @@ Item { color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) } + Column { + width: parent.width + spacing: 6 + visible: root.powerProfileAvailable + + Text { + text: "Power profile" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: true + } + + Repeater { + model: [ + { profile: PowerProfile.PowerSaver, label: "Power Saver", glyph: "󰌪" }, + { profile: PowerProfile.Balanced, label: "Balanced", glyph: "󰗑" }, + { profile: PowerProfile.Performance, label: "Performance", glyph: "󰓅" } + ] + + ProfileButton { + required property var modelData + width: parent.width + profile: modelData.profile + label: modelData.label + glyph: modelData.glyph + profileEnabled: modelData.profile !== PowerProfile.Performance || PowerProfiles.hasPerformanceProfile + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + } + Common.PillButton { width: parent.width iconText: "󰙪" @@ -331,34 +368,77 @@ Item { verticalPadding: 8 onClicked: { root.run("omarchy-launch-bar-settings"); root.popupOpen = false } } + } + } - Row { - width: parent.width - spacing: 6 - visible: root.powerProfileAvailable + component ProfileButton: Rectangle { + id: profileButton - Repeater { - model: [ - { profile: PowerProfile.PowerSaver, label: "Saver", glyph: "󰌪" }, - { profile: PowerProfile.Balanced, label: "Balanced", glyph: "󰗑" }, - { profile: PowerProfile.Performance, label: "Performance", glyph: "󰓅" } - ] + property int profile: PowerProfile.Balanced + property string label: "" + property string glyph: "" + property bool profileEnabled: true + readonly property bool active: root.currentProfile === profile - Common.PillButton { - required property var modelData - width: (parent.width - 12) / 3 - iconText: modelData.glyph - text: modelData.label - foreground: root.bar.foreground - horizontalPadding: 8 - verticalPadding: 8 - active: root.currentProfile === modelData.profile - enabled: modelData.profile !== PowerProfile.Performance || PowerProfiles.hasPerformanceProfile - opacity: enabled ? 1 : 0.4 - onClicked: PowerProfiles.profile = modelData.profile - } - } + height: 34 + radius: 4 + color: profileArea.pressed + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.22) + : profileArea.containsMouse + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + : (active ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.18) : "transparent") + border.color: active ? root.bar.foreground : Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + border.width: active ? 1 : 0 + opacity: profileEnabled ? 1 : 0.4 + + Behavior on color { ColorAnimation { duration: 120 } } + + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + text: profileButton.glyph + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 14 + width: 18 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter } + + Text { + text: profileButton.label + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width - 18 - 14 - 16 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: profileButton.active ? "󰄬" : "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 13 + width: 14 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: profileArea + anchors.fill: parent + hoverEnabled: true + enabled: profileButton.profileEnabled + cursorShape: profileButton.profileEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: PowerProfiles.profile = profileButton.profile } } diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/nightLight.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/nightLight.qml deleted file mode 100644 index 34e639da..00000000 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/nightLight.qml +++ /dev/null @@ -1,84 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import "../common" as Common - -Item { - id: root - - property QtObject bar: null - property string moduleName: "nightLight" - property var settings: ({}) - - property bool active: false - property bool toolAvailable: false - property bool toggling: false - - readonly property int onTemp: 4000 - readonly property int offTemp: 6000 - - function setting(name, fallback) { - var value = settings ? settings[name] : undefined - return value === undefined || value === null ? fallback : value - } - - function refresh() { - if (!statusProc.running) statusProc.running = true - } - - function toggle() { - if (toggling) return - toggling = true - if (root.bar) root.bar.run("omarchy-toggle-nightlight") - refreshTimer.restart() - } - - Component.onCompleted: refresh() - - Process { - id: statusProc - command: ["bash", "-lc", "command -v hyprsunset >/dev/null || { echo missing; exit; }; if pgrep -x hyprsunset >/dev/null 2>&1; then hyprctl hyprsunset temperature 2>/dev/null | grep -oE '[0-9]+' | head -1; else echo idle; fi"] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - var state = String(text || "").trim() - root.toggling = false - if (state === "missing") { - root.toolAvailable = false - root.active = false - return - } - root.toolAvailable = true - var temp = parseInt(state, 10) - root.active = !isNaN(temp) && temp < root.offTemp - } - } - } - - Timer { - id: refreshTimer - interval: 1500 - onTriggered: root.refresh() - } - - Timer { - interval: 10000 - running: true - repeat: true - onTriggered: root.refresh() - } - - visible: toolAvailable - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - - Common.WidgetButton { - id: button - anchors.fill: parent - bar: root.bar - text: root.active ? "󰖔" : "󰖙" - active: root.active - tooltipText: root.active ? "Night light on" : "Night light off" - onPressed: function() { root.toggle() } - } -} diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/powerMenu.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/powerMenu.qml deleted file mode 100644 index 6274c678..00000000 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/powerMenu.qml +++ /dev/null @@ -1,106 +0,0 @@ -import QtQuick -import Quickshell -import "../common" as Common - -Item { - id: root - - property QtObject bar: null - property string moduleName: "powerMenu" - property var settings: ({}) - - property bool popupOpen: false - - function closePopout() { popupOpen = false } - - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - - function run(command) { - if (root.bar) root.bar.run(command) - popupOpen = false - } - - Common.WidgetButton { - id: button - anchors.fill: parent - bar: root.bar - text: "󰐥" - fontSize: 14 - tooltipText: "Power menu" - onPressed: function() { root.popupOpen = !root.popupOpen } - } - - Common.PopupCard { - anchorItem: button - owner: root - bar: root.bar - open: root.popupOpen - contentWidth: 220 - contentHeight: column.implicitHeight + 28 - - Column { - id: column - anchors.fill: parent - spacing: 6 - - Text { - text: "Power" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 12 - font.bold: true - } - - Common.PillButton { - width: parent.width - iconText: "󰌾" - text: "Lock" - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 8 - onClicked: root.run("loginctl lock-session") - } - - Common.PillButton { - width: parent.width - iconText: "󰒲" - text: "Suspend" - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 8 - onClicked: root.run("systemctl suspend") - } - - Common.PillButton { - width: parent.width - iconText: "󰍃" - text: "Log out" - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 8 - onClicked: root.run("hyprctl dispatch exit") - } - - Common.PillButton { - width: parent.width - iconText: "󰜉" - text: "Reboot" - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 8 - onClicked: root.run("systemctl reboot") - } - - Common.PillButton { - width: parent.width - iconText: "󰐥" - text: "Shut down" - foreground: root.bar.urgent - horizontalPadding: 10 - verticalPadding: 8 - onClicked: root.run("systemctl poweroff") - } - } - } -} diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/powerProfile.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/powerProfile.qml deleted file mode 100644 index 0a37ed46..00000000 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/powerProfile.qml +++ /dev/null @@ -1,94 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Services.UPower -import "../common" as Common - -Item { - id: root - - property QtObject bar: null - property string moduleName: "powerProfile" - property var settings: ({}) - - property bool popupOpen: false - - function closePopout() { popupOpen = false } - - readonly property var profileGlyphs: ({ - [PowerProfile.PowerSaver]: "󰌪", - [PowerProfile.Balanced]: "󰗑", - [PowerProfile.Performance]: "󰓅" - }) - - readonly property var profileLabels: ({ - [PowerProfile.PowerSaver]: "Power Saver", - [PowerProfile.Balanced]: "Balanced", - [PowerProfile.Performance]: "Performance" - }) - - readonly property bool available: PowerProfiles.hasPerformanceProfile || PowerProfiles.profile === PowerProfile.PowerSaver || PowerProfiles.profile === PowerProfile.Balanced - readonly property int current: PowerProfiles.profile - - visible: available - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - - function setProfile(profile) { - PowerProfiles.profile = profile - } - - Common.WidgetButton { - id: button - anchors.fill: parent - bar: root.bar - text: root.profileGlyphs[root.current] || "" - tooltipText: "Power profile: " + (root.profileLabels[root.current] || "Unknown") - onPressed: function() { root.popupOpen = !root.popupOpen } - } - - Common.PopupCard { - anchorItem: button - owner: root - bar: root.bar - open: root.popupOpen - contentWidth: 240 - contentHeight: column.implicitHeight + 28 - - Column { - id: column - anchors.fill: parent - spacing: 6 - - Text { - text: "Power Profile" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 12 - font.bold: true - } - - Repeater { - model: [ - { profile: PowerProfile.PowerSaver, label: "Power Saver", glyph: "󰌪" }, - { profile: PowerProfile.Balanced, label: "Balanced", glyph: "󰗑" }, - { profile: PowerProfile.Performance, label: "Performance", glyph: "󰓅" } - ] - - Common.PillButton { - required property var modelData - - width: parent.width - iconText: modelData.glyph - text: modelData.label - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 8 - active: root.current === modelData.profile - enabled: modelData.profile !== PowerProfile.Performance || PowerProfiles.hasPerformanceProfile - opacity: enabled ? 1 : 0.4 - onClicked: { root.setProfile(modelData.profile); root.popupOpen = false } - } - } - } - } -} diff --git a/default/quickshell/omarchy-shell/services/PluginRegistry.qml b/default/quickshell/omarchy-shell/services/PluginRegistry.qml index 1e3ccc27..8bc400fb 100644 --- a/default/quickshell/omarchy-shell/services/PluginRegistry.qml +++ b/default/quickshell/omarchy-shell/services/PluginRegistry.qml @@ -195,13 +195,19 @@ QtObject { // 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. + // Special cases (implicitly always enabled, no shell.json entry needed): + // - plugins whose `kinds` contains "bar" are mounted directly by the host. + // - first-party plugins are shell infrastructure (bar-settings, + // image-picker, ...). Requiring users to add them to plugins[] just to + // summon them was a footgun: a stock shell.json with `plugins: []` would + // silently make `omarchy launch bar-settings` a no-op. function isEnabled(id) { var key = String(id) var manifest = installedPlugins[key] - if (manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1) return true + if (manifest) { + if (Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1) return true + if (manifest.__isFirstParty) return true + } var config = shellConfigProvider ? shellConfigProvider() : null return findEntryLocation(config, key).found } diff --git a/default/quickshell/omarchy-shell/shell-defaults.json b/default/quickshell/omarchy-shell/shell-defaults.json index 4858850d..9fde55f1 100644 --- a/default/quickshell/omarchy-shell/shell-defaults.json +++ b/default/quickshell/omarchy-shell/shell-defaults.json @@ -27,12 +27,8 @@ { "id": "bluetoothPanel" }, { "id": "networkPanel" }, { "id": "audioPanel" }, - { "id": "nightLight" }, - { "id": "brightness" }, - { "id": "powerProfile" }, { "id": "battery" }, - { "id": "controlCenter" }, - { "id": "powerMenu" } + { "id": "controlCenter" } ] } }, diff --git a/default/quickshell/omarchy-shell/shell.qml b/default/quickshell/omarchy-shell/shell.qml index 5a9b898b..df8928ce 100644 --- a/default/quickshell/omarchy-shell/shell.qml +++ b/default/quickshell/omarchy-shell/shell.qml @@ -50,7 +50,7 @@ ShellRoot { layout: { left: [{ id: "omarchy" }, { id: "workspaces" }], center: [{ id: "calendar", format: "dddd HH:mm" }], - right: [{ id: "audioPanel" }, { id: "controlCenter" }, { id: "powerMenu" }] + right: [{ id: "audioPanel" }, { id: "controlCenter" }] } }, plugins: [ @@ -196,6 +196,11 @@ ShellRoot { // captured in the closure. Compat.PluginApiFactory { id: noctaliaApiFactory } + Item { + id: noctaliaServiceHost + visible: false + } + property var _noctaliaApis: ({}) property var _noctaliaServices: ({}) @@ -254,6 +259,7 @@ ShellRoot { shell.summon(key, JSON.stringify({ source: "noctalia" })) }, closePanel: function(_pluginId, _screen) { shell.hide(key) }, + tooltipService: NoctaliaUI.TooltipService, currentScreen: function() { var screens = Quickshell.screens return screens && screens.length > 0 ? screens[0] : null @@ -293,7 +299,7 @@ ShellRoot { console.warn("noctalia service load failed for " + key + ": " + comp.errorString()) return } - var inst = comp.createObject(shell, { pluginApi: api }) + var inst = comp.createObject(noctaliaServiceHost, { pluginApi: api }) if (!inst) { console.warn("noctalia service createObject returned null for", key) return @@ -418,6 +424,13 @@ ShellRoot { console.warn("summon: unknown plugin", id) return false } + // A disabled plugin has no Loader, so setting openPanelIds would only + // produce an invisible "open" state that toggle() then has to unwind. + // Tell the caller plainly instead of silently no-op'ing. + if (!shell.pluginRegistry.isEnabled(id)) { + console.warn("summon: plugin not enabled, not summoning:", id) + return false + } var next = ({}) for (var k in openPanelIds) next[k] = openPanelIds[k] next[id] = true @@ -539,7 +552,9 @@ ShellRoot { readonly property string sourceUrl: shell.pluginRegistry.entryPointUrl(manifest, entryKind) property Loader panelLoader: Loader { - source: panelEntry.sourceUrl + readonly property bool wrapNoctaliaPanel: !!(panelEntry.manifest && panelEntry.manifest.__noctaliaCompat && panelEntry.entryKind === "panel") + source: wrapNoctaliaPanel ? "" : panelEntry.sourceUrl + sourceComponent: wrapNoctaliaPanel ? noctaliaPanelWrapperComponent : null active: panelEntry.sourceUrl !== "" && (panelEntry.keepLoaded || shell.openPanelIds[panelEntry.pluginId] === true) asynchronous: true onLoaded: { @@ -558,13 +573,23 @@ ShellRoot { } onStatusChanged: { if (status === Loader.Error) { - console.warn("panel plugin " + panelEntry.pluginId + " failed:", - sourceComponent ? sourceComponent.errorString() : "") + // Loader.errorString() reflects the source-load failure even when + // sourceComponent is null. Surface both so the user sees something + // actionable instead of a panel that silently refuses to open. + var detail = errorString && errorString() ? errorString() : "" + if (!detail && sourceComponent) detail = sourceComponent.errorString() + console.warn("panel plugin " + panelEntry.pluginId + " failed to load:", detail) shell.hide(panelEntry.pluginId) } } Component.onDestruction: shell.unregisterPanelLoader(panelEntry.pluginId) } + + property Component noctaliaPanelWrapperComponent: Component { + Compat.PanelWrapper { + panelSource: panelEntry.sourceUrl + } + } } } diff --git a/migrations/1778715864.sh b/migrations/1778715864.sh new file mode 100644 index 00000000..e0a6e058 --- /dev/null +++ b/migrations/1778715864.sh @@ -0,0 +1,24 @@ +echo "Remove standalone night light, brightness, power profile, and power menu bar widgets (moved into quick settings or removed)" + +config="$HOME/.config/omarchy/shell.json" +[[ -f $config ]] || return 0 + +if ! grep -Eq '"id": *"(nightLight|brightness|powerProfile|powerMenu)"' "$config"; then + return 0 +fi + +if omarchy-cmd-missing jq; then + return 0 +fi + +tmp=$(mktemp) +if jq ' + def keep_widget: select(.id != "nightLight" and .id != "brightness" and .id != "powerProfile" and .id != "powerMenu"); + .bar.layout.left |= map(keep_widget) | + .bar.layout.center |= map(keep_widget) | + .bar.layout.right |= map(keep_widget) +' "$config" > "$tmp" 2>/dev/null && [[ -s $tmp ]]; then + mv "$tmp" "$config" +else + rm -f "$tmp" +fi