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
@@ -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 }
}
}
}
}
}