diff --git a/dotfiles/.config/quickshell/config/Appearance.qml b/dotfiles/.config/quickshell/config/Appearance.qml index e598eda..a83c8a0 100644 --- a/dotfiles/.config/quickshell/config/Appearance.qml +++ b/dotfiles/.config/quickshell/config/Appearance.qml @@ -2,7 +2,7 @@ pragma Singleton import QtQuick QtObject { - readonly property string fontFamily: "Noto Sans" + readonly property string fontFamily: "CaskaydiaCove Nerd Font Mono" readonly property string iconFontFamily: "Material Symbols Outlined" // Material Symbols is a variable font. Left unset, glyphs fall back to the // font's default optical size (24px), which is tuned for larger rendering diff --git a/dotfiles/.config/quickshell/config/Colors.qml b/dotfiles/.config/quickshell/config/Colors.qml index 311efbf..62a35b1 100644 --- a/dotfiles/.config/quickshell/config/Colors.qml +++ b/dotfiles/.config/quickshell/config/Colors.qml @@ -36,7 +36,10 @@ QtObject { // identifier as a signal handler slot, not a regular property. readonly property color primaryText: background readonly property color urgent: paletteColor(1) - readonly property color muted: paletteColor(8) + // paletteColor(8) alone renders quite dark/low-contrast for text (it's + // meant as a muted *background* tone in most wal palettes) — lighten it + // so muted labels stay legible against the bar's dark background. + readonly property color muted: Qt.lighter(paletteColor(8), 2.2) readonly property color surface: Qt.lighter(background, 1.35) // QtObject has no default property, so the FileView is attached via a diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index ecc0363..0aa7c2b 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -47,9 +47,9 @@ PanelWindow { } spacing: Appearance.spacing Tray {} - NetworkIndicator {} + NetworkIndicator { barWindow: bar } BluetoothIndicator {} - Volume {} - PowerButton {} + Volume { barWindow: bar } + PowerButton { barWindow: bar } } } diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml index 5af66b5..82b8d5b 100644 --- a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Io +import Quickshell.Hyprland import "../../config" Item { @@ -9,6 +10,12 @@ Item { implicitWidth: label.implicitWidth + 4 implicitHeight: Appearance.barHeight + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + Text { id: label anchors.centerIn: parent @@ -22,7 +29,10 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: menu.visible = !menu.visible + onClicked: { + menu.visible = !menu.visible + grab.active = menu.visible + } } Process { id: proc } @@ -31,79 +41,134 @@ Item { id: menu visible: false color: "transparent" - implicitWidth: 170 - implicitHeight: list.model.length * 32 + (list.model.length - 1) * 2 + 16 + implicitWidth: 210 + // Same padding as Volume.qml's panel. + readonly property int contentPadding: 16 + readonly property int labelOffset: -135 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 anchor { - item: root + window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar (and thus the true screen edges) + // instead of a near-zero rect at the window's origin. + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height edges: Edges.Bottom | Edges.Right gravity: Edges.Bottom | Edges.Left - margins.top: 4 + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [menu] + onCleared: menu.visible = false } Rectangle { anchors.fill: parent - radius: Appearance.radius + radius: 0 color: Colors.surface border.color: Colors.muted - border.width: 1 + border.width: 3 Column { + id: mainColumn anchors.fill: parent - anchors.margins: 8 - spacing: 2 + anchors.margins: menu.contentPadding + spacing: 10 - Repeater { - id: list - model: [ - // "iconSize" is optional — omit it to use the 15px default below. - // Material Symbols glyphs aren't all the same visual weight at a - // given size (a padlock silhouette reads bulkier than a thin arrow), - // so nudge individual icons here to balance them by eye. - { label: "Lock", icon: "lock", iconSize: 13, cmd: ["hyprlock"] }, - { label: "Log out", icon: "logout", cmd: ["hyprctl", "dispatch", "exit"] }, - { label: "Reboot", icon: "restart_alt", cmd: ["systemctl", "reboot"] }, - { label: "Shut down", icon: "power_settings_new", cmd: ["systemctl", "poweroff"] } - ] + // Header — same title layout as Volume.qml's panel: the bar + // icon large, plus a bold title. + RowLayout { + id: header + width: parent.width + spacing: 8 - delegate: Rectangle { - id: entry - required property var modelData - width: parent ? parent.width : 0 - height: 32 - radius: Appearance.radiusSmall - color: entryMouse.containsMouse ? Colors.primary : "transparent" + Text { + text: label.text + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + color: Colors.foreground + } - RowLayout { - anchors.fill: parent - anchors.leftMargin: 8 - spacing: 8 - Text { - Layout.preferredWidth: 20 - horizontalAlignment: Text.AlignHCenter - text: entry.modelData.icon - font.family: Appearance.iconFontFamily - font.variableAxes: Appearance.iconFontAxes - font.pixelSize: entry.modelData.iconSize || 15 - color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + Text { + Layout.fillWidth: true + text: "Power" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + } + + Column { + width: parent.width + spacing: 2 + + Repeater { + id: list + model: [ + { label: "Lock", icon: "lock", cmd: ["hyprlock"] }, + { label: "Suspend", icon: "bedtime", cmd: ["systemctl", "suspend"] }, + { label: "Hibernate", icon: "ac_unit", cmd: ["systemctl", "hibernate"] }, + { label: "Log out", icon: "logout", cmd: ["hyprctl", "dispatch", "exit"] }, + { label: "Reboot", icon: "restart_alt", cmd: ["systemctl", "reboot"] }, + { label: "Shut down", icon: "power_settings_new", cmd: ["systemctl", "poweroff"] } + ] + + delegate: Rectangle { + id: entry + required property var modelData + width: parent ? parent.width : 0 + height: 40 + radius: Appearance.radiusSmall + color: (!entry.modelData.disabled && entryMouse.containsMouse) ? Colors.primary : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + Text { + Layout.preferredWidth: 25 + Layout.minimumWidth: 25 + Layout.maximumWidth: 25 + horizontalAlignment: Text.AlignLeft + text: entry.modelData.icon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: entry.modelData.iconSize || 15 + opacity: entry.modelData.disabled ? 0.4 : 1 + color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.leftMargin: menu.labelOffset + (entry.modelData.textOffset || 0) + text: entry.modelData.label + horizontalAlignment: Text.AlignLeft + font.family: Appearance.fontFamily + font.pixelSize: 16 + opacity: entry.modelData.disabled ? 0.4 : 1 + color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } } - Text { - text: entry.modelData.label - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSize - color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground - } - } - MouseArea { - id: entryMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - proc.command = entry.modelData.cmd - proc.running = true - menu.visible = false + MouseArea { + id: entryMouse + anchors.fill: parent + enabled: !entry.modelData.disabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + proc.command = entry.modelData.cmd + proc.running = true + menu.visible = false + grab.active = false + } } } } diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index 9c3f94b..8c893fa 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -1,4 +1,7 @@ import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland import Quickshell.Services.Pipewire import "../../config" @@ -7,11 +10,87 @@ Item { implicitWidth: label.implicitWidth + 4 implicitHeight: Appearance.barHeight - readonly property var sink: Pipewire.defaultAudioSink - readonly property real vol: (sink && sink.audio) ? sink.audio.volume : 0 - readonly property bool muted: (sink && sink.audio) ? sink.audio.muted : false + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow - PwObjectTracker { objects: root.sink ? [root.sink] : [] } + readonly property var sink: Pipewire.defaultAudioSink + readonly property var source: Pipewire.defaultAudioSource + readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : [] + + readonly property real outputVolume: (root.sink && root.sink.audio) ? root.sink.audio.volume : 0 + readonly property bool outputMuted: (root.sink && root.sink.audio) ? root.sink.audio.muted : false + readonly property real inputVolume: (root.source && root.source.audio) ? root.source.audio.volume : 0 + readonly property bool inputMuted: (root.source && root.source.audio) ? root.source.audio.muted : false + + readonly property var candidateSinks: root.nodes.filter(function(n) { return n && n.isSink && !n.isStream }) + readonly property var candidateSources: root.nodes.filter(function(n) { return n && !n.isSink && !n.isStream && n.audio }) + + PwObjectTracker { objects: root.candidateSinks } + PwObjectTracker { objects: root.candidateSources } + + // Live mic level for the input gauge below — separate from inputVolume + // (the configured gain), this tracks the actual incoming signal. + // Only runs while the panel is open, to skip metering when unused. + PwNodePeakMonitor { + id: inputPeak + node: root.source + enabled: panel.visible + } + + // --- Device label/icon heuristics, ported from omarchy's audio panel + // Model.js (github.com/basecamp/omarchy shell/plugins/panels/audio/Model.js), + // adapted to this project's Material Symbols icon set instead of Nerd Font. + function friendlyDeviceLabel(text) { + var out = String(text || "").trim() + out = out.replace(/^sof-soundwire\s+/i, "") + out = out.replace(/^built-?in audio\s+/i, "") + out = out.replace(/\s+Output$/i, "") + out = out.replace(/\s+Input$/i, "") + out = out.replace(/Microphones/gi, "Microphone") + return out + } + + function deviceLabel(node) { + if (!node) return "Unknown" + var nick = friendlyDeviceLabel(node.nickname || "") + if (nick) return nick + return friendlyDeviceLabel(node.description || node.name || "Unknown") + } + + function deviceBlob(node) { + if (!node) return "" + var p = node.properties || {} + return [node.name, node.description, node.nickname, + p["device.icon-name"] || "", p["device.product.name"] || ""].join(" ").toLowerCase() + } + + function isHeadphoneNode(node) { + var blob = deviceBlob(node) + return blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1 + || blob.indexOf("earbud") !== -1 || blob.indexOf("earphone") !== -1 + || blob.indexOf("airpod") !== -1 + } + + function sinkIcon(node) { + if (!node) return "speaker" + if (isHeadphoneNode(node)) return "headphones" + var blob = deviceBlob(node) + if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio" + if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "tv" + return "speaker" + } + + function sourceIcon(node) { + if (!node) return "mic" + var blob = deviceBlob(node) + if (blob.indexOf("headset") !== -1) return "headphones" + if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio" + if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "videocam" + return "mic" + } Text { id: label @@ -20,19 +99,17 @@ Item { font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.foreground - text: { - if (root.muted || root.vol <= 0) return "volume_off" - if (root.vol < 0.5) return "volume_down" - return "volume_up" - } + text: (root.outputMuted || root.outputVolume <= 0) ? "volume_off" + : (root.outputVolume < 0.5 ? "volume_down" : "volume_up") } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton - onClicked: if (root.sink && root.sink.audio) root.sink.audio.muted = !root.sink.audio.muted - + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } onWheel: (wheel) => { if (!root.sink || !root.sink.audio) return const step = 0.05 @@ -40,4 +117,330 @@ Item { root.sink.audio.volume = Math.max(0, Math.min(1, root.sink.audio.volume + delta)) } } + + // --- Reusable pieces ----------------------------------------------- + + component VolumeSlider: Item { + id: sliderRoot + property real value: 0 + property bool enabled: true + signal moved(real newValue) + implicitHeight: 16 + + Rectangle { + id: track + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 4 + radius: 0 + color: Colors.muted + opacity: 0.35 + } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: track.width * Math.max(0, Math.min(1, sliderRoot.value)) + height: 4 + radius: 0 + color: Colors.primary + opacity: sliderRoot.enabled ? 1 : 0.4 + } + Rectangle { + width: 12 + height: 12 + radius: 6 + color: Colors.primary + opacity: sliderRoot.enabled ? 1 : 0.4 + anchors.verticalCenter: parent.verticalCenter + x: Math.max(0, Math.min(track.width - width, track.width * sliderRoot.value - width / 2)) + } + MouseArea { + anchors.fill: parent + enabled: sliderRoot.enabled + cursorShape: Qt.PointingHandCursor + function updateFromX(mx) { + sliderRoot.moved(Math.max(0, Math.min(1, mx / track.width))) + } + onPressed: (mouse) => updateFromX(mouse.x) + onPositionChanged: (mouse) => { if (pressed) updateFromX(mouse.x) } + } + } + + // Read-only live level gauge — same track styling as VolumeSlider but no + // handle/interaction, filled from an actual signal level instead of a + // configured value. + component LevelMeter: Item { + id: meterRoot + property real level: 0 + implicitHeight: 4 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 4 + radius: 0 + color: Colors.muted + opacity: 0.25 + } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + height: 4 + radius: 0 + color: Colors.paletteColor(2) + width: parent.width * Math.max(0, Math.min(1, meterRoot.level)) + Behavior on width { NumberAnimation { duration: 80 } } + } + } + + component ToggleSwitch: Item { + id: toggleRoot + property bool checked: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: toggleRoot.checked ? 1 : 0.35 + } + Rectangle { + width: parent.height - 4 + height: parent.height - 4 + radius: 0 + color: Colors.background + anchors.verticalCenter: parent.verticalCenter + x: toggleRoot.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + component DeviceRow: Rectangle { + id: deviceRow + required property var node + required property string icon + required property bool active + signal activate() + width: parent ? parent.width : 0 + height: 30 + radius: 0 + color: rowMouse.containsMouse ? Colors.primary + : (deviceRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + Text { + Layout.preferredWidth: 18 + Layout.minimumWidth: 18 + Layout.maximumWidth: 18 + horizontalAlignment: Text.AlignHCenter + text: deviceRow.icon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: root.deviceLabel(deviceRow.node) + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: deviceRow.active + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: deviceRow.activate() + } + } + + // --- Panel ----------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + // Padding between the panel's border and its content. implicitWidth + // is contentWidth + padding on both sides, so the content area + // itself stays a fixed size regardless of how much padding surrounds it. + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + + anchor { + window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar (and thus the true screen edges) + // instead of a near-zero rect at the window's origin. + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Header + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 40 + color: Colors.foreground + } + + Text { + Layout.fillWidth: true + text: "Audio" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + + ToggleSwitch { + checked: !root.outputMuted + onToggled: if (root.sink && root.sink.audio) root.sink.audio.muted = !root.sink.audio.muted + } + } + + // Output + Column { + width: parent.width + spacing: 6 + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "OUTPUT" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Text { + text: Math.round(root.outputVolume * 100) + "%" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + VolumeSlider { + width: parent.width + value: root.outputVolume + enabled: !!root.sink + onMoved: (v) => { if (root.sink && root.sink.audio) root.sink.audio.volume = v } + } + + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.candidateSinks + DeviceRow { + required property var modelData + node: modelData + icon: root.sinkIcon(modelData) + active: !!(root.sink && modelData && root.sink.id === modelData.id) + onActivate: Pipewire.preferredDefaultAudioSink = modelData + } + } + } + } + + // Input + Column { + width: parent.width + spacing: 6 + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "INPUT" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Text { + text: Math.round(root.inputVolume * 100) + "%" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + VolumeSlider { + width: parent.width + value: root.inputVolume + enabled: !!root.source + onMoved: (v) => { if (root.source && root.source.audio) root.source.audio.volume = v } + } + + LevelMeter { + width: parent.width + level: inputPeak.peak + } + + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.candidateSources + DeviceRow { + required property var modelData + node: modelData + icon: root.sourceIcon(modelData) + active: !!(root.source && modelData && root.source.id === modelData.id) + onActivate: Pipewire.preferredDefaultAudioSource = modelData + } + } + } + } + } + } + } }