diff --git a/default/quickshell/bar/README.md b/default/quickshell/bar/README.md index ed844bc8..f01ff553 100644 --- a/default/quickshell/bar/README.md +++ b/default/quickshell/bar/README.md @@ -4,36 +4,59 @@ This is the Quickshell implementation of the Omarchy status bar. - `shell.qml` is Omarchy-owned bar engine code. Users should not edit it directly. - `bar-defaults.json` is the Omarchy-owned default layout and module settings. +- `widgets/` holds first-party widgets — modular, interactive components shipped with Omarchy. +- `common/` holds shared QML helpers (buttons, sliders, popup cards). - User overrides live in `~/.config/omarchy/bar.json` and are merged over defaults at runtime. - `omarchy-style-bar-position` updates only the user override file. -- Wi-Fi/LAN state is polled from the active route and NetworkManager so the QML can mirror Waybar's network icons. -- Tooltips use a small Quickshell `PopupWindow`, not Qt Quick Controls' clipped default tooltip. Example user override: ```json { - "position": "right", + "position": "top", "layout": { - "left": ["omarchy", "workspaces"], - "center": ["clock", "weather", "update"], - "right": ["tray", "bluetooth", "network", "audio", "cpu", "battery"] + "left": ["omarchy", "workspacesPro"], + "center": ["media", "calendar", "weatherFlyout"], + "right": ["systemStats", "notificationCenter", "bluetoothPanel", "networkPanel", "audioPanel", "brightness", "powerProfile", "battery", "powerMenu"] }, - "modules": { - "clock": { - "format": "HH:mm", - "formatAlt": "dd MMMM 'W'ww yyyy", - "verticalFormat": "HH\n—\nmm" - } - } + "centerAnchor": "calendar" } ``` -`centerAnchor` defaults to `clock`. When the anchor module is present in the `center` list, it is pinned to the exact center of the bar and modules before/after it flank that anchor. If the anchor is not in the center list, the center list is centered as a group. +`centerAnchor` pins one center module to the exact horizontal/vertical center and flanks others around it. -Available built-in modules: `omarchy`, `workspaces`, `clock`, `weather`, `update`, `voxtype`, `screenRecording`, `idle`, `notifications`, `tray`, `bluetooth`, `network`, `audio`, `cpu`, `battery`. +## Module catalogue -## Custom modules +### First-party interactive widgets (in `widgets/`) + +| Name | What it does | Interactions | +|---|---|---| +| `media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup | +| `audioPanel` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = audio TUI · scroll = volume | +| `networkPanel` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect | left = popup · right = nmtui | +| `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 | +| `workspacesPro` | Animated focus indicator that slides between workspaces | left = focus · right = move window · scroll = cycle | +| `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 | + +### Built-in legacy modules (in `shell.qml`) + +`omarchy`, `workspaces`, `clock`, `weather`, `update`, `voxtype`, `screenRecording`, `idle`, `notifications`, `tray`, `bluetooth`, `network`, `audio`, `cpu`, `battery`. + +These remain available — set them in `layout` to use them instead of the richer widget versions. + +## Orientation + +All widgets work in `top`, `bottom`, `left`, and `right` positions. Popups anchor on the side opposite the bar edge, sliding into the workspace. Vertical bars use 28px width; widgets that show text fall back to compact icon-only forms (e.g. `media` hides its scrolling label). + +## Custom user modules Add a module name to a layout list, then define it under `modules` in `~/.config/omarchy/bar.json`. @@ -42,7 +65,7 @@ For simple text/JSON output, use a command module: ```json { "layout": { - "right": ["tray", "vpn", "audio", "cpu"] + "right": ["tray", "vpn", "audioPanel", "cpu"] }, "modules": { "vpn": { @@ -67,7 +90,7 @@ For advanced custom UI, use QML: ```json { "layout": { - "right": ["gpu", "audio", "cpu"] + "right": ["gpu", "audioPanel", "cpu"] }, "modules": { "gpu": { @@ -106,3 +129,19 @@ Item { } } ``` + +## Bar properties available to widgets + +Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (object) injected at load time. The bar exposes: + +- `bar.foreground`, `bar.background`, `bar.urgent` — theme colors (live-updated) +- `bar.fontFamily` — current monospace family +- `bar.position` — `"top" | "bottom" | "left" | "right"` +- `bar.vertical` — boolean shortcut +- `bar.barSize` — 26 horizontal / 28 vertical +- `bar.run(command)` — fire-and-forget bash exec +- `bar.shellQuote(value)` — safe shell-quote a string +- `bar.showTooltip(target, text)` / `bar.hideTooltip(target)` — shared tooltip popup +- `bar.requestPopout(owner)` / `bar.releasePopout(owner)` — one-popup-at-a-time coordinator + +Drop new widgets into `widgets/.qml`, add the name to the `firstPartyWidgets` registry in `shell.qml`, and reference it by name in any layout list. diff --git a/default/quickshell/bar/bar-defaults.json b/default/quickshell/bar/bar-defaults.json index 546bb1dc..aad6a492 100644 --- a/default/quickshell/bar/bar-defaults.json +++ b/default/quickshell/bar/bar-defaults.json @@ -1,13 +1,18 @@ { "position": "top", "fontFamily": "JetBrainsMono Nerd Font", - "centerAnchor": "clock", + "centerAnchor": "calendar", "layout": { - "left": ["omarchy", "workspaces"], - "center": ["clock", "weather", "update", "voxtype", "screenRecording", "idle", "notifications"], - "right": ["tray", "bluetooth", "network", "audio", "cpu", "battery"] + "left": ["omarchy", "workspacesPro"], + "center": ["media", "calendar", "weatherFlyout", "update", "voxtype", "screenRecording", "idle"], + "right": ["tray", "systemStats", "notificationCenter", "microphone", "bluetoothPanel", "networkPanel", "audioPanel", "brightness", "powerProfile", "battery", "powerMenu"] }, "modules": { + "calendar": { + "format": "dddd HH:mm", + "formatAlt": "dd MMMM 'W'ww yyyy", + "verticalFormat": "HH\n—\nmm" + }, "clock": { "format": "dddd HH:mm", "formatAlt": "dd MMMM 'W'ww yyyy", diff --git a/default/quickshell/bar/common/PillButton.qml b/default/quickshell/bar/common/PillButton.qml new file mode 100644 index 00000000..06c009c3 --- /dev/null +++ b/default/quickshell/bar/common/PillButton.qml @@ -0,0 +1,66 @@ +import QtQuick + +Rectangle { + id: root + + property string text: "" + property string iconText: "" + property color foreground: "#cacccc" + property color background: "transparent" + property color hoverBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.12) + property color pressedBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.22) + property string fontFamily: "JetBrainsMono Nerd Font" + property real fontSize: 12 + property real iconSize: 14 + property real horizontalPadding: 10 + property real verticalPadding: 6 + property bool active: false + property color activeBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18) + + signal clicked() + signal rightClicked() + + implicitWidth: row.implicitWidth + horizontalPadding * 2 + implicitHeight: row.implicitHeight + verticalPadding * 2 + radius: 4 + color: mouseArea.pressed ? pressedBackground : (mouseArea.containsMouse ? hoverBackground : (active ? activeBackground : background)) + + Behavior on color { + ColorAnimation { duration: 120 } + } + + Row { + id: row + anchors.centerIn: parent + spacing: 8 + + Text { + visible: root.iconText !== "" + text: root.iconText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: root.iconSize + anchors.verticalCenter: parent.verticalCenter + } + + Text { + visible: root.text !== "" + text: root.text + color: root.foreground + font.family: root.fontFamily + font.pixelSize: root.fontSize + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) root.rightClicked() + else root.clicked() + } + } +} diff --git a/default/quickshell/bar/common/PopupCard.qml b/default/quickshell/bar/common/PopupCard.qml new file mode 100644 index 00000000..96184dee --- /dev/null +++ b/default/quickshell/bar/common/PopupCard.qml @@ -0,0 +1,93 @@ +import QtQuick +import Quickshell + +PopupWindow { + id: root + + required property Item anchorItem + required property QtObject bar + property var owner: null + property int margin: 8 + property int padding: 14 + property int contentWidth: 280 + property int contentHeight: 200 + property bool dismissOnOutsideClick: true + property bool open: false + + onOpenChanged: { + if (!bar) return + if (open) bar.requestPopout(owner || root) + else if (bar.activePopout === (owner || root)) bar.releasePopout(owner || root) + } + + default property alias contentItem: contentHolder.children + + visible: open + color: "transparent" + implicitWidth: contentWidth + implicitHeight: contentHeight + + anchor { + id: popupAnchor + window: anchorItem ? anchorItem.QsWindow.window : null + adjustment: PopupAdjustment.Slide + edges: Edges.Top | Edges.Left + gravity: Edges.Bottom | Edges.Right + rect.width: 1 + rect.height: 1 + + onAnchoring: { + if (!root.anchorItem || !root.bar) return + + var target = root.anchorItem + var popupWidth = root.implicitWidth + var popupHeight = root.implicitHeight + var localX = target.width / 2 - popupWidth / 2 + var localY = target.height + root.margin + + if (root.bar.position === "bottom") { + localY = -popupHeight - root.margin + } else if (root.bar.position === "left") { + localX = target.width + root.margin + localY = target.height / 2 - popupHeight / 2 + } else if (root.bar.position === "right") { + localX = -popupWidth - root.margin + localY = target.height / 2 - popupHeight / 2 + } + + var window = target.QsWindow.window + if (!window) return + + var point = window.contentItem.mapFromItem(target, localX, localY) + popupAnchor.rect.x = Math.round(point.x) + popupAnchor.rect.y = Math.round(point.y) + } + } + + Rectangle { + id: card + anchors.fill: parent + color: root.bar ? root.bar.background : "#101315" + border.color: root.bar ? root.bar.foreground : "#cacccc" + border.width: 1 + radius: 0 + opacity: root.open ? 0.97 : 0 + + Behavior on opacity { + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } + + Item { + id: contentHolder + anchors.fill: parent + anchors.margins: root.padding + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: false + propagateComposedEvents: true + } + } +} diff --git a/default/quickshell/bar/common/Slider.qml b/default/quickshell/bar/common/Slider.qml new file mode 100644 index 00000000..12e6db69 --- /dev/null +++ b/default/quickshell/bar/common/Slider.qml @@ -0,0 +1,112 @@ +import QtQuick + +Item { + id: root + + property QtObject bar: null + property real value: 0 + property real minimum: 0 + property real maximum: 1 + property real step: 0.05 + property bool integer: false + property color trackColor: bar ? Qt.rgba(bar.foreground.r, bar.foreground.g, bar.foreground.b, 0.18) : "#333" + property color fillColor: bar ? bar.foreground : "#cacccc" + property color knobColor: bar ? bar.foreground : "#cacccc" + property bool dragging: false + property real trackHeight: 4 + + signal moved(real value) + signal released(real value) + + implicitWidth: 200 + implicitHeight: 22 + + readonly property real range: Math.max(0.0001, maximum - minimum) + readonly property real progress: Math.max(0, Math.min(1, (value - minimum) / range)) + + Rectangle { + id: track + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + height: root.trackHeight + radius: height / 2 + color: root.trackColor + } + + Rectangle { + id: fill + anchors.verticalCenter: track.verticalCenter + anchors.left: track.left + height: track.height + radius: track.radius + color: root.fillColor + width: track.width * root.progress + + Behavior on width { + enabled: !root.dragging + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } + } + + Rectangle { + id: knob + width: 14 + height: 14 + radius: 7 + color: root.knobColor + border.color: root.bar ? root.bar.background : "#101315" + border.width: 2 + anchors.verticalCenter: track.verticalCenter + x: Math.max(0, Math.min(track.width - width, track.width * root.progress - width / 2)) + scale: mouseArea.containsMouse || root.dragging ? 1.15 : 1.0 + + Behavior on x { + enabled: !root.dragging + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } + + Behavior on scale { + NumberAnimation { duration: 110; easing.type: Easing.OutCubic } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton + + function valueFromX(x) { + var clamped = Math.max(0, Math.min(track.width, x)) + var raw = root.minimum + (clamped / track.width) * root.range + if (root.integer) raw = Math.round(raw) + return Math.max(root.minimum, Math.min(root.maximum, raw)) + } + + onPressed: function(mouse) { + root.dragging = true + var next = valueFromX(mouse.x) + root.value = next + root.moved(next) + } + onPositionChanged: function(mouse) { + if (!root.dragging) return + var next = valueFromX(mouse.x) + root.value = next + root.moved(next) + } + onReleased: function(mouse) { + root.dragging = false + root.released(root.value) + } + onWheel: function(wheel) { + var delta = wheel.angleDelta.y > 0 ? root.step : -root.step + var next = Math.max(root.minimum, Math.min(root.maximum, root.value + delta)) + if (root.integer) next = Math.round(next) + root.value = next + root.moved(next) + root.released(next) + } + } +} diff --git a/default/quickshell/bar/common/WidgetButton.qml b/default/quickshell/bar/common/WidgetButton.qml new file mode 100644 index 00000000..6920e6e6 --- /dev/null +++ b/default/quickshell/bar/common/WidgetButton.qml @@ -0,0 +1,69 @@ +import QtQuick + +Item { + id: root + + property var bar: null + property string text: "" + property string fontFamily: bar ? bar.fontFamily : "JetBrainsMono Nerd Font" + property real fontSize: 12 + property color foreground: bar ? bar.foreground : "#cacccc" + property color activeColor: bar ? bar.urgent : "#a55555" + property bool active: false + property real horizontalMargin: 7.5 + property real verticalPadding: 6 + property real fixedWidth: -1 + property real fixedHeight: -1 + property real textRotation: 0 + property bool keepSpace: false + property string tooltipText: "" + property real hoverScale: 1.0 + property real pressScale: 0.92 + + signal pressed(int button) + signal wheelMoved(int delta) + + readonly property bool vertical: bar ? bar.vertical : false + readonly property int barSize: bar ? bar.barSize : 26 + + visible: text !== "" || keepSpace + opacity: text === "" ? 0 : 1 + implicitWidth: fixedWidth > 0 ? fixedWidth : (vertical ? barSize : Math.max(12, label.implicitWidth + horizontalMargin * 2)) + implicitHeight: fixedHeight > 0 ? fixedHeight : (vertical ? Math.max(12, label.implicitHeight + verticalPadding * 2) : barSize) + + Behavior on opacity { + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } + + Text { + id: label + anchors.centerIn: parent + text: root.text + color: root.active ? root.activeColor : root.foreground + font.family: root.fontFamily + font.pixelSize: root.fontSize + rotation: root.textRotation + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + scale: mouseArea.pressed ? root.pressScale : (mouseArea.containsMouse ? root.hoverScale : 1.0) + + Behavior on color { + ColorAnimation { duration: 160 } + } + + Behavior on scale { + NumberAnimation { duration: 110; easing.type: Easing.OutCubic } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + 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) } + onWheel: function(wheel) { root.wheelMoved(wheel.angleDelta.y) } + } +} diff --git a/default/quickshell/bar/shell.qml b/default/quickshell/bar/shell.qml index 1c1c3a14..3369ae9f 100644 --- a/default/quickshell/bar/shell.qml +++ b/default/quickshell/bar/shell.qml @@ -64,6 +64,17 @@ ShellRoot { property var tooltipTarget: null property string tooltipText: "" property bool tooltipShown: false + property var activePopout: null + + function requestPopout(owner) { + if (activePopout === owner) return + if (activePopout && "closePopout" in activePopout) activePopout.closePopout() + activePopout = owner + } + + function releasePopout(owner) { + if (activePopout === owner) activePopout = null + } readonly property bool vertical: position === "left" || position === "right" readonly property int barSize: vertical ? 28 : 26 @@ -248,6 +259,28 @@ ShellRoot { return source ? fileUrl(source) : "" } + readonly property var firstPartyWidgets: ({ + "media": true, + "audioPanel": true, + "networkPanel": true, + "bluetoothPanel": true, + "calendar": true, + "notificationCenter": true, + "brightness": true, + "powerProfile": true, + "systemStats": true, + "weatherFlyout": true, + "workspacesPro": true, + "powerMenu": true, + "idleInhibitor": true, + "microphone": true + }) + + function firstPartyWidgetSource(name) { + if (!firstPartyWidgets[String(name)]) return "" + return fileUrl(omarchyPath + "/default/quickshell/bar/widgets/" + String(name) + ".qml") + } + function networkCommand() { return [ "route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == \"dev\") { print $(i + 1); exit } }')", @@ -1048,9 +1081,11 @@ ShellRoot { required property string moduleName readonly property string customType: root.customModuleType(moduleName) readonly property var builtinComponent: customType ? null : root.builtinModuleComponent(moduleName) + readonly property string firstPartySource: customType || builtinComponent ? "" : root.firstPartyWidgetSource(moduleName) readonly property bool qmlCustom: customType === "qml" readonly property bool commandCustom: customType === "command" - readonly property var activeItem: qmlCustom ? qmlLoader.item : componentLoader.item + readonly property bool firstParty: firstPartySource !== "" + readonly property var activeItem: qmlCustom || firstParty ? qmlLoader.item : componentLoader.item implicitWidth: activeItem && activeItem.visible ? activeItem.implicitWidth : 0 implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0 @@ -1059,15 +1094,15 @@ ShellRoot { Loader { id: componentLoader - active: !slot.qmlCustom + active: !slot.qmlCustom && !slot.firstParty sourceComponent: slot.builtinComponent || (slot.commandCustom ? customCommandModuleComponent : emptyModuleComponent) anchors.fill: parent } Loader { id: qmlLoader - active: slot.qmlCustom - source: slot.qmlCustom ? root.customModuleSource(slot.moduleName) : "" + active: slot.qmlCustom || slot.firstParty + source: slot.qmlCustom ? root.customModuleSource(slot.moduleName) : (slot.firstParty ? slot.firstPartySource : "") anchors.fill: parent onLoaded: { if (item && "bar" in item) item.bar = root diff --git a/default/quickshell/bar/widgets/audioPanel.qml b/default/quickshell/bar/widgets/audioPanel.qml new file mode 100644 index 00000000..927caa96 --- /dev/null +++ b/default/quickshell/bar/widgets/audioPanel.qml @@ -0,0 +1,232 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "audioPanel" + property var settings: ({}) + + property bool popupOpen: false + + function closePopout() { popupOpen = false } + + readonly property var sink: Pipewire.defaultAudioSink + readonly property var source: Pipewire.defaultAudioSource + readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : [] + + readonly property var audioSinks: { + var list = [] + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i] + if (node && node.isSink && !node.isStream && node.audio) list.push(node) + } + return list + } + + readonly property var audioStreams: { + var list = [] + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i] + if (node && node.isStream && !node.isSink && node.audio) list.push(node) + } + 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 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 setVolume(v) { + if (!sink || !sink.audio) return + sink.audio.volume = Math.max(0, Math.min(1, v)) + } + + function toggleMute() { + if (sink && sink.audio) sink.audio.muted = !sink.audio.muted + } + + function setDefaultSink(node) { + Pipewire.preferredDefaultAudioSink = node + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + PwObjectTracker { objects: root.audioSinks } + 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" + + onPressed: function(b) { + if (b === Qt.RightButton) root.toggleMute() + 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)) + } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 340 + contentHeight: panelColumn.implicitHeight + 28 + + Column { + id: panelColumn + anchors.fill: parent + spacing: 12 + + // 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 + + 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 + + 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 + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: 110 + anchors.verticalCenter: parent.verticalCenter + } + + Common.Slider { + bar: root.bar + width: parent.width - 124 + anchors.verticalCenter: parent.verticalCenter + minimum: 0 + maximum: 1.5 + step: 0.05 + value: modelData && modelData.audio ? modelData.audio.volume : 0 + + onMoved: function(v) { + if (modelData && modelData.audio) modelData.audio.volume = v + } + } + } + } + } + } + } + + function sinkGlyph(node) { + if (!node) return "" + var blob = String([ + node.name, node.description, node.nickname, + node.properties ? node.properties["device.icon-name"] : "", + node.properties ? node.properties["device.product.name"] : "" + ].join(" ")).toLowerCase() + if (blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1) return "" + if (blob.indexOf("bluetooth") !== -1) return "󰂯" + if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹" + return "󰓃" + } +} diff --git a/default/quickshell/bar/widgets/bluetoothPanel.qml b/default/quickshell/bar/widgets/bluetoothPanel.qml new file mode 100644 index 00000000..28836320 --- /dev/null +++ b/default/quickshell/bar/widgets/bluetoothPanel.qml @@ -0,0 +1,152 @@ +import QtQuick +import Quickshell +import Quickshell.Bluetooth +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "bluetoothPanel" + property var settings: ({}) + + property bool popupOpen: false + + function closePopout() { popupOpen = false } + + readonly property var adapter: Bluetooth.defaultAdapter + readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] + + readonly property var connectedDevices: { + var list = [] + for (var i = 0; i < devices.length; i++) + if (devices[i] && devices[i].connected) list.push(devices[i]) + return list + } + + readonly property var knownDevices: { + var list = [] + for (var i = 0; i < devices.length; i++) { + var d = devices[i] + if (d && (d.paired || d.connected || d.bonded || d.trusted)) list.push(d) + } + list.sort(function(a, b) { + if (a.connected !== b.connected) return a.connected ? -1 : 1 + return (a.name || a.deviceName || "").localeCompare(b.name || b.deviceName || "") + }) + return list + } + + readonly property string icon: { + if (!adapter) return "" + if (!adapter.enabled) return "󰂲" + if (connectedDevices.length > 0) return "󰂱" + return "" + } + + visible: adapter !== null + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.icon + horizontalMargin: 8.5 + tooltipText: root.adapter ? (root.adapter.enabled ? "Bluetooth: " + root.connectedDevices.length + " connected" : "Bluetooth off") : "" + + onPressed: function(b) { + if (b === Qt.RightButton && root.adapter) root.adapter.enabled = !root.adapter.enabled + else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-bluetooth") + else root.popupOpen = !root.popupOpen + } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 320 + contentHeight: column.implicitHeight + 28 + + Column { + id: column + anchors.fill: parent + spacing: 8 + + Row { + width: parent.width + spacing: 8 + + Text { + text: "Bluetooth" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + + Item { width: parent.width - 200; height: 1 } + + Common.PillButton { + iconText: root.adapter && root.adapter.enabled ? "" : "" + text: root.adapter && root.adapter.enabled ? "On" : "Off" + foreground: root.bar.foreground + horizontalPadding: 8 + verticalPadding: 4 + active: root.adapter && root.adapter.enabled + onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled + } + + Common.PillButton { + iconText: "" + foreground: root.bar.foreground + horizontalPadding: 8 + verticalPadding: 4 + enabled: root.adapter !== null && root.adapter.enabled + opacity: enabled ? 1 : 0.4 + active: root.adapter && root.adapter.discovering + onClicked: if (root.adapter) root.adapter.discovering = !root.adapter.discovering + } + } + + Repeater { + model: root.knownDevices + + Common.PillButton { + required property var modelData + + width: parent.width + iconText: modelData && modelData.connected ? "󰂱" : "" + text: { + var label = modelData ? (modelData.deviceName || modelData.name || modelData.address || "Device") : "" + if (modelData && modelData.batteryAvailable) label += " " + Math.round(modelData.battery * 100) + "%" + return label + } + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 6 + active: modelData && modelData.connected + + onClicked: { + if (!modelData) return + if (modelData.connected) modelData.disconnect() + else modelData.connect() + } + onRightClicked: if (modelData) modelData.forget() + } + } + + Text { + visible: root.knownDevices.length === 0 + text: root.adapter && root.adapter.enabled ? "Scanning for devices…" : "Turn Bluetooth on to scan" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + } + } + } +} diff --git a/default/quickshell/bar/widgets/brightness.qml b/default/quickshell/bar/widgets/brightness.qml new file mode 100644 index 00000000..588ac03b --- /dev/null +++ b/default/quickshell/bar/widgets/brightness.qml @@ -0,0 +1,147 @@ +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 + } + + function setBrightness(percent) { + var clamped = Math.max(1, Math.min(100, Math.round(percent))) + currentPercent = clamped + writeProc.command = ["bash", "-lc", "brightnessctl set " + clamped + "% >/dev/null"] + writeProc.running = true + } + + Component.onCompleted: refresh() + + Process { + id: readProc + command: ["bash", "-lc", "if command -v brightnessctl >/dev/null; then echo $(( 100 * $(brightnessctl get) / $(brightnessctl max) )); fi"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var n = parseInt(String(text || "").trim(), 10) + if (!isNaN(n)) root.currentPercent = n + } + } + } + + Process { id: writeProc } + + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: root.refresh() + } + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.iconGlyph + horizontalMargin: 6.5 + tooltipText: root.currentPercent >= 0 ? "Brightness " + root.currentPercent + "%" : "" + + onPressed: function(b) { + if (b === Qt.MiddleButton) { + root.popupOpen = false + root.setBrightness(80) + } else { + root.popupOpen = !root.popupOpen + } + } + + onWheelMoved: function(delta) { + var step = Number(root.setting("step", 5)) + root.setBrightness(root.currentPercent + (delta > 0 ? step : -step)) + } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 280 + contentHeight: 80 + + Column { + anchors.fill: parent + spacing: 10 + + Row { + spacing: 10 + width: parent.width + + Text { + text: root.iconGlyph + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 18 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "Brightness" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + + Item { width: 10; height: 1 } + + Text { + text: root.currentPercent + "%" + color: Qt.darker(root.bar.foreground, 1.3) + font.family: root.bar.fontFamily + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + } + + Common.Slider { + bar: root.bar + width: parent.width + minimum: 1 + maximum: 100 + step: 5 + integer: true + value: root.currentPercent + + onMoved: function(v) { root.setBrightness(v) } + } + } + } +} diff --git a/default/quickshell/bar/widgets/calendar.qml b/default/quickshell/bar/widgets/calendar.qml new file mode 100644 index 00000000..2fe16abd --- /dev/null +++ b/default/quickshell/bar/widgets/calendar.qml @@ -0,0 +1,191 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "calendar" + property var settings: ({}) + + property date now: new Date() + property date viewMonth: new Date() + property bool popupOpen: false + + function closePopout() { popupOpen = false } + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function formatLabel() { + if (!bar) return "" + var fmt = bar.vertical + ? String(setting("verticalFormat", "HH\n—\nmm")) + : String(setting("format", "dddd HH:mm")) + return Qt.formatDateTime(now, fmt) + } + + function shiftMonth(delta) { + var date = new Date(viewMonth) + date.setDate(1) + date.setMonth(date.getMonth() + delta) + viewMonth = date + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + SystemClock { + id: clockTimer + precision: SystemClock.Minutes + onDateChanged: root.now = clockTimer.date + } + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.formatLabel() + horizontalMargin: 8.75 + verticalPadding: 8.75 + tooltipText: Qt.formatDateTime(root.now, "dddd, MMMM d, yyyy") + + onPressed: function(b) { + if (b === Qt.RightButton) { + root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-tz-select") + } else { + root.viewMonth = new Date(root.now) + root.popupOpen = !root.popupOpen + } + } + } + + Common.PopupCard { + id: popup + anchorItem: button + bar: root.bar + open: root.popupOpen + contentWidth: 300 + contentHeight: header.implicitHeight + grid.implicitHeight + 36 + + Column { + anchors.fill: parent + spacing: 8 + + Item { + id: header + width: parent.width + implicitHeight: 28 + + Common.PillButton { + id: prevButton + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + iconText: "" + foreground: root.bar.foreground + horizontalPadding: 8 + verticalPadding: 4 + onClicked: root.shiftMonth(-1) + } + + Text { + anchors.centerIn: parent + text: Qt.formatDate(root.viewMonth, "MMMM yyyy") + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 14 + font.bold: true + } + + Common.PillButton { + id: nextButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + iconText: "" + foreground: root.bar.foreground + horizontalPadding: 8 + verticalPadding: 4 + onClicked: root.shiftMonth(1) + } + } + + Grid { + id: grid + columns: 7 + rowSpacing: 4 + columnSpacing: 4 + width: parent.width + + Repeater { + model: ["S", "M", "T", "W", "T", "F", "S"] + + Item { + required property string modelData + width: (grid.width - grid.columnSpacing * 6) / 7 + height: 18 + + Text { + anchors.centerIn: parent + text: modelData + color: Qt.darker(root.bar.foreground, 1.6) + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: true + } + } + } + + Repeater { + model: 42 + + Rectangle { + required property int index + + readonly property var startOfMonth: { + var d = new Date(root.viewMonth) + d.setDate(1) + return d + } + readonly property int firstDayOffset: startOfMonth.getDay() + readonly property var dayDate: { + var d = new Date(startOfMonth) + d.setDate(d.getDate() + index - firstDayOffset) + return d + } + readonly property bool inMonth: dayDate.getMonth() === root.viewMonth.getMonth() + readonly property bool isToday: { + var n = root.now + return dayDate.getDate() === n.getDate() && dayDate.getMonth() === n.getMonth() && dayDate.getFullYear() === n.getFullYear() + } + + width: (grid.width - grid.columnSpacing * 6) / 7 + height: 28 + radius: 4 + color: isToday ? root.bar.foreground : "transparent" + border.color: isToday ? root.bar.foreground : "transparent" + border.width: isToday ? 1 : 0 + + Text { + anchors.centerIn: parent + text: dayDate.getDate() + color: isToday ? root.bar.background : (inMonth ? root.bar.foreground : Qt.darker(root.bar.foreground, 2.2)) + font.family: root.bar.fontFamily + font.pixelSize: 12 + font.bold: isToday + } + } + } + } + } + } + + MouseArea { + anchors.fill: parent + visible: false + enabled: false + } +} diff --git a/default/quickshell/bar/widgets/idleInhibitor.qml b/default/quickshell/bar/widgets/idleInhibitor.qml new file mode 100644 index 00000000..fc905c0d --- /dev/null +++ b/default/quickshell/bar/widgets/idleInhibitor.qml @@ -0,0 +1,66 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "idleInhibitor" + property var settings: ({}) + + property bool active: false + property int holdSeconds: 0 + + readonly property string icon: active ? "󰛊" : "󰾪" + + function refresh() { + if (!statusProc.running) statusProc.running = true + } + + function toggle() { + if (root.bar) root.bar.run("omarchy-toggle-idle") + refreshTimer.restart() + } + + Component.onCompleted: refresh() + + Process { + id: statusProc + command: ["bash", "-lc", "if pgrep -x hyprlock >/dev/null 2>&1; then echo locked; elif pgrep -f 'systemd-inhibit' >/dev/null 2>&1; then echo inhibited; elif [[ -f /tmp/omarchy-idle-off ]]; then echo off; else echo idle; fi"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var state = String(text || "").trim() + root.active = state === "inhibited" || state === "off" + } + } + } + + Timer { + id: refreshTimer + interval: 1500 + onTriggered: root.refresh() + } + + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: root.refresh() + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.icon + active: root.active + tooltipText: root.active ? "Idle inhibited — click to allow sleep" : "System can idle — click to keep awake" + onPressed: function() { root.toggle() } + } +} diff --git a/default/quickshell/bar/widgets/media.qml b/default/quickshell/bar/widgets/media.qml new file mode 100644 index 00000000..726c85ca --- /dev/null +++ b/default/quickshell/bar/widgets/media.qml @@ -0,0 +1,234 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Mpris +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "media" + property var settings: ({}) + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + readonly property var players: Mpris.players ? Mpris.players.values : [] + readonly property var activePlayer: { + var playing = null + for (var i = 0; i < players.length; i++) { + var p = players[i] + if (!p) continue + if (p.isPlaying) return p + if (!playing && p.trackTitle) playing = p + } + return playing + } + + readonly property bool hasMedia: activePlayer !== null && (activePlayer.trackTitle || activePlayer.trackArtist) + readonly property string playIcon: activePlayer && activePlayer.isPlaying ? "" : "" + readonly property string title: activePlayer ? (activePlayer.trackTitle || "") : "" + readonly property string artist: activePlayer ? (activePlayer.trackArtist || "") : "" + + property bool popupOpen: false + + function closePopout() { popupOpen = false } + property real maxLabelWidth: 180 + + visible: hasMedia + implicitWidth: hasMedia ? row.implicitWidth + 14 : 0 + implicitHeight: bar ? bar.barSize : 26 + + Row { + id: row + anchors.centerIn: parent + spacing: 6 + + Text { + id: glyph + anchors.verticalCenter: parent.verticalCenter + text: root.playIcon + color: activePlayer && activePlayer.isPlaying ? root.bar.foreground : Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 12 + + Behavior on color { ColorAnimation { duration: 160 } } + } + + Item { + id: scrollClip + width: Math.min(root.maxLabelWidth, labelText.implicitWidth) + height: glyph.height + clip: true + anchors.verticalCenter: parent.verticalCenter + visible: !root.bar.vertical && root.title !== "" + + Text { + id: labelText + text: root.title + (root.artist ? " · " + root.artist : "") + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + + property bool needsScroll: implicitWidth > scrollClip.width + x: needsScroll ? scrollAnim.position : 0 + + NumberAnimation on x { + id: scrollAnim + property real position: 0 + running: labelText.needsScroll && !root.popupOpen + loops: Animation.Infinite + duration: Math.max(6000, labelText.implicitWidth * 25) + from: scrollClip.width + to: -labelText.implicitWidth + easing.type: Easing.Linear + } + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onClicked: function(mouse) { + if (!root.activePlayer) return + if (mouse.button === Qt.MiddleButton) { + if (root.activePlayer.canGoNext) root.activePlayer.next() + } else if (mouse.button === Qt.RightButton) { + root.popupOpen = !root.popupOpen + } else { + if (root.activePlayer.canTogglePlaying) root.activePlayer.togglePlaying() + } + } + onWheel: function(wheel) { + if (!root.activePlayer) return + if (wheel.angleDelta.y > 0 && root.activePlayer.canGoPrevious) root.activePlayer.previous() + else if (wheel.angleDelta.y < 0 && root.activePlayer.canGoNext) root.activePlayer.next() + } + onEntered: if (root.bar) root.bar.showTooltip(root, root.hasMedia ? (root.title + (root.artist ? " — " + root.artist : "")) : "") + onExited: if (root.bar) root.bar.hideTooltip(root) + } + + Common.PopupCard { + id: popup + anchorItem: root + bar: root.bar + open: root.popupOpen + contentWidth: 320 + contentHeight: column.implicitHeight + 28 + + Column { + id: column + anchors.fill: parent + spacing: 10 + + Row { + spacing: 10 + width: parent.width + + Rectangle { + width: 64 + height: 64 + radius: 4 + color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.08) + border.color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.2) + border.width: 1 + + Image { + anchors.fill: parent + anchors.margins: 2 + fillMode: Image.PreserveAspectCrop + asynchronous: true + source: root.activePlayer && root.activePlayer.trackArtUrl ? root.activePlayer.trackArtUrl : "" + visible: source !== "" + } + + Text { + anchors.centerIn: parent + visible: !root.activePlayer || !root.activePlayer.trackArtUrl + text: "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 28 + } + } + + Column { + spacing: 4 + width: parent.width - 74 + + Text { + text: root.title || "Nothing playing" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 13 + font.bold: true + elide: Text.ElideRight + width: parent.width + } + + Text { + text: root.artist + color: Qt.darker(root.bar.foreground, 1.3) + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width + visible: text !== "" + } + + Text { + text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : "" + color: Qt.darker(root.bar.foreground, 1.6) + font.family: root.bar.fontFamily + font.pixelSize: 10 + elide: Text.ElideRight + width: parent.width + visible: text !== "" + } + } + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 6 + + Common.PillButton { + iconText: "" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 6 + enabled: root.activePlayer && root.activePlayer.canGoPrevious + opacity: enabled ? 1.0 : 0.4 + onClicked: if (root.activePlayer) root.activePlayer.previous() + } + + Common.PillButton { + iconText: root.activePlayer && root.activePlayer.isPlaying ? "" : "" + foreground: root.bar.foreground + horizontalPadding: 14 + verticalPadding: 6 + iconSize: 18 + enabled: root.activePlayer && root.activePlayer.canTogglePlaying + opacity: enabled ? 1.0 : 0.4 + onClicked: if (root.activePlayer) root.activePlayer.togglePlaying() + } + + Common.PillButton { + iconText: "" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 6 + enabled: root.activePlayer && root.activePlayer.canGoNext + opacity: enabled ? 1.0 : 0.4 + onClicked: if (root.activePlayer) root.activePlayer.next() + } + } + } + } +} diff --git a/default/quickshell/bar/widgets/microphone.qml b/default/quickshell/bar/widgets/microphone.qml new file mode 100644 index 00000000..84281973 --- /dev/null +++ b/default/quickshell/bar/widgets/microphone.qml @@ -0,0 +1,56 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "microphone" + property var settings: ({}) + + readonly property var source: Pipewire.defaultAudioSource + readonly property bool muted: source && source.audio ? source.audio.muted : true + readonly property real volume: source && source.audio ? source.audio.volume : 0 + readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : [] + + readonly property var activeStreams: { + var list = [] + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i] + if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node) + } + return list + } + + readonly property bool inUse: activeStreams.length > 0 && !muted + + visible: source !== null + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + function toggleMute() { + if (source && source.audio) source.audio.muted = !source.audio.muted + } + + PwObjectTracker { objects: root.source ? [root.source] : [] } + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.muted ? "󰍭" : "󰍬" + active: root.inUse + tooltipText: root.muted ? "Microphone muted" : (root.inUse ? "Microphone in use" : "Microphone live") + onPressed: function(b) { + if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-audio") + else root.toggleMute() + } + onWheelMoved: function(delta) { + if (!root.source || !root.source.audio) return + var step = 0.05 + root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step))) + } + } +} diff --git a/default/quickshell/bar/widgets/networkPanel.qml b/default/quickshell/bar/widgets/networkPanel.qml new file mode 100644 index 00000000..394878f8 --- /dev/null +++ b/default/quickshell/bar/widgets/networkPanel.qml @@ -0,0 +1,217 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "networkPanel" + property var settings: ({}) + + property bool popupOpen: false + + function closePopout() { popupOpen = false } + property var networks: [] + property bool scanning: false + + readonly property string kind: bar ? bar.networkKind : "disconnected" + readonly property string label: bar ? bar.networkLabel : "" + readonly property int signal: bar ? bar.networkSignal : -1 + + readonly property string icon: { + if (kind === "wifi") { + var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"] + var index = Math.max(0, Math.min(4, Math.ceil(signal / 20) - 1)) + return icons[index] + } + if (kind === "ethernet") return "󰀂" + return "󰤮" + } + + function refresh() { + if (!scanProc.running) { + scanning = true + scanProc.running = true + } + } + + function updateScan(raw) { + var list = [] + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim() + if (!line) continue + var parts = line.split("\t") + if (parts.length < 3) continue + list.push({ + inUse: parts[0] === "*", + ssid: parts[1], + signal: parseInt(parts[2], 10) || 0, + security: parts[3] || "" + }) + } + list.sort(function(a, b) { + if (a.inUse !== b.inUse) return a.inUse ? -1 : 1 + return b.signal - a.signal + }) + networks = list + scanning = false + } + + function wifiIconFor(signal) { + var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"] + var index = Math.max(0, Math.min(4, Math.ceil(signal / 20) - 1)) + return icons[index] + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + Component.onCompleted: refresh() + + Process { + id: scanProc + command: ["bash", "-lc", "command -v nmcli >/dev/null && nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY device wifi list --rescan no 2>/dev/null"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.updateScan(text) + } + } + + Process { id: actionProc } + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.icon + horizontalMargin: 6.5 + tooltipText: bar ? bar.networkTooltip() : "" + + onPressed: function(b) { + if (b === Qt.RightButton) root.bar.run("OMARCHY_PATH=" + root.bar.shellQuote(root.bar.omarchyPath) + " " + root.bar.omarchyPath + "/bin/omarchy-launch-wifi") + else { + root.popupOpen = !root.popupOpen + if (root.popupOpen) root.refresh() + } + } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 320 + contentHeight: column.implicitHeight + 28 + + Column { + id: column + anchors.fill: parent + spacing: 8 + + Row { + width: parent.width + spacing: 8 + + Text { + text: root.icon + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 16 + anchors.verticalCenter: parent.verticalCenter + } + + Column { + spacing: 2 + width: parent.width - 100 + anchors.verticalCenter: parent.verticalCenter + + Text { + text: root.kind === "disconnected" ? "Disconnected" : (root.label || root.kind) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + font.bold: true + elide: Text.ElideRight + width: parent.width + } + + Text { + visible: root.kind !== "disconnected" + text: root.kind === "wifi" ? "Wi-Fi · " + root.signal + "%" : "Ethernet" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: 10 + } + } + + Common.PillButton { + iconText: "" + foreground: root.bar.foreground + horizontalPadding: 8 + verticalPadding: 4 + active: root.scanning + onClicked: { + scanProc.command = ["bash", "-lc", "command -v nmcli >/dev/null && nmcli device wifi rescan 2>/dev/null; nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY device wifi list --rescan no 2>/dev/null"] + root.refresh() + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.15) + } + + Column { + spacing: 2 + width: parent.width + + Repeater { + model: root.networks.slice(0, 6) + + Common.PillButton { + required property var modelData + + width: parent.width + iconText: root.wifiIconFor(modelData.signal) + text: (modelData.ssid || "Hidden") + (modelData.security ? " · " : "") + (modelData.security || "") + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 6 + active: modelData.inUse + + onClicked: { + if (modelData.inUse) return + actionProc.command = ["bash", "-lc", "command -v nmcli >/dev/null && nmcli device wifi connect " + root.bar.shellQuote(modelData.ssid)] + actionProc.running = true + root.popupOpen = false + } + } + } + + Text { + visible: root.networks.length === 0 + text: root.scanning ? "Scanning…" : "No networks found" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + } + } + + Common.PillButton { + width: parent.width + iconText: "" + text: "Open Wi-Fi manager" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 6 + onClicked: { root.bar.run(root.bar.omarchyPath + "/bin/omarchy-launch-wifi"); root.popupOpen = false } + } + } + } +} diff --git a/default/quickshell/bar/widgets/notificationCenter.qml b/default/quickshell/bar/widgets/notificationCenter.qml new file mode 100644 index 00000000..c08391c9 --- /dev/null +++ b/default/quickshell/bar/widgets/notificationCenter.qml @@ -0,0 +1,244 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Notifications +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "notificationCenter" + property var settings: ({}) + + property bool popupOpen: false + + function closePopout() { popupOpen = false } + property var stored: [] + property bool dnd: false + + readonly property int count: stored.length + readonly property string icon: { + if (dnd) return "󰂛" + if (count > 0) return "󱅫" + return "" + } + + NotificationServer { + id: server + keepOnReload: false + bodySupported: true + actionsSupported: true + imageSupported: true + + onNotification: function(notification) { + if (root.dnd) { + notification.expire() + return + } + notification.tracked = true + var snapshot = { + id: notification.id, + app: notification.appName, + summary: notification.summary, + body: notification.body, + time: new Date(), + ref: notification + } + var next = root.stored.slice() + next.unshift(snapshot) + if (next.length > 30) next.pop() + root.stored = next + } + } + + function dismiss(index) { + var item = stored[index] + if (item && item.ref && !item.ref.closed) item.ref.dismiss() + var next = stored.slice() + next.splice(index, 1) + stored = next + } + + function clearAll() { + for (var i = 0; i < stored.length; i++) { + if (stored[i].ref && !stored[i].ref.closed) stored[i].ref.dismiss() + } + stored = [] + } + + function relativeTime(date) { + if (!date) return "" + var diff = (Date.now() - date.getTime()) / 1000 + if (diff < 60) return "just now" + if (diff < 3600) return Math.floor(diff / 60) + "m" + if (diff < 86400) return Math.floor(diff / 3600) + "h" + return Math.floor(diff / 86400) + "d" + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.icon + active: root.count > 0 && !root.dnd + tooltipText: root.dnd ? "Do Not Disturb" : (root.count > 0 ? root.count + " notifications" : "No notifications") + + onPressed: function(b) { + if (b === Qt.RightButton) root.dnd = !root.dnd + else root.popupOpen = !root.popupOpen + } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 340 + contentHeight: Math.min(420, listColumn.implicitHeight + 60) + + Column { + id: listColumn + anchors.fill: parent + spacing: 8 + + Row { + width: parent.width + spacing: 8 + + Text { + text: "Notifications" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + + Item { width: parent.width - 220; height: 1 } + + Common.PillButton { + iconText: root.dnd ? "󰂛" : "" + text: root.dnd ? "DND" : "" + foreground: root.bar.foreground + horizontalPadding: 8 + verticalPadding: 4 + active: root.dnd + onClicked: root.dnd = !root.dnd + } + + Common.PillButton { + iconText: "" + foreground: root.bar.foreground + horizontalPadding: 8 + verticalPadding: 4 + enabled: root.count > 0 + opacity: enabled ? 1 : 0.4 + onClicked: root.clearAll() + } + } + + Flickable { + width: parent.width + height: Math.min(320, contentHeight) + contentHeight: feedColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: feedColumn + width: parent.width + spacing: 4 + + Repeater { + model: root.stored + + Rectangle { + required property var modelData + required property int index + + width: feedColumn.width + radius: 4 + color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.06) + implicitHeight: notifContent.implicitHeight + 16 + + Column { + id: notifContent + anchors.fill: parent + anchors.margins: 8 + spacing: 2 + + Row { + width: parent.width + + Text { + text: modelData ? (modelData.app || "App") : "" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: 10 + font.bold: true + elide: Text.ElideRight + width: parent.width - timeText.implicitWidth - dismissBtn.width - 12 + } + + Text { + id: timeText + text: modelData ? root.relativeTime(modelData.time) : "" + color: Qt.darker(root.bar.foreground, 1.6) + font.family: root.bar.fontFamily + font.pixelSize: 10 + } + + Item { width: 6; height: 1 } + + Common.PillButton { + id: dismissBtn + iconText: "" + foreground: root.bar.foreground + horizontalPadding: 4 + verticalPadding: 0 + iconSize: 10 + onClicked: root.dismiss(index) + } + } + + Text { + text: modelData ? (modelData.summary || "") : "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: true + wrapMode: Text.WordWrap + width: parent.width + } + + Text { + visible: modelData && modelData.body !== "" + text: modelData ? (modelData.body || "") : "" + color: Qt.darker(root.bar.foreground, 1.2) + font.family: root.bar.fontFamily + font.pixelSize: 10 + wrapMode: Text.WordWrap + width: parent.width + maximumLineCount: 3 + elide: Text.ElideRight + } + } + } + } + } + } + + Text { + visible: root.stored.length === 0 + text: root.dnd ? "Do Not Disturb is on" : "Nothing new" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + } + } + } +} diff --git a/default/quickshell/bar/widgets/powerMenu.qml b/default/quickshell/bar/widgets/powerMenu.qml new file mode 100644 index 00000000..c54e3af6 --- /dev/null +++ b/default/quickshell/bar/widgets/powerMenu.qml @@ -0,0 +1,106 @@ +import QtQuick +import Quickshell +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "powerMenu" + property var settings: ({}) + + property bool popupOpen: false + + function closePopout() { popupOpen = false } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + function run(command) { + if (root.bar) root.bar.run(command) + popupOpen = false + } + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: "⏻" + fontSize: 14 + tooltipText: "Power menu" + onPressed: function() { root.popupOpen = !root.popupOpen } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 220 + contentHeight: column.implicitHeight + 28 + + Column { + id: column + anchors.fill: parent + spacing: 6 + + Text { + text: "Power" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + font.bold: true + } + + Common.PillButton { + width: parent.width + iconText: "" + text: "Lock" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 8 + onClicked: root.run("loginctl lock-session") + } + + Common.PillButton { + width: parent.width + iconText: "󰒲" + text: "Suspend" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 8 + onClicked: root.run("systemctl suspend") + } + + Common.PillButton { + width: parent.width + iconText: "" + text: "Log out" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 8 + onClicked: root.run("hyprctl dispatch exit") + } + + Common.PillButton { + width: parent.width + iconText: "" + text: "Reboot" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 8 + onClicked: root.run("systemctl reboot") + } + + Common.PillButton { + width: parent.width + iconText: "" + text: "Shut down" + foreground: root.bar.urgent + horizontalPadding: 10 + verticalPadding: 8 + onClicked: root.run("systemctl poweroff") + } + } + } +} diff --git a/default/quickshell/bar/widgets/powerProfile.qml b/default/quickshell/bar/widgets/powerProfile.qml new file mode 100644 index 00000000..3686ce47 --- /dev/null +++ b/default/quickshell/bar/widgets/powerProfile.qml @@ -0,0 +1,94 @@ +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.profile !== undefined + readonly property int current: PowerProfiles.profile + + visible: available + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + function setProfile(profile) { + PowerProfiles.profile = profile + } + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.profileGlyphs[root.current] || "" + tooltipText: "Power profile: " + (root.profileLabels[root.current] || "Unknown") + onPressed: function() { root.popupOpen = !root.popupOpen } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 240 + contentHeight: column.implicitHeight + 28 + + Column { + id: column + anchors.fill: parent + spacing: 6 + + Text { + text: "Power Profile" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + font.bold: true + } + + Repeater { + model: [ + { profile: PowerProfile.PowerSaver, label: "Power Saver", glyph: "󰌪" }, + { profile: PowerProfile.Balanced, label: "Balanced", glyph: "󰂄" }, + { profile: PowerProfile.Performance, label: "Performance", glyph: "󰓅" } + ] + + Common.PillButton { + required property var modelData + + width: parent.width + iconText: modelData.glyph + text: modelData.label + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 8 + active: root.current === modelData.profile + enabled: modelData.profile !== PowerProfile.Performance || PowerProfiles.hasPerformanceProfile + opacity: enabled ? 1 : 0.4 + onClicked: { root.setProfile(modelData.profile); root.popupOpen = false } + } + } + } + } +} diff --git a/default/quickshell/bar/widgets/systemStats.qml b/default/quickshell/bar/widgets/systemStats.qml new file mode 100644 index 00000000..82620852 --- /dev/null +++ b/default/quickshell/bar/widgets/systemStats.qml @@ -0,0 +1,372 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "systemStats" + property var settings: ({}) + + property real cpuPercent: 0 + property real memPercent: 0 + property var cpuHistory: [] + property var memHistory: [] + property real loadAvg: 0 + + property var prevCpu: ({ idle: 0, total: 0 }) + + property bool popupOpen: false + + function closePopout() { popupOpen = false } + + readonly property int historyLimit: 30 + + function refresh() { + if (!cpuProc.running) cpuProc.running = true + if (!memProc.running) memProc.running = true + } + + function pushHistory(arr, value) { + var next = arr.slice() + next.push(value) + if (next.length > historyLimit) next.shift() + return next + } + + function updateCpu(raw) { + var fields = String(raw || "").trim().split(/\s+/) + if (fields.length < 8) return + var user = parseInt(fields[1], 10) || 0 + var nice = parseInt(fields[2], 10) || 0 + var sys = parseInt(fields[3], 10) || 0 + var idle = parseInt(fields[4], 10) || 0 + var iowait = parseInt(fields[5], 10) || 0 + var irq = parseInt(fields[6], 10) || 0 + var softirq = parseInt(fields[7], 10) || 0 + + var total = user + nice + sys + idle + iowait + irq + softirq + var totalDiff = total - prevCpu.total + var idleDiff = idle - prevCpu.idle + + if (prevCpu.total > 0 && totalDiff > 0) { + var usage = (1 - idleDiff / totalDiff) * 100 + cpuPercent = Math.max(0, Math.min(100, usage)) + cpuHistory = pushHistory(cpuHistory, cpuPercent) + } + + prevCpu = { idle: idle, total: total } + } + + function updateMem(raw) { + var lines = String(raw || "").split("\n") + var total = 0 + var available = 0 + for (var i = 0; i < lines.length; i++) { + var line = lines[i] + if (line.indexOf("MemTotal:") === 0) total = parseInt(line.replace(/[^0-9]/g, ""), 10) || 0 + else if (line.indexOf("MemAvailable:") === 0) available = parseInt(line.replace(/[^0-9]/g, ""), 10) || 0 + } + if (total > 0) { + memPercent = ((total - available) / total) * 100 + memHistory = pushHistory(memHistory, memPercent) + } + } + + function updateLoad(raw) { + var n = parseFloat(String(raw || "").trim().split(/\s+/)[0]) + if (!isNaN(n)) loadAvg = n + } + + Component.onCompleted: refresh() + + Process { + id: cpuProc + command: ["bash", "-lc", "head -n1 /proc/stat"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.updateCpu(text) + } + } + + Process { + id: memProc + command: ["bash", "-lc", "head -n3 /proc/meminfo"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.updateMem(text) + } + } + + Process { + id: loadProc + command: ["bash", "-lc", "cat /proc/loadavg"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.updateLoad(text) + } + } + + Timer { + interval: 2000 + running: true + repeat: true + onTriggered: { + root.refresh() + if (!loadProc.running) loadProc.running = true + } + } + + implicitWidth: row.implicitWidth + 6 + implicitHeight: bar ? bar.barSize : 26 + + Row { + id: row + anchors.centerIn: parent + spacing: 8 + + StatPill { + glyph: "󰍛" + percent: root.cpuPercent + history: root.cpuHistory + vertical: root.bar.vertical + barFg: root.bar.foreground + fontFamily: root.bar.fontFamily + } + + StatPill { + glyph: "" + percent: root.memPercent + history: root.memHistory + vertical: root.bar.vertical + barFg: root.bar.foreground + fontFamily: root.bar.fontFamily + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) root.bar.run("alacritty") + else root.popupOpen = !root.popupOpen + } + onEntered: if (root.bar) root.bar.showTooltip(root, "CPU " + Math.round(root.cpuPercent) + "% · Mem " + Math.round(root.memPercent) + "% · Load " + root.loadAvg.toFixed(2)) + onExited: if (root.bar) root.bar.hideTooltip(root) + } + + Common.PopupCard { + anchorItem: root + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 320 + contentHeight: detailColumn.implicitHeight + 28 + + Column { + id: detailColumn + anchors.fill: parent + spacing: 10 + + Text { + text: "System" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + font.bold: true + } + + DetailStat { + title: "CPU" + value: Math.round(root.cpuPercent) + "%" + history: root.cpuHistory + barFg: root.bar.foreground + fontFamily: root.bar.fontFamily + width: parent.width + } + + DetailStat { + title: "Memory" + value: Math.round(root.memPercent) + "%" + history: root.memHistory + barFg: root.bar.foreground + fontFamily: root.bar.fontFamily + width: parent.width + } + + Row { + width: parent.width + spacing: 6 + Text { + text: "Load" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + } + Text { + text: root.loadAvg.toFixed(2) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + } + } + + Common.PillButton { + width: parent.width + iconText: "󰍛" + text: "Open btop" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 8 + onClicked: { root.bar.run("omarchy-launch-or-focus-tui btop"); root.popupOpen = false } + } + } + } + + component StatPill: Item { + id: pill + property string glyph: "" + property real percent: 0 + property var history: [] + property bool vertical: false + property color barFg: "#cacccc" + property string fontFamily: "JetBrainsMono Nerd Font" + + implicitWidth: vertical ? 22 : 56 + implicitHeight: vertical ? 32 : 22 + + Row { + visible: !pill.vertical + anchors.fill: parent + spacing: 4 + + Text { + text: pill.glyph + color: pill.barFg + font.family: pill.fontFamily + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + + Canvas { + id: spark + width: 36 + height: 14 + anchors.verticalCenter: parent.verticalCenter + property var history: pill.history + onHistoryChanged: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + if (!pill.history || pill.history.length === 0) return + + ctx.strokeStyle = pill.barFg + ctx.fillStyle = Qt.rgba(pill.barFg.r, pill.barFg.g, pill.barFg.b, 0.2) + ctx.lineWidth = 1 + + ctx.beginPath() + var step = width / Math.max(1, pill.history.length - 1) + for (var i = 0; i < pill.history.length; i++) { + var x = i * step + var y = height - (pill.history[i] / 100) * height + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + ctx.stroke() + ctx.lineTo(width, height) + ctx.lineTo(0, height) + ctx.closePath() + ctx.fill() + } + } + } + + Column { + visible: pill.vertical + anchors.fill: parent + spacing: 2 + + Text { + text: pill.glyph + color: pill.barFg + font.family: pill.fontFamily + font.pixelSize: 10 + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + text: Math.round(pill.percent) + "" + color: pill.barFg + font.family: pill.fontFamily + font.pixelSize: 9 + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + + component DetailStat: Column { + id: detail + + property string title: "" + property string value: "" + property var history: [] + property color barFg: "#cacccc" + property string fontFamily: "JetBrainsMono Nerd Font" + + spacing: 4 + + Row { + width: parent.width + Text { + text: detail.title + color: Qt.darker(detail.barFg, 1.4) + font.family: detail.fontFamily + font.pixelSize: 11 + } + Item { width: detail.width - parent.children[0].implicitWidth - parent.children[2].implicitWidth; height: 1 } + Text { + text: detail.value + color: detail.barFg + font.family: detail.fontFamily + font.pixelSize: 11 + } + } + + Canvas { + id: detailCanvas + width: parent.width + height: 40 + property var history: detail.history + onHistoryChanged: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + if (!detail.history || detail.history.length === 0) return + + ctx.strokeStyle = detail.barFg + ctx.fillStyle = Qt.rgba(detail.barFg.r, detail.barFg.g, detail.barFg.b, 0.25) + ctx.lineWidth = 1.5 + + ctx.beginPath() + var step = width / Math.max(1, detail.history.length - 1) + for (var i = 0; i < detail.history.length; i++) { + var x = i * step + var y = height - (detail.history[i] / 100) * (height - 2) - 1 + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + ctx.stroke() + ctx.lineTo(width, height) + ctx.lineTo(0, height) + ctx.closePath() + ctx.fill() + } + } + } +} diff --git a/default/quickshell/bar/widgets/weatherFlyout.qml b/default/quickshell/bar/widgets/weatherFlyout.qml new file mode 100644 index 00000000..e26d8e53 --- /dev/null +++ b/default/quickshell/bar/widgets/weatherFlyout.qml @@ -0,0 +1,142 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "weatherFlyout" + property var settings: ({}) + + property bool popupOpen: false + + function closePopout() { popupOpen = false } + property var forecast: ({}) + property string fullReport: "" + + readonly property string label: bar ? bar.weatherText : "" + readonly property string klass: bar ? bar.weatherClass : "" + + visible: label !== "" + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + function refresh() { + if (!forecastProc.running) forecastProc.running = true + } + + function parseForecast(text) { + var lines = String(text || "").split("\n") + var data = { location: "", current: "", hourly: [], daily: [] } + var section = "" + + for (var i = 0; i < lines.length; i++) { + var line = lines[i] + if (i === 0 && line.indexOf("Weather report:") === 0) { + data.location = line.replace("Weather report:", "").trim() + } else if (line.match(/^[├└]\s/)) { + section = "daily" + } + } + return data + } + + Process { + id: forecastProc + command: ["bash", "-lc", "curl -fsS --max-time 5 'wttr.in/?T0&format=%l:+%C+%t+%f+wind+%w+%h+humidity' 2>/dev/null"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.fullReport = String(text || "").trim() + } + } + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.label + active: root.klass === "active" + horizontalMargin: 7.5 + tooltipText: root.fullReport || "Weather" + + onPressed: function(b) { + if (b === Qt.RightButton) root.bar.run("omarchy-notification-send \"$(omarchy-weather-status)\"") + else { + root.popupOpen = !root.popupOpen + if (root.popupOpen) root.refresh() + } + } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 320 + contentHeight: column.implicitHeight + 28 + + Column { + id: column + anchors.fill: parent + spacing: 10 + + Row { + spacing: 12 + width: parent.width + + Text { + text: root.label || "—" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 28 + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + text: "Weather" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + font.bold: true + } + + Text { + text: root.fullReport || "Fetching forecast…" + color: Qt.darker(root.bar.foreground, 1.2) + font.family: root.bar.fontFamily + font.pixelSize: 10 + wrapMode: Text.WordWrap + width: 220 + } + } + } + + Common.PillButton { + width: parent.width + iconText: "" + text: "Refresh" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 6 + onClicked: root.refresh() + } + + Common.PillButton { + width: parent.width + iconText: "" + text: "Open wttr.in" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 6 + onClicked: { root.bar.run("xdg-open https://wttr.in"); root.popupOpen = false } + } + } + } +} diff --git a/default/quickshell/bar/widgets/workspacesPro.qml b/default/quickshell/bar/widgets/workspacesPro.qml new file mode 100644 index 00000000..521032b8 --- /dev/null +++ b/default/quickshell/bar/widgets/workspacesPro.qml @@ -0,0 +1,131 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland + +Item { + id: root + + property QtObject bar: null + property string moduleName: "workspacesPro" + property var settings: ({}) + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + readonly property var ids: bar ? bar.workspaceIds() : [1, 2, 3, 4, 5] + readonly property bool vertical: bar ? bar.vertical : false + readonly property int barSize: bar ? bar.barSize : 26 + readonly property int slotSize: vertical ? barSize : 26 + readonly property int spacing: vertical ? 4 : 4 + + readonly property int focusedIndex: { + if (!Hyprland.focusedWorkspace) return -1 + var focused = Hyprland.focusedWorkspace.id + for (var i = 0; i < ids.length; i++) if (ids[i] === focused) return i + return -1 + } + + implicitWidth: vertical ? barSize : ids.length * slotSize + (ids.length - 1) * spacing + 6 + implicitHeight: vertical ? ids.length * slotSize + (ids.length - 1) * spacing + 6 : barSize + + Rectangle { + id: indicator + width: vertical ? root.slotSize - 8 : root.slotSize - 6 + height: vertical ? root.slotSize - 6 : root.slotSize - 8 + radius: 4 + color: root.bar.foreground + opacity: root.focusedIndex >= 0 ? 0.25 : 0 + x: vertical ? (root.width - width) / 2 : 3 + root.focusedIndex * (root.slotSize + root.spacing) + (root.slotSize - width) / 2 + y: vertical ? 3 + root.focusedIndex * (root.slotSize + root.spacing) + (root.slotSize - height) / 2 : (root.height - height) / 2 + + Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } } + Behavior on y { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 160 } } + } + + Loader { + anchors.fill: parent + sourceComponent: root.vertical ? verticalLayout : horizontalLayout + } + + Component { + id: horizontalLayout + + Row { + anchors.centerIn: parent + spacing: root.spacing + + Repeater { + model: root.ids + WorkspaceButton { + required property int modelData + workspaceId: modelData + } + } + } + } + + Component { + id: verticalLayout + + Column { + anchors.centerIn: parent + spacing: root.spacing + + Repeater { + model: root.ids + WorkspaceButton { + required property int modelData + workspaceId: modelData + } + } + } + } + + component WorkspaceButton: Item { + id: ws + + property int workspaceId: 0 + readonly property var workspace: root.bar ? root.bar.workspaceById(workspaceId) : null + readonly property bool occupied: workspace !== null && workspace.toplevels.values.length > 0 + readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === workspaceId + + implicitWidth: root.slotSize + implicitHeight: root.slotSize + + Text { + anchors.centerIn: parent + text: ws.focused ? "󱓻" : (ws.workspaceId === 10 ? "0" : String(ws.workspaceId)) + color: root.bar ? root.bar.foreground : "#cacccc" + font.family: root.bar ? root.bar.fontFamily : "JetBrainsMono Nerd Font" + font.pixelSize: ws.focused ? 13 : 11 + opacity: ws.focused ? 1 : (ws.occupied || ws.workspaceId <= 5 ? 0.8 : 0.4) + + Behavior on opacity { NumberAnimation { duration: 160 } } + Behavior on font.pixelSize { NumberAnimation { duration: 160 } } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) { + Hyprland.dispatch("movetoworkspace " + ws.workspaceId) + } else { + root.bar.focusWorkspace(ws.workspaceId) + } + } + + onWheel: function(wheel) { + if (wheel.angleDelta.y > 0) Hyprland.dispatch("workspace e-1") + else Hyprland.dispatch("workspace e+1") + } + } + } +}