diff --git a/dotfiles/.config/quickshell/config/Appearance.qml b/dotfiles/.config/quickshell/config/Appearance.qml new file mode 100644 index 0000000..e598eda --- /dev/null +++ b/dotfiles/.config/quickshell/config/Appearance.qml @@ -0,0 +1,25 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property string fontFamily: "Noto Sans" + 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 + // and makes some icons (e.g. "lock") look heavier/bigger than others when + // drawn small — pin "opsz" near the actual render size to keep icons + // visually consistent. See Appearance.iconSize for the matching pixelSize. + readonly property var iconFontAxes: ({ "FILL": 0, "wght": 400, "GRAD": 0, "opsz": 20 }) + readonly property int iconSize: 16 + + readonly property int fontSize: 13 + readonly property int fontSizeSmall: 11 + + readonly property int radius: 14 + readonly property int radiusSmall: 8 + + readonly property int barHeight: 34 + readonly property int spacing: 10 + + readonly property int animationDuration: 150 +} diff --git a/dotfiles/.config/quickshell/config/Colors.qml b/dotfiles/.config/quickshell/config/Colors.qml new file mode 100644 index 0000000..311efbf --- /dev/null +++ b/dotfiles/.config/quickshell/config/Colors.qml @@ -0,0 +1,59 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// Wallpaper-driven palette, sourced from pywal's ~/.cache/wal/colors.json. +// Run `wal -i /path/to/wallpaper.png` to (re)generate it — this file watches +// the cache and updates live, no shell restart needed. Until that's been run +// once, everything falls back to a hand-picked dark palette below. +QtObject { + id: root + + readonly property bool active: _parsed !== null + property var _parsed: null + + // --- Fallback palette (used until pywal has run) --- + readonly property color fallbackBg: "#1a1b26" + readonly property color fallbackFg: "#c0caf5" + readonly property var fallbackPalette: [ + "#15161e", "#f7768e", "#9ece6a", "#e0af68", + "#7aa2f7", "#bb9af7", "#7dcfff", "#a9b1d6", + "#414868", "#f7768e", "#9ece6a", "#e0af68", + "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5" + ] + + function paletteColor(i) { + if (_parsed && _parsed.colors) + return _parsed.colors["color" + i] || fallbackPalette[i] + return fallbackPalette[i] + } + + readonly property color background: _parsed && _parsed.special ? _parsed.special.background : fallbackBg + readonly property color foreground: _parsed && _parsed.special ? _parsed.special.foreground : fallbackFg + readonly property color primary: paletteColor(4) + // Named primaryText (not "onPrimary") because QML treats any "onXxx" + // 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) + readonly property color surface: Qt.lighter(background, 1.35) + + // QtObject has no default property, so the FileView is attached via a + // property assignment rather than as a plain child element. + readonly property FileView _walFile: FileView { + path: Quickshell.env("HOME") + "/.cache/wal/colors.json" + watchChanges: true + onLoaded: { + try { + root._parsed = JSON.parse(text()) + } catch (e) { + console.warn("Colors: failed to parse pywal cache:", e) + root._parsed = null + } + } + onLoadFailed: (error) => { + root._parsed = null + } + } +} diff --git a/dotfiles/.config/quickshell/config/qmldir b/dotfiles/.config/quickshell/config/qmldir new file mode 100644 index 0000000..26da31e --- /dev/null +++ b/dotfiles/.config/quickshell/config/qmldir @@ -0,0 +1,3 @@ +module config +singleton Colors 1.0 Colors.qml +singleton Appearance 1.0 Appearance.qml diff --git a/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml b/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml new file mode 100644 index 0000000..beb5d74 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland +import "../../config" + +Text { + Layout.maximumWidth: 320 + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + text: Hyprland.activeToplevel ? Hyprland.activeToplevel.title : "" +} diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml new file mode 100644 index 0000000..ecc0363 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -0,0 +1,55 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts +import "../../config" + +PanelWindow { + id: bar + + anchors { + top: true + left: true + right: true + } + implicitHeight: Appearance.barHeight + exclusiveZone: implicitHeight + color: "transparent" + + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "quickshell:bar" + + Rectangle { + anchors.fill: parent + color: Colors.background + } + + RowLayout { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: Appearance.spacing + } + spacing: Appearance.spacing + Workspaces { screen: bar.screen } + ActiveWindow {} + } + + Clock { + anchors.centerIn: parent + } + + RowLayout { + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + rightMargin: Appearance.spacing + } + spacing: Appearance.spacing + Tray {} + NetworkIndicator {} + BluetoothIndicator {} + Volume {} + PowerButton {} + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml new file mode 100644 index 0000000..8087ee1 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -0,0 +1,38 @@ +import QtQuick +import Quickshell.Bluetooth +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + readonly property var adapter: Bluetooth.defaultAdapter + readonly property bool anyConnected: { + if (!adapter) return false + const devices = adapter.devices.values + for (let i = 0; i < devices.length; i++) { + if (devices[i].connected) return true + } + return false + } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: (root.adapter && root.adapter.enabled) ? Colors.foreground : Colors.muted + text: { + if (!root.adapter || !root.adapter.enabled) return "bluetooth_disabled" + return root.anyConnected ? "bluetooth_connected" : "bluetooth" + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/Clock.qml b/dotfiles/.config/quickshell/modules/bar/Clock.qml new file mode 100644 index 0000000..66db3c9 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Clock.qml @@ -0,0 +1,18 @@ +import QtQuick +import "../../config" + +Text { + id: root + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: true + color: Colors.foreground + text: Qt.formatDateTime(new Date(), "ddd d MMM hh:mm") + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: root.text = Qt.formatDateTime(new Date(), "ddd d MMM hh:mm") + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml new file mode 100644 index 0000000..e15be89 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -0,0 +1,35 @@ +import QtQuick +import Quickshell.Networking +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + readonly property var connectedDevice: { + const devices = Networking.devices.values + for (let i = 0; i < devices.length; i++) { + if (devices[i].connected) + return devices[i] + } + return null + } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: root.connectedDevice ? Colors.foreground : Colors.muted + text: { + if (!root.connectedDevice) + return "wifi_off" + return root.connectedDevice.type === DeviceType.Wired ? "settings_ethernet" : "wifi" + } + } + + // TODO(follow-up): wire this up to a network panel (SSID list, connect/forget) + // once the control center lands. +} diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml new file mode 100644 index 0000000..5af66b5 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.foreground + text: "power_settings_new" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: menu.visible = !menu.visible + } + + Process { id: proc } + + PopupWindow { + id: menu + visible: false + color: "transparent" + implicitWidth: 170 + implicitHeight: list.model.length * 32 + (list.model.length - 1) * 2 + 16 + + anchor { + item: root + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.top: 4 + } + + Rectangle { + anchors.fill: parent + radius: Appearance.radius + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + Column { + anchors.fill: parent + anchors.margins: 8 + spacing: 2 + + 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"] } + ] + + delegate: Rectangle { + id: entry + required property var modelData + width: parent ? parent.width : 0 + height: 32 + radius: Appearance.radiusSmall + color: entryMouse.containsMouse ? Colors.primary : "transparent" + + 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 { + 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 + } + } + } + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/Tray.qml b/dotfiles/.config/quickshell/modules/bar/Tray.qml new file mode 100644 index 0000000..84c0597 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Tray.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import "../../config" + +RowLayout { + spacing: Appearance.spacing * 0.6 + + Repeater { + model: SystemTray.items + + delegate: IconImage { + id: trayIcon + required property var modelData + implicitWidth: 16 + implicitHeight: 16 + source: modelData.icon + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) + trayIcon.modelData.activate() + else + trayIcon.modelData.secondaryActivate() + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml new file mode 100644 index 0000000..9c3f94b --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -0,0 +1,43 @@ +import QtQuick +import Quickshell.Services.Pipewire +import "../../config" + +Item { + id: root + 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 + + PwObjectTracker { objects: root.sink ? [root.sink] : [] } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + 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" + } + } + + 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 + + onWheel: (wheel) => { + if (!root.sink || !root.sink.audio) return + const step = 0.05 + const delta = wheel.angleDelta.y > 0 ? step : -step + root.sink.audio.volume = Math.max(0, Math.min(1, root.sink.audio.volume + delta)) + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/Workspaces.qml b/dotfiles/.config/quickshell/modules/bar/Workspaces.qml new file mode 100644 index 0000000..bf655b4 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Workspaces.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland +import "../../config" + +RowLayout { + id: root + property var screen + + spacing: 4 + + Repeater { + model: Hyprland.workspaces + + delegate: Rectangle { + id: pill + required property var modelData + visible: modelData.monitor && root.screen && modelData.monitor.name === root.screen.name + + implicitWidth: 26 + implicitHeight: 22 + radius: Appearance.radiusSmall + color: modelData.urgent ? Colors.urgent + : modelData.active ? Colors.primary + : "transparent" + + Behavior on color { ColorAnimation { duration: Appearance.animationDuration } } + + Text { + anchors.centerIn: parent + text: modelData.name + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: modelData.active ? Colors.primaryText : Colors.foreground + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Hyprland.dispatch("workspace " + modelData.name) + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml new file mode 100644 index 0000000..b4381ee --- /dev/null +++ b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml @@ -0,0 +1,78 @@ +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Pipewire +import QtQuick +import QtQuick.Layouts +import "../../config" + +PanelWindow { + id: osd + visible: false + color: "transparent" + exclusiveZone: 0 + + anchors { bottom: true } + margins.bottom: 70 + + implicitWidth: 240 + implicitHeight: 54 + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell:osd" + + readonly property var sink: Pipewire.defaultAudioSink + PwObjectTracker { objects: osd.sink ? [osd.sink] : [] } + + Timer { + id: hideTimer + interval: 1400 + onTriggered: osd.visible = false + } + + Connections { + target: osd.sink ? osd.sink.audio : null + function onVolumeChanged() { osd.visible = true; hideTimer.restart() } + function onMutedChanged() { osd.visible = true; hideTimer.restart() } + } + + Rectangle { + anchors.fill: parent + radius: Appearance.radius + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 12 + + Text { + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 18 + color: Colors.foreground + text: { + const audio = osd.sink ? osd.sink.audio : null + if (!audio || audio.muted || audio.volume <= 0) return "volume_off" + return audio.volume < 0.5 ? "volume_down" : "volume_up" + } + } + + Rectangle { + Layout.fillWidth: true + height: 6 + radius: 3 + color: Colors.muted + + Rectangle { + height: parent.height + radius: 3 + color: Colors.primary + width: parent.width * (osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0) + Behavior on width { NumberAnimation { duration: Appearance.animationDuration } } + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/shell.qml b/dotfiles/.config/quickshell/shell.qml new file mode 100644 index 0000000..ab67430 --- /dev/null +++ b/dotfiles/.config/quickshell/shell.qml @@ -0,0 +1,16 @@ +import Quickshell +import "./modules/bar" +import "./modules/osd" + +ShellRoot { + Variants { + model: Quickshell.screens + + Bar { + required property var modelData + screen: modelData + } + } + + VolumeOsd {} +}