Refine quickshell bar and Noctalia compatibility

This commit is contained in:
Ryan Hughes
2026-05-14 02:21:48 -04:00
parent 5086654d51
commit 7d2745b6a6
28 changed files with 1096 additions and 699 deletions
+16 -5
View File
@@ -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
@@ -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",
@@ -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
+1 -2
View File
@@ -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" }
]
}
},
@@ -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)
}
}
}
@@ -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
@@ -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) }
@@ -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
}
}
@@ -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()
}
}
@@ -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)
}
}
}
@@ -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
@@ -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
}
}
}
@@ -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)
}
@@ -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`) |
@@ -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: ""
@@ -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) {
@@ -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 |
@@ -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) }
}
}
@@ -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 "󰓃"
}
}
@@ -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) }
}
}
}
}
@@ -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
}
}
@@ -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() }
}
}
@@ -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")
}
}
}
}
@@ -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 }
}
}
}
}
}
@@ -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
}
@@ -27,12 +27,8 @@
{ "id": "bluetoothPanel" },
{ "id": "networkPanel" },
{ "id": "audioPanel" },
{ "id": "nightLight" },
{ "id": "brightness" },
{ "id": "powerProfile" },
{ "id": "battery" },
{ "id": "controlCenter" },
{ "id": "powerMenu" }
{ "id": "controlCenter" }
]
}
},
+30 -5
View File
@@ -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
}
}
}
}
+24
View File
@@ -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