From c5f7162ebbc40df2c941fa690b88be056d5081c5 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Sun, 5 Apr 2026 20:00:54 +0200 Subject: [PATCH] quickshell: add initial bar config with per-monitor workspaces - Vertical bar on DP-2 with rounded-square pills throughout - Per-monitor workspace groups sorted by screen x position, with Nerd Font icons for named workspaces and apex-neon red active indicator - Bar layout: datetime+weather top, workspaces centered, gamemode+media+notif+system bottom - Popouts anchor to triggering icon (top-right for datetime/weather, bottom-right for media/notif/system) - Lock command switched from hyprlock to swaylock - Hyprland blur/ignore_alpha layerrules for quickshell namespace Co-Authored-By: Claude Sonnet 4.6 --- dot_config/hypr/hyprland.d/60-rules.conf.tmpl | 4 + dot_config/quickshell/Bar.qml | 273 ++++++ dot_config/quickshell/CLAUDE.md | 85 ++ dot_config/quickshell/README.md | 70 ++ dot_config/quickshell/bar/ActiveWindow.qml | 43 + dot_config/quickshell/bar/BarPill.qml | 52 ++ dot_config/quickshell/bar/DateTimePill.qml | 37 + dot_config/quickshell/bar/GamemodePill.qml | 48 + dot_config/quickshell/bar/MediaPill.qml | 35 + dot_config/quickshell/bar/SystemPill.qml | 19 + dot_config/quickshell/bar/WeatherPill.qml | 33 + dot_config/quickshell/bar/Workspaces.qml | 164 ++++ .../quickshell/bar/popouts/CalendarPopout.qml | 189 ++++ .../quickshell/bar/popouts/MediaPopout.qml | 240 +++++ .../bar/popouts/NotificationCenter.qml | 352 ++++++++ .../bar/popouts/PopoutBackground.qml | 19 + .../quickshell/bar/popouts/SystemPopout.qml | 840 ++++++++++++++++++ .../quickshell/bar/popouts/WeatherPopout.qml | 153 ++++ dot_config/quickshell/launcher/.keep | 0 dot_config/quickshell/lock/.keep | 0 dot_config/quickshell/lock/IdleScreen.qml | 104 +++ .../notifications/NotificationDaemon.qml | 159 ++++ .../notifications/NotificationPopup.qml | 163 ++++ dot_config/quickshell/osd/Osd.qml | 178 ++++ .../quickshell/scripts/executable_gpu.sh | 110 +++ dot_config/quickshell/shared/Config.qml.tmpl | 62 ++ dot_config/quickshell/shared/PopoutState.qml | 22 + dot_config/quickshell/shared/Theme.qml | 124 +++ dot_config/quickshell/shared/Time.qml | 15 + dot_config/quickshell/shared/Weather.qml | 85 ++ dot_config/quickshell/shell.qml | 13 + 31 files changed, 3691 insertions(+) create mode 100644 dot_config/quickshell/Bar.qml create mode 100644 dot_config/quickshell/CLAUDE.md create mode 100644 dot_config/quickshell/README.md create mode 100644 dot_config/quickshell/bar/ActiveWindow.qml create mode 100644 dot_config/quickshell/bar/BarPill.qml create mode 100644 dot_config/quickshell/bar/DateTimePill.qml create mode 100644 dot_config/quickshell/bar/GamemodePill.qml create mode 100644 dot_config/quickshell/bar/MediaPill.qml create mode 100644 dot_config/quickshell/bar/SystemPill.qml create mode 100644 dot_config/quickshell/bar/WeatherPill.qml create mode 100644 dot_config/quickshell/bar/Workspaces.qml create mode 100644 dot_config/quickshell/bar/popouts/CalendarPopout.qml create mode 100644 dot_config/quickshell/bar/popouts/MediaPopout.qml create mode 100644 dot_config/quickshell/bar/popouts/NotificationCenter.qml create mode 100644 dot_config/quickshell/bar/popouts/PopoutBackground.qml create mode 100644 dot_config/quickshell/bar/popouts/SystemPopout.qml create mode 100644 dot_config/quickshell/bar/popouts/WeatherPopout.qml create mode 100644 dot_config/quickshell/launcher/.keep create mode 100644 dot_config/quickshell/lock/.keep create mode 100644 dot_config/quickshell/lock/IdleScreen.qml create mode 100644 dot_config/quickshell/notifications/NotificationDaemon.qml create mode 100644 dot_config/quickshell/notifications/NotificationPopup.qml create mode 100644 dot_config/quickshell/osd/Osd.qml create mode 100644 dot_config/quickshell/scripts/executable_gpu.sh create mode 100644 dot_config/quickshell/shared/Config.qml.tmpl create mode 100644 dot_config/quickshell/shared/PopoutState.qml create mode 100644 dot_config/quickshell/shared/Theme.qml create mode 100644 dot_config/quickshell/shared/Time.qml create mode 100644 dot_config/quickshell/shared/Weather.qml create mode 100644 dot_config/quickshell/shell.qml diff --git a/dot_config/hypr/hyprland.d/60-rules.conf.tmpl b/dot_config/hypr/hyprland.d/60-rules.conf.tmpl index 2ff5cdd..c2b4c3e 100644 --- a/dot_config/hypr/hyprland.d/60-rules.conf.tmpl +++ b/dot_config/hypr/hyprland.d/60-rules.conf.tmpl @@ -128,3 +128,7 @@ windowrule = match:class com.gabm.satty, min_size 700 400 #layerrule = blur on, match:namespace swaync-notification-window #layerrule = ignore_alpha 0, match:namespace swaync-control-center #layerrule = ignore_alpha 0, match:namespace swaync-notification-window + +# Quickshell +layerrule = blur on, match:namespace quickshell:.* +layerrule = ignore_alpha 0.79, match:namespace quickshell:.* diff --git a/dot_config/quickshell/Bar.qml b/dot_config/quickshell/Bar.qml new file mode 100644 index 0000000..0bbbedb --- /dev/null +++ b/dot_config/quickshell/Bar.qml @@ -0,0 +1,273 @@ +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import Quickshell.Services.Pipewire +import QtQuick +import QtQuick.Layouts +import "shared" as Shared +import "bar" as BarComponents +import "bar/popouts" as Popouts + +Scope { + property var notifModel: null + property var notifDaemon: null + + PwObjectTracker { + objects: [ Pipewire.defaultAudioSink, Pipewire.defaultAudioSource ] + } + + property bool popoutOpen: Shared.PopoutState.active !== "" + + // ═══════════════════════════════════════ + // BAR — fixed width, never resizes + // ═══════════════════════════════════════ + Variants { + model: Quickshell.screens + + delegate: Component { + PanelWindow { + id: barWindow + required property var modelData + screen: modelData + + // Screens sorted left→right by x position (negative x = leftmost) + property var sortedScreens: { + let s = []; + for (let i = 0; i < Quickshell.screens.length; i++) s.push(Quickshell.screens[i]); + s.sort((a, b) => a.x - b.x); + return s; + } + + visible: modelData.name === Shared.Config.monitor + WlrLayershell.namespace: "quickshell:bar" + surfaceFormat { opaque: !Shared.Theme.transparencyEnabled } + + anchors { + top: true + right: true + bottom: true + } + + implicitWidth: Shared.Theme.barWidth + exclusiveZone: Shared.Theme.barWidth + color: Shared.Theme.barBackground + + ColumnLayout { + anchors.fill: parent + anchors.topMargin: Shared.Theme.barPadding + 2 + anchors.bottomMargin: Shared.Theme.barPadding + 2 + anchors.leftMargin: Shared.Theme.barPadding + anchors.rightMargin: Shared.Theme.barPadding + spacing: Shared.Theme.spacing + + // ── Top ────────────────────────────── + BarComponents.DateTimePill { id: datetimeBtn } + + BarComponents.WeatherPill { id: weatherBtn } + + // ── Center (workspaces) ─────────────── + Item { Layout.fillHeight: true } + + // Per-monitor workspace groups — sorted left→right by screen position + Repeater { + model: barWindow.sortedScreens.length + + delegate: Column { + required property int index + spacing: Shared.Theme.spacing + + Rectangle { + visible: index > 0 + width: Shared.Theme.barInnerWidth + height: 1 + color: Shared.Theme.overlay0 + anchors.horizontalCenter: parent.horizontalCenter + } + + BarComponents.Workspaces { + monitorName: barWindow.sortedScreens[index].name + } + } + } + + Item { Layout.fillHeight: true } + + // ── Bottom ──────────────────────────── + BarComponents.GamemodePill {} + + BarComponents.MediaPill { id: mediaBtn } + + BarComponents.BarPill { + id: notifBtn + groupName: "notifications" + accentColor: Shared.Theme.mauve + + property int count: notifModel ? notifModel.values.length : 0 + + content: [ + Text { + Layout.alignment: Qt.AlignHCenter + text: notifDaemon?.dnd ? "\u{f009b}" : "\u{f0f3}" + color: notifDaemon?.dnd ? Shared.Theme.danger : Shared.Theme.mauve + font.pixelSize: Shared.Theme.fontLarge + font.family: Shared.Theme.iconFont + }, + Text { + Layout.alignment: Qt.AlignHCenter + visible: notifBtn.count > 0 + text: notifBtn.count.toString() + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + font.bold: true + } + ] + } + + BarComponents.SystemPill { id: systemBtn } + } + } + } + } + + // ═══════════════════════════════════════ + // POPOUT WINDOW — separate overlay, only exists when open + // ═══════════════════════════════════════ + Variants { + model: Quickshell.screens + + delegate: Component { + PanelWindow { + id: popoutWindow + required property var modelData + screen: modelData + + visible: modelData.name === Shared.Config.monitor && (popoutOpen || notifSlot.animating || mediaSlot.animating || weatherSlot.animating || datetimeSlot.animating || systemSlot.animating) + WlrLayershell.namespace: "quickshell:popout" + surfaceFormat { opaque: false } + + anchors { + top: true + right: true + bottom: true + } + + implicitWidth: Shared.Theme.popoutWidth + 12 + exclusionMode: ExclusionMode.Ignore + focusable: popoutOpen + color: "transparent" + + margins { + right: Shared.Theme.barWidth + } + + // Escape key = close + Keys.onEscapePressed: Shared.PopoutState.close() + + // Click on empty area = close + MouseArea { + anchors.fill: parent + z: -1 + onClicked: Shared.PopoutState.close() + } + + // Popout slots + Item { + id: popoutArea + anchors.fill: parent + + PopoutSlot { + id: notifSlot + name: "notifications" + verticalAnchor: "bottom" + sourceComponent: Popouts.NotificationCenter { trackedNotifications: notifModel; daemon: notifDaemon } + } + + PopoutSlot { + id: mediaSlot + name: "media" + verticalAnchor: "bottom" + sourceComponent: Popouts.MediaPopout {} + } + + PopoutSlot { + id: weatherSlot + name: "weather" + verticalAnchor: "top" + sourceComponent: Popouts.WeatherPopout {} + } + + PopoutSlot { + id: datetimeSlot + name: "datetime" + verticalAnchor: "top" + sourceComponent: Popouts.CalendarPopout {} + } + + PopoutSlot { + id: systemSlot + name: "system" + verticalAnchor: "bottom" + sourceComponent: Popouts.SystemPopout { panelWindow: popoutWindow } + } + } + + // PopoutSlot — anchored to triggering icon, MD3 animation + component PopoutSlot: Item { + id: slot + required property string name + property alias sourceComponent: loader.sourceComponent + property string verticalAnchor: "center" // "top" | "bottom" | "center" + + readonly property bool isOpen: Shared.PopoutState.active === name + readonly property bool animating: fadeAnim.running || scaleAnim.running || slideAnim.running + readonly property real cardH: loader.item?.implicitHeight ?? 400 + readonly property real cardW: loader.item?.implicitWidth ?? Shared.Theme.popoutWidth + + readonly property real centerY: Math.max(16, Math.min( + popoutArea.height - cardH - 16, + (popoutArea.height - cardH) / 2 + )) + + anchors.right: parent.right + y: { + if (verticalAnchor === "top") + return Math.max(16, Shared.PopoutState.triggerY); + if (verticalAnchor === "bottom") + return Math.min(popoutArea.height - cardH - 16, + Shared.PopoutState.triggerY - cardH); + return centerY; + } + width: cardW + height: cardH + + visible: isOpen || animating + + opacity: isOpen ? 1.0 : 0.0 + scale: isOpen ? 1.0 : 0.97 + transformOrigin: Item.Right + property real slideX: isOpen ? 0 : 8 + + Behavior on opacity { + NumberAnimation { id: fadeAnim; duration: 180; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { id: scaleAnim; duration: 220; easing.type: Easing.OutCubic } + } + Behavior on slideX { + NumberAnimation { id: slideAnim; duration: 220; easing.type: Easing.OutCubic } + } + + transform: Translate { x: slot.slideX } + + Loader { + id: loader + active: slot.isOpen || slot.animating + width: slot.cardW + height: slot.cardH + } + } + } + } + } +} diff --git a/dot_config/quickshell/CLAUDE.md b/dot_config/quickshell/CLAUDE.md new file mode 100644 index 0000000..12a2378 --- /dev/null +++ b/dot_config/quickshell/CLAUDE.md @@ -0,0 +1,85 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +A Quickshell (v0.2.x) desktop shell configuration for Hyprland on Arch Linux. It renders a vertical bar on the right edge of the screen with popout panels. Written entirely in QML using Quickshell's extended Qt Quick modules. + +## Running / Reloading + +```bash +quickshell # launch (reads shell.qml from ~/.config/quickshell/) +quickshell -c /path/to/dir # launch from a different config dir +``` + +Quickshell hot-reloads on file save — no restart needed during development. If the shell crashes or gets stuck, `killall quickshell && quickshell &` to restart. + +## Architecture + +### Entry Point + +`shell.qml` — `ShellRoot` that instantiates three top-level components: +1. `NotificationDaemon` — D-Bus notification server +2. `Bar` — the main bar + popout overlay +3. `Osd` — volume/mic OSD overlay + +### Bar (`Bar.qml`) + +Uses `Variants` over `Quickshell.screens` to create per-monitor `PanelWindow` instances (filtered to `Config.monitor`). Two window layers: +- **Bar window** — fixed-width right-anchored panel with pill-shaped widgets +- **Popout window** — transparent overlay with animated `PopoutSlot` components that appear left of the bar + +Bar pills (notification, workspaces, weather, datetime, system) toggle popouts via the `PopoutState` singleton. + +### Shared Singletons (`shared/`) + +All four are `pragma Singleton`: +- **Config** — user settings (monitor=DP-2, weatherLocation=Nospelt, power commands, disk mounts, formats); `.tmpl` file driven by chezmoi data +- **Theme** — Apex-neon/aeon colors (replaces Catppuccin), layout constants, typography (GeistMono Nerd Font), animation durations +- **PopoutState** — which popout is active (`active` string + `triggerY`), with `toggle()`/`close()` +- **Time** — `SystemClock`-backed formatted time strings +- **Weather** — fetches from `wttr.in`, parses JSON, exposes current + forecast data + +### Popout Pattern + +Each popout follows the same structure: +1. A `BarPill` in the bar column (toggles `PopoutState`) +2. A `PopoutSlot` in `Bar.qml` (handles animation — fade, scale, slide) +3. A popout component in `bar/popouts/` wrapped in `PopoutBackground` (rounded left, flush right) + +### Data Fetching + +`SystemPopout` polls system stats via `Process` + `StdioCollector`: +- CPU, memory, temperature, GPU (via `scripts/gpu.sh`), disk, network, updates +- Different refresh intervals: 5s (CPU/mem/temp/GPU), 30s (disk/net), 5min (updates) + +Weather uses `curl wttr.in` with a 30-minute refresh timer. + +### Quickshell-Specific Patterns + +- `Variants { model: Quickshell.screens }` — per-screen window instantiation +- `PanelWindow` with `anchors`/`margins`/`exclusiveZone` — Wayland layer-shell positioning +- `Scope` — non-visual Quickshell container for grouping logic +- `Process` + `StdioCollector { onStreamFinished }` — async shell command execution +- `PwObjectTracker` — required to track PipeWire default sink/source changes +- `Quickshell.Hyprland` — workspace state and `Hyprland.dispatch()` for IPC + +### Key Dependencies + +- **Hyprland** — compositor (workspace switching, IPC) +- **PipeWire** — audio control (volume, mute) +- **wttr.in** — weather data (no API key needed) +- **lm_sensors** (`sensors`) — CPU temperature +- **Nerd Fonts** (GeistMono Nerd Font) — all icons are Unicode Nerd Font glyphs +- **hyprlock** / **hypridle** / **hyprshutdown** — lock, idle, power actions + +## Conventions + +- Icons: Nerd Font Unicode escapes (`"\u{f057e}"`) — not icon names or image files +- Colors: always reference `Shared.Theme.*` (apex-neon palette by default; apex-aeon for light mode) +- Layout: use `Shared.Theme.*` constants for sizing, spacing, radii +- Config: user-facing settings go in `Config.qml`, not hardcoded in components +- Animations: use `Behavior on ` with `Shared.Theme.animFast/animNormal/animSlow` +- Inline components: `component Foo: ...` inside a file for file-local reusable types (see `SystemPopout`) +- Popout panels consume mouse clicks with a root `MouseArea` to prevent click-through closing diff --git a/dot_config/quickshell/README.md b/dot_config/quickshell/README.md new file mode 100644 index 0000000..4a99804 --- /dev/null +++ b/dot_config/quickshell/README.md @@ -0,0 +1,70 @@ +# Quickshell Desktop Shell + +A vertical bar + popout panel shell for Hyprland, built with Quickshell v0.2.x and themed with Catppuccin. + +## Setup + +```bash +quickshell # launch (reads from ~/.config/quickshell/) +``` + +Quickshell hot-reloads on file save. If it crashes: `killall quickshell && quickshell &` + +## Configuration + +All user settings live in **`shared/Config.qml`**. Edit this file to customize your setup: + +| Setting | Default | Description | +|---------|---------|-------------| +| `catppuccinFlavor` | `"mocha"` | Color theme: `"mocha"`, `"macchiato"`, `"frappe"`, `"latte"` | +| `transparency` | `true` | Semi-transparent bar/popouts (requires Hyprland layerrules below) | +| `monitor` | `"DP-1"` | Which monitor to display the bar on | +| `workspaceCount` | `5` | Number of workspace indicators | +| `weatherLocation` | `"Munich"` | City name for wttr.in weather data | +| `useCelsius` | `true` | Temperature unit (`false` for Fahrenheit) | +| `use24h` | `true` | Clock format (`false` for 12-hour) | +| `weekStartsMonday` | `true` | Calendar week start day | +| `diskMount1` / `diskMount2` | `"/"` / `~/data` | Disk mounts shown in system popout | +| `idleProcess` | `"hypridle"` | Idle daemon to toggle | +| `lockCommand` | `"hyprlock"` | Lock screen command | + +## Hyprland Layerrules (required for blur/transparency) + +Add to your `hyprland.conf`: + +```ini +# Enable blur on all Quickshell windows +layerrule = match:namespace quickshell:.*, blur on +layerrule = match:namespace quickshell:.*, ignore_alpha 0.79 +``` + +The shell registers these namespaces: + +| Namespace | Window | +|-----------|--------| +| `quickshell:bar` | The main bar panel | +| `quickshell:popout` | Popout overlay (notifications, media, weather, etc.) | +| `quickshell:osd` | Volume/brightness OSD | +| `quickshell:notifications` | Toast notification popups | +| `quickshell:idle` | Idle screen overlay | + +To target specific windows, replace `quickshell:.*` with the exact namespace: + +```ini +# Only blur the bar, not popouts +layerrule = match:namespace quickshell:bar, blur on +layerrule = match:namespace quickshell:bar, ignore_alpha 0.79 +``` + +If you set `transparency: false` in Config.qml, layerrules are not needed. + +## Dependencies + +- **Hyprland** — compositor +- **PipeWire** — audio control +- **lm_sensors** (`sensors`) — CPU temperature +- **Nerd Fonts** (Inconsolata Go Nerd Font) — all icons +- **hyprlock** / **hypridle** / **hyprshutdown** — lock, idle, power +- **brightnessctl** — brightness OSD (optional, auto-detected) +- **wf-recorder** — screen recording (optional) +- **hyprpicker** — color picker (optional) diff --git a/dot_config/quickshell/bar/ActiveWindow.qml b/dot_config/quickshell/bar/ActiveWindow.qml new file mode 100644 index 0000000..feb3551 --- /dev/null +++ b/dot_config/quickshell/bar/ActiveWindow.qml @@ -0,0 +1,43 @@ +import Quickshell.Hyprland +import QtQuick +import "../shared" as Shared + +// Compact active window title, rotated vertically to fit the narrow bar +Item { + id: root + + readonly property string title: { + let w = Hyprland.focusedWindow; + if (!w) return ""; + let t = w.title || ""; + let c = w.wlClass || ""; + // Show class if title is too long or empty + if (t.length === 0) return c; + if (t.length > 30) return c || t.substring(0, 20); + return t; + } + + visible: title !== "" + implicitWidth: Shared.Theme.barInnerWidth + implicitHeight: Math.min(label.implicitWidth + 8, 120) + + Text { + id: label + anchors.centerIn: parent + rotation: -90 + width: root.implicitHeight + text: root.title + color: Shared.Theme.subtext0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + + Behavior on text { + enabled: false // no animation on text change + } + } + + opacity: title !== "" ? 0.7 : 0 + Behavior on opacity { NumberAnimation { duration: Shared.Theme.animFast } } +} diff --git a/dot_config/quickshell/bar/BarPill.qml b/dot_config/quickshell/bar/BarPill.qml new file mode 100644 index 0000000..cb2e6b8 --- /dev/null +++ b/dot_config/quickshell/bar/BarPill.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +// Unified segmented-control pill for bar group buttons +Item { + id: root + + property string groupName: "" + property color accentColor: Shared.Theme.text + property alias content: contentArea.data + + readonly property bool isActive: Shared.PopoutState.active === groupName + readonly property bool isHovered: mouse.containsMouse + + implicitWidth: Shared.Theme.barInnerWidth + implicitHeight: pill.height + + Rectangle { + id: pill + width: parent.width + height: contentArea.implicitHeight + Shared.Theme.barPadding * 2 + 6 + radius: Shared.Theme.radiusNormal + color: root.isActive + ? Qt.alpha(root.accentColor, Shared.Theme.opacityLight) + : root.isHovered + ? Shared.Theme.surface1 + : Shared.Theme.surface0 + border.width: root.isActive ? 1 : 0 + border.color: Qt.alpha(root.accentColor, Shared.Theme.opacityMedium) + + Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } } + Behavior on border.color { ColorAnimation { duration: Shared.Theme.animFast } } + + ColumnLayout { + id: contentArea + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + let globalPos = root.mapToItem(null, 0, root.height / 2); + Shared.PopoutState.toggle(root.groupName, globalPos.y); + } + } +} diff --git a/dot_config/quickshell/bar/DateTimePill.qml b/dot_config/quickshell/bar/DateTimePill.qml new file mode 100644 index 0000000..2d8db45 --- /dev/null +++ b/dot_config/quickshell/bar/DateTimePill.qml @@ -0,0 +1,37 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +BarPill { + groupName: "datetime" + accentColor: Shared.Theme.teal + + content: [ + Text { + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + text: Qt.formatDateTime(Shared.Time.date, Shared.Config.pillTimeFormat) + color: Shared.Theme.teal + font.pixelSize: Shared.Theme.fontLarge + font.family: Shared.Theme.fontFamily + font.bold: true + lineHeight: 1.1 + }, + Rectangle { + Layout.alignment: Qt.AlignHCenter + width: Shared.Theme.barInnerWidth * 0.5 + height: 1 + color: Shared.Theme.teal + opacity: Shared.Theme.opacityLight + }, + Text { + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + text: Qt.formatDateTime(Shared.Time.date, Shared.Config.pillDateFormat) + color: Shared.Theme.teal + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + ] +} diff --git a/dot_config/quickshell/bar/GamemodePill.qml b/dot_config/quickshell/bar/GamemodePill.qml new file mode 100644 index 0000000..df4f18b --- /dev/null +++ b/dot_config/quickshell/bar/GamemodePill.qml @@ -0,0 +1,48 @@ +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +// Gamemode indicator — visible only when gamemode is active +// Polls `gamemoded --status` every 5 seconds +BarPill { + id: root + + groupName: "" // no popout + accentColor: Shared.Theme.green + visible: isActive + + property bool isActive: false + + content: [ + Text { + Layout.alignment: Qt.AlignHCenter + text: "\u{f0522}" // nf-md-controller_classic + color: Shared.Theme.green + font.pixelSize: Shared.Theme.fontLarge + font.family: Shared.Theme.iconFont + } + ] + + Process { + id: gameProc + command: ["gamemoded", "--status"] + stdout: StdioCollector { + onStreamFinished: { + root.isActive = this.text.indexOf("is active") >= 0 + } + } + } + + function poll() { gameProc.running = false; gameProc.running = true; } + + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: root.poll() + } + + Component.onCompleted: root.poll() +} diff --git a/dot_config/quickshell/bar/MediaPill.qml b/dot_config/quickshell/bar/MediaPill.qml new file mode 100644 index 0000000..20a0265 --- /dev/null +++ b/dot_config/quickshell/bar/MediaPill.qml @@ -0,0 +1,35 @@ +import Quickshell +import Quickshell.Services.Mpris +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +BarPill { + id: root + + readonly property var player: { + let players = Mpris.players.values; + for (let i = 0; i < players.length; i++) { + if (players[i].isPlaying) return players[i]; + } + return players.length > 0 ? players[0] : null; + } + + visible: player !== null + groupName: "media" + accentColor: Shared.Theme.green + + content: [ + Text { + Layout.alignment: Qt.AlignHCenter + text: { + if (!root.player) return "\u{f075a}"; + if (root.player.isPlaying) return "\u{f040a}"; + return "\u{f03e4}"; + } + color: Shared.Theme.green + font.pixelSize: Shared.Theme.fontLarge + font.family: Shared.Theme.iconFont + } + ] +} diff --git a/dot_config/quickshell/bar/SystemPill.qml b/dot_config/quickshell/bar/SystemPill.qml new file mode 100644 index 0000000..833f4be --- /dev/null +++ b/dot_config/quickshell/bar/SystemPill.qml @@ -0,0 +1,19 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +BarPill { + groupName: "system" + accentColor: Shared.Theme.lavender + + content: [ + Text { + Layout.alignment: Qt.AlignHCenter + text: "\u{f303}" + color: Shared.Theme.lavender + font.pixelSize: Shared.Theme.fontLarge + 2 + font.family: Shared.Theme.iconFont + } + ] +} diff --git a/dot_config/quickshell/bar/WeatherPill.qml b/dot_config/quickshell/bar/WeatherPill.qml new file mode 100644 index 0000000..07789c0 --- /dev/null +++ b/dot_config/quickshell/bar/WeatherPill.qml @@ -0,0 +1,33 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +BarPill { + groupName: "weather" + accentColor: Shared.Weather.status === "error" ? Shared.Theme.overlay0 : Shared.Theme.peach + + content: [ + Text { + Layout.alignment: Qt.AlignHCenter + text: Shared.Weather.status === "error" ? "\u{f0599}" : Shared.Weather.icon + color: Shared.Weather.status === "error" ? Shared.Theme.overlay0 + : Shared.Weather.status === "stale" ? Shared.Theme.warning + : Shared.Theme.peach + font.pixelSize: Shared.Theme.fontLarge + font.family: Shared.Theme.iconFont + }, + Text { + Layout.alignment: Qt.AlignHCenter + text: Shared.Weather.status === "error" ? "N/A" + : Shared.Weather.status === "loading" ? "…" + : Shared.Weather.temp + color: Shared.Weather.status === "error" ? Shared.Theme.overlay0 + : Shared.Weather.status === "stale" ? Shared.Theme.warning + : Shared.Theme.peach + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + font.bold: true + } + ] +} diff --git a/dot_config/quickshell/bar/Workspaces.qml b/dot_config/quickshell/bar/Workspaces.qml new file mode 100644 index 0000000..44d4487 --- /dev/null +++ b/dot_config/quickshell/bar/Workspaces.qml @@ -0,0 +1,164 @@ +import Quickshell +import Quickshell.Hyprland +import QtQuick +import "../shared" as Shared + +// Rounded-square workspace slots with sliding active highlight. +// Set monitorName to show only workspaces on a specific monitor. +Item { + id: root + + property string monitorName: "" + property int wsCount: Shared.Config.workspaceCount + + readonly property int cellSize: Shared.Theme.barInnerWidth - Shared.Theme.barPadding * 2 + + readonly property var iconMap: ({ + "mail": "\u{eb1c}", + "comms": "\u{ee59}", + "element": "\u{f1d7}", + "joplin": "\u{f249}", + "steam": "\u{f1b6}", + "spotify": "\u{f1bc}" + }) + + // { id, name } objects for workspaces on this monitor, sorted by name numerically. + property var monitorWsData: { + if (!monitorName) return []; + let all = Hyprland.workspaces, data = []; + for (let i = 0; i < all.values.length; i++) { + let ws = all.values[i]; + if (ws.monitor && ws.monitor.name === monitorName) + data.push({ id: ws.id, name: ws.name }); + } + data.sort((a, b) => parseInt(a.name) - parseInt(b.name)); + return data; + } + + // Active workspace ID for this monitor specifically + property int activeWsId: { + if (monitorName) { + let mons = Hyprland.monitors; + for (let i = 0; i < mons.values.length; i++) { + if (mons.values[i].name === monitorName) + return mons.values[i].activeWorkspace?.id ?? -1; + } + return -1; + } + let fw = Hyprland.focusedWorkspace; + return fw ? fw.id : 1; + } + + implicitWidth: Shared.Theme.barInnerWidth + implicitHeight: container.height + + Rectangle { + id: container + width: parent.width + height: wsCol.height + padding * 2 + radius: Shared.Theme.radiusNormal + color: Shared.Theme.surface0 + + property int padding: Shared.Theme.barPadding + 2 + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: event => { + if (event.angleDelta.y > 0) + Hyprland.dispatch("workspace m-1"); + else + Hyprland.dispatch("workspace m+1"); + } + } + + // Sliding active indicator + Rectangle { + id: activeIndicator + + property int targetIndex: { + if (root.monitorName) { + for (let i = 0; i < root.monitorWsData.length; i++) { + if (root.monitorWsData[i].id === root.activeWsId) return i; + } + return 0; + } + return Math.max(0, Math.min(root.activeWsId - 1, root.wsCount - 1)); + } + property var targetItem: wsRepeater.itemAt(targetIndex) + + x: (container.width - width) / 2 + y: targetItem ? targetItem.y + wsCol.y : container.padding + width: root.cellSize + height: root.cellSize + radius: Shared.Theme.radiusSmall + color: Shared.Theme.red + + Behavior on y { + NumberAnimation { + duration: Shared.Theme.animSlow + easing.type: Easing.InOutQuart + } + } + } + + Column { + id: wsCol + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: container.padding + spacing: Math.floor(Shared.Theme.spacing / 2) + + Repeater { + id: wsRepeater + model: root.monitorName ? root.monitorWsData.length : root.wsCount + + delegate: Item { + id: wsItem + required property int index + + width: root.cellSize + height: root.cellSize + + property int wsId: root.monitorName ? root.monitorWsData[index].id : index + 1 + property string wsName: root.monitorName ? root.monitorWsData[index].name : (index + 1).toString() + property bool isActive: root.activeWsId === wsId + property bool isOccupied: { + let all = Hyprland.workspaces; + for (let i = 0; i < all.values.length; i++) { + if (all.values[i].id === wsId) + return all.values[i].lastIpcObject?.windows > 0; + } + return false; + } + + Text { + anchors.centerIn: parent + + property string icon: root.iconMap[wsItem.wsName] ?? "" + property bool hasIcon: icon !== "" + + text: hasIcon ? icon : wsItem.wsName + color: wsItem.isActive + ? Shared.Theme.crust + : wsItem.isOccupied + ? Shared.Theme.text + : Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontLarge + font.family: hasIcon ? Shared.Theme.iconFont : Shared.Theme.fontFamily + font.bold: !hasIcon && wsItem.isActive + + Behavior on color { + ColorAnimation { duration: Shared.Theme.animFast } + } + } + + MouseArea { + anchors.fill: parent + onClicked: Hyprland.dispatch("workspace name:" + wsItem.wsName) + } + } + } + } + } +} diff --git a/dot_config/quickshell/bar/popouts/CalendarPopout.qml b/dot_config/quickshell/bar/popouts/CalendarPopout.qml new file mode 100644 index 0000000..8995687 --- /dev/null +++ b/dot_config/quickshell/bar/popouts/CalendarPopout.qml @@ -0,0 +1,189 @@ +import QtQuick +import QtQuick.Layouts +import "../../shared" as Shared + +Item { + id: root + + implicitWidth: Shared.Theme.popoutWidth + implicitHeight: col.implicitHeight + Shared.Theme.popoutPadding * 2 + + PopoutBackground { anchors.fill: parent } + + property int viewMonth: new Date().getMonth() + property int viewYear: new Date().getFullYear() + property int todayDay: Shared.Time.date.getDate() + property int todayMonth: Shared.Time.date.getMonth() + property int todayYear: Shared.Time.date.getFullYear() + + readonly property bool isViewingToday: viewMonth === todayMonth && viewYear === todayYear + + function prevMonth() { if (viewMonth === 0) { viewMonth = 11; viewYear--; } else viewMonth--; } + function nextMonth() { if (viewMonth === 11) { viewMonth = 0; viewYear++; } else viewMonth++; } + function goToday() { viewMonth = todayMonth; viewYear = todayYear; } + function daysInMonth(m, y) { return new Date(y, m + 1, 0).getDate(); } + function firstDayOfWeek(m, y) { + let d = new Date(y, m, 1).getDay(); + if (Shared.Config.weekStartsMonday) + return d === 0 ? 6 : d - 1; + return d; + } + + MouseArea { + anchors.fill: parent + onWheel: event => { + if (event.angleDelta.y > 0) root.prevMonth(); + else root.nextMonth(); + } + } + + ColumnLayout { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Shared.Theme.popoutPadding + spacing: Shared.Theme.popoutSpacing + + Text { + text: Shared.Time.clockSeconds + color: Shared.Theme.text + font.pixelSize: 28 + font.family: Shared.Theme.fontFamily + font.bold: true + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 2 + } + + Text { + text: Qt.formatDateTime(Shared.Time.date, Shared.Config.dateFormat) + color: Shared.Theme.subtext0 + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + Layout.alignment: Qt.AlignHCenter + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + Layout.topMargin: 4 + Layout.bottomMargin: 4 + height: 1 + color: Shared.Theme.surface0 + } + + RowLayout { + Layout.fillWidth: true + + Text { + text: "\u{f0141}" + color: navPrev.containsMouse ? Shared.Theme.text : Shared.Theme.subtext0 + font.pixelSize: 16 + font.family: Shared.Theme.iconFont + MouseArea { id: navPrev; anchors.fill: parent; hoverEnabled: true; onClicked: root.prevMonth() } + } + + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: new Date(root.viewYear, root.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy") + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + font.bold: true + MouseArea { anchors.fill: parent; onClicked: root.goToday() } + } + + Rectangle { + visible: !root.isViewingToday + implicitWidth: todayLabel.implicitWidth + 10 + implicitHeight: 20 + radius: 10 + color: todayMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0 + Behavior on color { ColorAnimation { duration: 100 } } + Text { + id: todayLabel + anchors.centerIn: parent + text: "Today" + color: Shared.Theme.blue + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + MouseArea { id: todayMouse; anchors.fill: parent; hoverEnabled: true; onClicked: root.goToday() } + } + + Text { + text: "\u{f0142}" + color: navNext.containsMouse ? Shared.Theme.text : Shared.Theme.subtext0 + font.pixelSize: 16 + font.family: Shared.Theme.iconFont + MouseArea { id: navNext; anchors.fill: parent; hoverEnabled: true; onClicked: root.nextMonth() } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 0 + Repeater { + model: Shared.Config.dayHeaders + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: modelData + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + } + } + + Grid { + id: calGrid + Layout.fillWidth: true + columns: 7 + spacing: 0 + + property int numDays: root.daysInMonth(root.viewMonth, root.viewYear) + property int startDay: root.firstDayOfWeek(root.viewMonth, root.viewYear) + + Repeater { + model: calGrid.startDay + calGrid.numDays + (7 - (calGrid.startDay + calGrid.numDays) % 7) % 7 + + Item { + required property int index + property int day: index - calGrid.startDay + 1 + property bool isValid: day >= 1 && day <= calGrid.numDays + property bool isToday: isValid && day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear + property bool isWeekend: { + let col = index % 7; + if (Shared.Config.weekStartsMonday) + return col >= 5; // Sa=5, Su=6 + return col === 0 || col === 6; // Su=0, Sa=6 + } + + width: calGrid.width / 7 + height: 28 + + Rectangle { + anchors.centerIn: parent + width: 24; height: 24; radius: 12 + color: parent.isToday ? Shared.Theme.blue : "transparent" + } + + Text { + anchors.centerIn: parent + text: parent.isValid ? parent.day.toString() : "" + color: parent.isToday ? Shared.Theme.crust + : parent.isWeekend && parent.isValid ? Shared.Theme.overlay0 + : parent.isValid ? Shared.Theme.text + : "transparent" + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + font.bold: parent.isToday + } + } + } + } + } +} diff --git a/dot_config/quickshell/bar/popouts/MediaPopout.qml b/dot_config/quickshell/bar/popouts/MediaPopout.qml new file mode 100644 index 0000000..0c572f4 --- /dev/null +++ b/dot_config/quickshell/bar/popouts/MediaPopout.qml @@ -0,0 +1,240 @@ +import Quickshell +import Quickshell.Services.Mpris +import QtQuick +import QtQuick.Layouts +import "../../shared" as Shared + +Item { + id: root + + implicitWidth: Shared.Theme.popoutWidth + implicitHeight: col.implicitHeight + Shared.Theme.popoutPadding * 2 + + PopoutBackground { anchors.fill: parent } + MouseArea { anchors.fill: parent } + + readonly property var player: { + let players = Mpris.players.values; + for (let i = 0; i < players.length; i++) { + if (players[i].isPlaying) return players[i]; + } + return players.length > 0 ? players[0] : null; + } + + readonly property bool isPlaying: player?.isPlaying ?? false + readonly property string trackTitle: player?.trackTitle ?? "" + readonly property string trackArtist: player?.trackArtist ?? "" + readonly property string trackAlbum: player?.trackAlbum ?? "" + readonly property string artUrl: player?.trackArtUrl ?? "" + readonly property real trackLength: player?.length ?? 0 + readonly property real trackPosition: player?.position ?? 0 + + // Position doesn't auto-update — emit positionChanged periodically while playing + Timer { + interval: 1000 + running: root.isPlaying && root.player !== null + repeat: true + onTriggered: { if (root.player) root.player.positionChanged(); } + } + + ColumnLayout { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Shared.Theme.popoutPadding + spacing: 10 + + // Album art + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: artImg.hasArt ? width * 0.6 : 48 + radius: Shared.Theme.radiusSmall + color: Shared.Theme.surface0 + clip: true + + Behavior on Layout.preferredHeight { NumberAnimation { duration: Shared.Theme.animNormal; easing.type: Easing.OutCubic } } + + Image { + id: artImg + anchors.fill: parent + source: root.artUrl + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: hasArt + property bool hasArt: status === Image.Ready && root.artUrl !== "" + } + + // Gradient overlay at bottom for readability + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * 0.4 + visible: artImg.hasArt + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: Qt.alpha(Shared.Theme.mantle, 0.8) } + } + } + + // Placeholder icon when no art + Text { + anchors.centerIn: parent + visible: !artImg.hasArt + text: "\u{f075a}" + color: Shared.Theme.overlay0 + font.pixelSize: 24 + font.family: Shared.Theme.iconFont + } + } + + // Track info + Text { + text: root.trackTitle || "No track" + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontLarge + font.family: Shared.Theme.fontFamily + font.bold: true + Layout.fillWidth: true + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + visible: text !== "" + text: root.trackArtist + color: Shared.Theme.subtext0 + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + Layout.fillWidth: true + elide: Text.ElideRight + Layout.topMargin: -6 + } + + // Progress bar + Rectangle { + Layout.fillWidth: true + implicitHeight: 4 + radius: 2 + color: Shared.Theme.surface0 + visible: root.trackLength > 0 + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.trackLength > 0 ? parent.width * Math.min(1, root.trackPosition / root.trackLength) : 0 + radius: 2 + color: Shared.Theme.green + } + + MouseArea { + anchors.fill: parent + onClicked: event => { + if (root.player?.canSeek && root.trackLength > 0) { + let ratio = event.x / parent.width; + let target = ratio * root.trackLength; + root.player.position = target; + } + } + } + } + + // Time labels + RowLayout { + Layout.fillWidth: true + visible: root.trackLength > 0 + + Text { + text: formatTime(root.trackPosition) + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + Item { Layout.fillWidth: true } + Text { + text: formatTime(root.trackLength) + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + } + + // Controls + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 16 + + // Previous + Text { + text: "\u{f04ae}" + color: prevMouse.containsMouse ? Shared.Theme.text : Shared.Theme.subtext0 + font.pixelSize: 20 + font.family: Shared.Theme.iconFont + opacity: root.player?.canGoPrevious ? 1.0 : 0.3 + MouseArea { + id: prevMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { if (root.player?.canGoPrevious) root.player.previous(); } + } + } + + // Play/Pause + Rectangle { + implicitWidth: 40 + implicitHeight: 40 + radius: 20 + color: playMouse.containsMouse ? Shared.Theme.green : Qt.alpha(Shared.Theme.green, Shared.Theme.opacityMedium) + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: root.isPlaying ? "\u{f03e4}" : "\u{f040a}" + color: Shared.Theme.crust + font.pixelSize: 20 + font.family: Shared.Theme.iconFont + } + + MouseArea { + id: playMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { if (root.player?.canTogglePlaying) root.player.togglePlaying(); } + } + } + + // Next + Text { + text: "\u{f04ad}" + color: nextMouse.containsMouse ? Shared.Theme.text : Shared.Theme.subtext0 + font.pixelSize: 20 + font.family: Shared.Theme.iconFont + opacity: root.player?.canGoNext ? 1.0 : 0.3 + MouseArea { + id: nextMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { if (root.player?.canGoNext) root.player.next(); } + } + } + } + + // Player name + Text { + Layout.alignment: Qt.AlignHCenter + text: root.player?.identity ?? "" + color: Shared.Theme.surface2 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + } + + function formatTime(secs) { + let m = Math.floor(secs / 60); + let s = Math.floor(secs % 60); + return m + ":" + (s < 10 ? "0" : "") + s; + } +} diff --git a/dot_config/quickshell/bar/popouts/NotificationCenter.qml b/dot_config/quickshell/bar/popouts/NotificationCenter.qml new file mode 100644 index 0000000..604515e --- /dev/null +++ b/dot_config/quickshell/bar/popouts/NotificationCenter.qml @@ -0,0 +1,352 @@ +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import "../../shared" as Shared + +Item { + id: root + + required property var trackedNotifications + required property var daemon + + function relativeTime(id) { + let ts = root.daemon?.timestamps?.[id]; + if (!ts) return ""; + let diff = Math.floor((Date.now() - ts) / 1000); + if (diff < 60) return "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: Shared.Theme.popoutWidth + implicitHeight: Math.min(600, col.implicitHeight + Shared.Theme.popoutPadding * 2) + + PopoutBackground { anchors.fill: parent } + MouseArea { anchors.fill: parent } + + // Drives relative timestamp re-evaluation + Timer { id: tsRefresh; property int tick: 0; interval: 30000; running: true; repeat: true; onTriggered: tick++ } + + // DnD auto-off timer + Timer { + id: dndTimer + property int remaining: 0 // seconds, 0 = indefinite + interval: 1000 + running: false + repeat: true + onTriggered: { + remaining--; + if (remaining <= 0) { + running = false; + if (root.daemon) root.daemon.dnd = false; + } + } + } + + ColumnLayout { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Shared.Theme.popoutPadding + spacing: 8 + + // Header + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: "Notifications" + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontLarge + font.family: Shared.Theme.fontFamily + font.bold: true + Layout.fillWidth: true + } + + // DnD toggle + Rectangle { + implicitWidth: dndRow.implicitWidth + 12 + implicitHeight: 24 + radius: 12 + color: root.daemon?.dnd ? Qt.alpha(Shared.Theme.danger, Shared.Theme.opacityLight) : (dndMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0) + border.width: root.daemon?.dnd ? 1 : 0 + border.color: Qt.alpha(Shared.Theme.danger, Shared.Theme.opacityMedium) + Behavior on color { ColorAnimation { duration: 100 } } + + RowLayout { + id: dndRow + anchors.centerIn: parent + spacing: 4 + Text { text: root.daemon?.dnd ? "\u{f009b}" : "\u{f009a}"; color: root.daemon?.dnd ? Shared.Theme.danger : Shared.Theme.overlay0; font.pixelSize: 12; font.family: Shared.Theme.iconFont } + Text { text: "DnD"; color: root.daemon?.dnd ? Shared.Theme.danger : Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSmall; font.family: Shared.Theme.fontFamily } + } + + MouseArea { + id: dndMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { if (root.daemon) root.daemon.dnd = !root.daemon.dnd; } + } + } + + // Clear all + Rectangle { + visible: root.trackedNotifications.values.length > 0 + implicitWidth: clearRow.implicitWidth + 12 + implicitHeight: 24 + radius: 12 + color: clearMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0 + Behavior on color { ColorAnimation { duration: 100 } } + + RowLayout { + id: clearRow + anchors.centerIn: parent + spacing: 4 + Text { text: "Clear all"; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSmall; font.family: Shared.Theme.fontFamily } + } + + MouseArea { + id: clearMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + let tracked = root.trackedNotifications; + for (let i = tracked.values.length - 1; i >= 0; i--) + tracked.values[i].dismiss(); + } + } + } + } + + // DnD schedule (visible when DnD is active) + Loader { + Layout.fillWidth: true + active: root.daemon?.dnd === true + visible: active + sourceComponent: RowLayout { + spacing: 4 + Repeater { + model: [ + { label: "30m", mins: 30 }, + { label: "1h", mins: 60 }, + { label: "2h", mins: 120 }, + { label: "\u{f0026}", mins: 0 } // infinity = until manual off + ] + + Rectangle { + required property var modelData + required property int index + readonly property bool isActive: { + if (modelData.mins === 0) return dndTimer.remaining <= 0; + return dndTimer.remaining > 0 && dndTimer.remaining <= modelData.mins * 60; + } + Layout.fillWidth: true + implicitHeight: 22 + radius: 11 + color: isActive ? Qt.alpha(Shared.Theme.danger, Shared.Theme.opacityLight) : (schedMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0) + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: parent.modelData.label + color: parent.isActive ? Shared.Theme.danger : Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + + MouseArea { + id: schedMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (parent.modelData.mins === 0) { + dndTimer.remaining = 0; // indefinite + } else { + dndTimer.remaining = parent.modelData.mins * 60; + dndTimer.running = true; + } + } + } + } + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0 } + + // Empty state + Text { + visible: root.trackedNotifications.values.length === 0 + text: "No notifications" + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 20 + Layout.bottomMargin: 20 + } + + // Notification list + Flickable { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(480, notifList.implicitHeight) + contentHeight: notifList.implicitHeight + clip: true + visible: root.trackedNotifications.values.length > 0 + + ColumnLayout { + id: notifList + width: parent.width + spacing: 6 + + Repeater { + model: root.trackedNotifications + + Rectangle { + id: notifCard + required property var modelData + + Layout.fillWidth: true + implicitHeight: notifContent.implicitHeight + 16 + radius: Shared.Theme.radiusSmall + color: notifMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0 + border.width: modelData.urgency === NotificationUrgency.Critical ? 1 : 0 + border.color: Shared.Theme.danger + + Behavior on color { ColorAnimation { duration: 100 } } + + MouseArea { + id: notifMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + let actions = notifCard.modelData.actions; + if (actions.length > 0) + actions[0].invoke(); + else + notifCard.modelData.dismiss(); + } + } + + ColumnLayout { + id: notifContent + anchors.fill: parent + anchors.margins: 8 + spacing: 4 + + // App name + close + RowLayout { + Layout.fillWidth: true + spacing: 6 + + IconImage { + source: notifCard.modelData.appIcon + implicitSize: 14 + visible: notifCard.modelData.appIcon !== "" + } + + Text { + text: notifCard.modelData.appName || "App" + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + Layout.fillWidth: true + elide: Text.ElideRight + } + + Text { + property int tick: tsRefresh.tick + text: root.relativeTime(notifCard.modelData.id) + color: Shared.Theme.surface2 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + visible: text !== "" + } + + Text { + text: "\u{f0156}" + color: dismissMouse.containsMouse ? Shared.Theme.danger : Shared.Theme.overlay0 + font.pixelSize: 12 + font.family: Shared.Theme.iconFont + MouseArea { + id: dismissMouse + anchors.fill: parent + hoverEnabled: true + onClicked: notifCard.modelData.dismiss() + } + } + } + + // Summary + Text { + visible: text !== "" + text: notifCard.modelData.summary + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + font.bold: true + Layout.fillWidth: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + + // Body + Text { + visible: text !== "" + text: notifCard.modelData.body + textFormat: Text.PlainText + color: Shared.Theme.subtext0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + Layout.fillWidth: true + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + } + + // Actions + RowLayout { + visible: notifCard.modelData.actions.length > 0 + Layout.fillWidth: true + spacing: 4 + + Repeater { + model: notifCard.modelData.actions + + Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: 24 + radius: 4 + color: actMouse.containsMouse ? Shared.Theme.surface2 : Shared.Theme.surface1 + + Text { + anchors.centerIn: parent + text: modelData.text + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + + MouseArea { + id: actMouse + anchors.fill: parent + hoverEnabled: true + onClicked: modelData.invoke() + } + } + } + } + } + } + } + } + } + } +} diff --git a/dot_config/quickshell/bar/popouts/PopoutBackground.qml b/dot_config/quickshell/bar/popouts/PopoutBackground.qml new file mode 100644 index 0000000..e578940 --- /dev/null +++ b/dot_config/quickshell/bar/popouts/PopoutBackground.qml @@ -0,0 +1,19 @@ +import QtQuick +import "../../shared" as Shared + +// Rounded left corners, square right (flush against bar) +Item { + anchors.fill: parent + clip: true + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width + 22 + radius: 22 + color: Shared.Theme.popoutBackground + border.width: 1 + border.color: Shared.Theme.borderSubtle + } +} diff --git a/dot_config/quickshell/bar/popouts/SystemPopout.qml b/dot_config/quickshell/bar/popouts/SystemPopout.qml new file mode 100644 index 0000000..a0ab9d0 --- /dev/null +++ b/dot_config/quickshell/bar/popouts/SystemPopout.qml @@ -0,0 +1,840 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import QtQuick.Window +import "../../shared" as Shared + +Item { + id: root + + implicitWidth: Shared.Theme.popoutWidth + implicitHeight: Math.min(flickable.contentHeight + Shared.Theme.popoutPadding * 2, Screen.height - 32) + + PopoutBackground { anchors.fill: parent } + + // --- State --- + readonly property int historySize: 30 + property var cpuHistory: [] + property var memHistory: [] + property var gpuHistory: [] + + function pushHistory(arr, val) { + let a = arr.slice(); + a.push(val); + if (a.length > historySize) a.shift(); + return a; + } + + property real cpuVal: 0 + property string memText: "--" + property real memVal: 0 + property string tempText: "--" + property int tempVal: 0 + property string gpuText: "--" + property real gpuUsage: 0 + property int gpuTemp: 0 + property string nvmeTempText: "--" + property int nvmeTempVal: 0 + property string diskRootText: "--" + property real diskRootVal: 0 + property string diskDataText: "--" + property real diskDataVal: 0 + property string updatesText: "" + property string updatesClass: "" + property string alhpText: "" + property string alhpClass: "" + property string networkIp: "--" + property string networkIface: "--" + property bool idleActive: false + property var panelWindow: null + property string audioDrawer: "" // "" = closed, "sink" or "source" + + function thresholdColor(val, warn, crit) { + if (val >= crit) return Shared.Theme.danger; + if (val >= warn) return Shared.Theme.warning; + return Shared.Theme.success; + } + + function tempColor(t) { + if (t >= 85) return Shared.Theme.danger; + if (t >= 70) return Shared.Theme.warning; + return Shared.Theme.success; + } + + MouseArea { anchors.fill: parent } + + Flickable { + id: flickable + anchors.fill: parent + anchors.margins: Shared.Theme.popoutPadding + contentHeight: col.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: col + width: flickable.width + spacing: 6 + + // ─── UPDATES ───────────────────────── + + Loader { + Layout.fillWidth: true + active: root.updatesClass === "pending" || root.updatesClass === "many" + visible: active + sourceComponent: Rectangle { + implicitHeight: updRow.implicitHeight + 14 + radius: Shared.Theme.radiusSmall + color: Shared.Theme.surface0 + + RowLayout { + id: updRow + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + Text { text: "\u{f0ab7}"; color: Shared.Theme.warning; font.pixelSize: 14; font.family: Shared.Theme.iconFont } + Text { text: root.updatesText + " updates available"; color: Shared.Theme.text; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily; Layout.fillWidth: true } + Text { visible: root.alhpText !== ""; text: "ALHP " + root.alhpText; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily } + } + } + } + + Loader { + Layout.fillWidth: true + active: root.updatesClass !== "pending" && root.updatesClass !== "many" && root.updatesClass !== "" + visible: active + sourceComponent: RowLayout { + spacing: 10 + Text { text: "\u{f05e0}"; color: Shared.Theme.success; font.pixelSize: 14; font.family: Shared.Theme.iconFont } + Text { text: "System up to date"; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily } + } + } + + // ─── separator ─── + Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 } + + // ─── STORAGE & PERFORMANCE ─────────── + + MetricBar { label: Shared.Config.diskMount1Label; value: root.diskRootText; fill: root.diskRootVal; barColor: thresholdColor(root.diskRootVal * 100, 70, 90); valueColor: thresholdColor(root.diskRootVal * 100, 70, 90) } + MetricBar { label: Shared.Config.diskMount2Label; value: root.diskDataText; fill: root.diskDataVal; barColor: thresholdColor(root.diskDataVal * 100, 70, 90); valueColor: thresholdColor(root.diskDataVal * 100, 70, 90) } + + Item { implicitHeight: 2 } + + MetricBar { + label: "CPU" + value: root.cpuVal.toFixed(0) + "%" + fill: root.cpuVal / 100 + barColor: thresholdColor(root.cpuVal, 50, 80) + valueColor: thresholdColor(root.cpuVal, 50, 80) + suffix: root.tempText + suffixColor: tempColor(root.tempVal) + history: root.cpuHistory + } + + MetricBar { + label: "Mem" + value: root.memText + fill: root.memVal + barColor: thresholdColor(root.memVal * 100, 60, 85) + valueColor: thresholdColor(root.memVal * 100, 60, 85) + history: root.memHistory + } + + MetricBar { + label: "GPU" + value: root.gpuText + fill: root.gpuUsage / 100 + barColor: thresholdColor(root.gpuUsage, 50, 80) + valueColor: thresholdColor(root.gpuUsage, 50, 80) + history: root.gpuHistory + } + + MetricBar { + label: "NVMe" + value: root.nvmeTempText + fill: root.nvmeTempVal / 100 + barColor: tempColor(root.nvmeTempVal) + valueColor: tempColor(root.nvmeTempVal) + } + + // ─── separator ─── + Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 } + + // ─── AUDIO ─────────────────────────── + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + VolumeSlider { + Layout.fillWidth: true + audio: Pipewire.defaultAudioSink?.audio ?? null + icon: (!audio || audio.muted) ? "\u{f057f}" : "\u{f057e}" + label: "Out" + accentColor: Shared.Theme.sky + drawerActive: root.audioDrawer === "sink" + onDrawerToggled: root.audioDrawer = root.audioDrawer === "sink" ? "" : "sink" + } + + VolumeSlider { + Layout.fillWidth: true + audio: Pipewire.defaultAudioSource?.audio ?? null + icon: (!audio || audio.muted) ? "\u{f036d}" : "\u{f036c}" + label: "Mic" + accentColor: Shared.Theme.pink + drawerActive: root.audioDrawer === "source" + onDrawerToggled: root.audioDrawer = root.audioDrawer === "source" ? "" : "source" + } + } + + // Device drawer + Loader { + Layout.fillWidth: true + active: root.audioDrawer !== "" + visible: active + sourceComponent: ColumnLayout { + spacing: 3 + + Repeater { + model: Pipewire.nodes + + Rectangle { + id: devItem + required property var modelData + + readonly property bool isSinkDrawer: root.audioDrawer === "sink" + readonly property bool matchesFilter: modelData.audio && !modelData.isStream + && modelData.isSink === isSinkDrawer + readonly property bool isDefault: isSinkDrawer + ? Pipewire.defaultAudioSink === modelData + : Pipewire.defaultAudioSource === modelData + + visible: matchesFilter + Layout.fillWidth: true + implicitHeight: matchesFilter ? 28 : 0 + radius: Shared.Theme.radiusSmall + color: devMouse.containsMouse ? Shared.Theme.surface1 : (isDefault ? Shared.Theme.surface0 : "transparent") + + Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + + Text { + text: devItem.isDefault ? "\u{f012c}" : "\u{f0765}" + color: devItem.isDefault ? (devItem.isSinkDrawer ? Shared.Theme.sky : Shared.Theme.pink) : Shared.Theme.overlay0 + font.pixelSize: 12 + font.family: Shared.Theme.iconFont + } + Text { + text: devItem.modelData.description || devItem.modelData.name + color: devItem.isDefault ? Shared.Theme.text : Shared.Theme.subtext0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + font.bold: devItem.isDefault + Layout.fillWidth: true + elide: Text.ElideRight + } + } + + MouseArea { + id: devMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (devItem.isSinkDrawer) + Pipewire.preferredDefaultAudioSink = devItem.modelData; + else + Pipewire.preferredDefaultAudioSource = devItem.modelData; + root.audioDrawer = ""; + } + } + } + } + } + } + + // ─── separator ─── + Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 } + + // ─── NETWORK ───────────────────────── + + RowLayout { + Layout.fillWidth: true + spacing: 10 + Text { text: "\u{f0bf0}"; color: Shared.Theme.overlay0; font.pixelSize: 14; font.family: Shared.Theme.iconFont } + Text { text: root.networkIface; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily } + Item { Layout.fillWidth: true } + Text { text: root.networkIp; color: Shared.Theme.text; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily; font.bold: true } + } + + // ─── separator ─── + Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 } + + // ─── SYSTRAY ───────────────────────── + + Flow { + Layout.fillWidth: true + spacing: 6 + + Repeater { + model: SystemTray.items + + Rectangle { + id: trayItem + required property var modelData + + visible: modelData.status !== Status.Passive + width: 32 + height: 32 + radius: Shared.Theme.radiusSmall + color: trayMouse.containsMouse ? Shared.Theme.surface1 : "transparent" + + Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } } + + IconImage { + anchors.centerIn: parent + source: trayItem.modelData.icon + implicitSize: 22 + } + + // Tooltip — positioned above icon, clamped to popout width + Rectangle { + id: tooltip + visible: trayMouse.containsMouse && tipText.text !== "" + width: Math.min(tipText.implicitWidth + 12, Shared.Theme.popoutWidth - Shared.Theme.popoutPadding * 2) + height: tipText.implicitHeight + 8 + radius: 6 + color: Shared.Theme.surface0 + z: 10 + + // Position above the icon, clamp horizontally within the popout + y: -height - 4 + x: { + let centered = (trayItem.width - width) / 2; + let globalX = trayItem.mapToItem(col, centered, 0).x; + let maxX = col.width - width; + let clampedGlobalX = Math.max(0, Math.min(globalX, maxX)); + return centered + (clampedGlobalX - globalX); + } + + Text { + id: tipText + anchors.centerIn: parent + width: parent.width - 12 + text: trayItem.modelData.tooltipTitle || trayItem.modelData.title || "" + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + MouseArea { + id: trayMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onClicked: event => { + let item = trayItem.modelData; + if (event.button === Qt.RightButton || item.onlyMenu) { + if (item.hasMenu && root.panelWindow) { + let pos = trayItem.mapToItem(null, trayItem.width / 2, trayItem.height / 2); + item.display(root.panelWindow, pos.x, pos.y); + } + } else if (event.button === Qt.MiddleButton) { + item.secondaryActivate(); + } else { + item.activate(); + } + } + + onWheel: event => { + trayItem.modelData.scroll(event.angleDelta.y, false); + } + } + } + } + } + + // ─── separator ─── + Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 6; Layout.bottomMargin: 6 } + + // ─── ACTIONS ───────────────────────── + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + ActionIcon { + icon: "\u{f1436}" + label: "Idle" + iconColor: root.idleActive ? Shared.Theme.green : Shared.Theme.overlay0 + onActivated: { + if (root.idleActive) idleKill.running = true; + else idleStart.running = true; + root.idleActive = !root.idleActive; + } + } + + ActionIcon { + icon: "\u{f033e}" + label: "Lock" + iconColor: Shared.Theme.subtext0 + onActivated: { Shared.PopoutState.close(); lockProc.running = true; } + } + + Item { Layout.fillWidth: true } + + HoldAction { + icon: "\u{f0425}" + label: "Logout" + iconColor: Shared.Theme.subtext0 + holdColor: Shared.Theme.green + onConfirmed: { Shared.PopoutState.close(); logoutProc.running = true; } + } + + HoldAction { + icon: "\u{f0709}" + label: "Reboot" + iconColor: Shared.Theme.peach + holdColor: Shared.Theme.peach + onConfirmed: { Shared.PopoutState.close(); rebootProc.running = true; } + } + + HoldAction { + icon: "\u{f0425}" + label: "Off" + iconColor: Shared.Theme.danger + holdColor: Shared.Theme.danger + onConfirmed: { Shared.PopoutState.close(); offProc.running = true; } + } + } + } // ColumnLayout + } // Flickable + + // ═══════════════════════════════════════ + // COMPONENTS + // ═══════════════════════════════════════ + + component Sparkline: Canvas { + id: spark + property var history: [] + property color lineColor: Shared.Theme.overlay0 + + onHistoryChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + onPaint: { + let ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + if (history.length < 2) return; + ctx.strokeStyle = Qt.alpha(lineColor, 0.5); + ctx.lineWidth = 1; + ctx.beginPath(); + let step = width / (root.historySize - 1); + let offset = root.historySize - history.length; + for (let i = 0; i < history.length; i++) { + let x = (offset + i) * step; + let y = height - history[i] * height; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + } + } + + component MetricBar: RowLayout { + property string label + property string value + property real fill: 0 + property color barColor: Shared.Theme.green + property color valueColor: Shared.Theme.text + property string suffix: "" + property color suffixColor: Shared.Theme.overlay0 + property var history: null + + Layout.fillWidth: true + implicitHeight: 22 + spacing: 10 + + Text { + text: parent.label + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + Layout.preferredWidth: 40 + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 10 + radius: 5 + color: Shared.Theme.surface0 + + Sparkline { + anchors.fill: parent + visible: history && history.length > 1 + history: parent.parent.history ?? [] + lineColor: barColor + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * Math.min(1, Math.max(0, fill)) + radius: 5 + color: barColor + opacity: Shared.Theme.opacityFill + Behavior on width { NumberAnimation { duration: Shared.Theme.animFast; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: Shared.Theme.animNormal } } + } + } + + Text { + text: parent.value + color: parent.valueColor + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + font.bold: true + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 86 + } + + Loader { + active: parent.suffix !== "" + visible: active + sourceComponent: Text { + text: suffix + color: suffixColor + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + font.bold: true + } + } + } + + component VolumeSlider: Rectangle { + id: vs + property var audio: null + property string icon + property string label + property color accentColor + property bool drawerActive: false + signal drawerToggled() + + property real vol: 0 + property bool muted: audio?.muted ?? false + + function updateVol() { + if (audio && !isNaN(audio.volume) && audio.volume >= 0) + vol = audio.volume; + } + onAudioChanged: { updateVol(); bindRetry.retries = 0; bindRetry.running = true; } + Connections { + target: vs.audio + function onVolumeChanged() { vs.updateVol(); } + } + + // Retry binding pickup after async PwObjectTracker re-bind + Timer { + id: bindRetry + interval: 300 + running: false + repeat: true + property int retries: 0 + onTriggered: { + vs.updateVol(); + retries++; + if (vs.vol > 0 || retries >= 5) running = false; + } + } + + Layout.fillWidth: true + implicitHeight: 36 + radius: Shared.Theme.radiusSmall + color: drawerActive ? Qt.alpha(accentColor, Shared.Theme.opacityLight) : Shared.Theme.surface0 + border.width: drawerActive ? 1 : 0 + border.color: Qt.alpha(accentColor, Shared.Theme.opacityMedium) + opacity: muted ? 0.45 : 1.0 + clip: true + + Behavior on opacity { NumberAnimation { duration: Shared.Theme.animFast } } + Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: vs.muted ? 0 : parent.width * Math.min(1, vs.vol) + radius: Shared.Theme.radiusSmall + color: Qt.alpha(vs.accentColor, Shared.Theme.opacityMedium) + Behavior on width { NumberAnimation { duration: Shared.Theme.animFast; easing.type: Easing.OutCubic } } + } + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + Text { text: vs.icon; color: vs.accentColor; font.pixelSize: 14; font.family: Shared.Theme.iconFont } + Text { text: vs.label; color: vs.accentColor; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily } + Item { Layout.fillWidth: true } + Text { + text: vs.muted ? "Muted" : Math.round(vs.vol * 100) + "%" + color: vs.muted ? Shared.Theme.overlay0 : Shared.Theme.text + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + font.bold: !vs.muted + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + property bool dragging: false + property real startX: 0 + property int pressedBtn: Qt.NoButton + onClicked: event => { + if (event.button === Qt.MiddleButton) { pavuProc.running = true; return; } + if (event.button === Qt.RightButton) { if (vs.audio) vs.audio.muted = !vs.audio.muted; return; } + if (!dragging) vs.drawerToggled(); + } + onPressed: event => { dragging = false; startX = event.x; pressedBtn = event.button; } + onPositionChanged: event => { + if (pressed && pressedBtn === Qt.LeftButton) { + if (Math.abs(event.x - startX) > 5) dragging = true; + if (dragging && vs.audio) vs.audio.volume = Math.max(0, Math.min(1, event.x / vs.width)); + } + } + onWheel: event => { + if (!vs.audio) return; + let step = 0.05; + if (event.angleDelta.y > 0) vs.audio.volume = Math.min(1.0, vs.vol + step); + else vs.audio.volume = Math.max(0.0, vs.vol - step); + } + } + } + + component ActionIcon: Rectangle { + property string icon + property string label + property color iconColor + signal activated() + + implicitWidth: actCol.implicitWidth + 16 + implicitHeight: actCol.implicitHeight + 14 + radius: Shared.Theme.radiusSmall + color: actMouse.containsMouse ? Shared.Theme.surface1 : "transparent" + border.width: actMouse.containsMouse ? 1 : 0 + border.color: Shared.Theme.surface2 + + Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } } + + Column { + id: actCol + anchors.centerIn: parent + spacing: 3 + Text { anchors.horizontalCenter: parent.horizontalCenter; text: icon; color: iconColor; font.pixelSize: 18; font.family: Shared.Theme.iconFont } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: label; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSmall; font.family: Shared.Theme.fontFamily } + } + + MouseArea { id: actMouse; anchors.fill: parent; hoverEnabled: true; onClicked: parent.activated() } + } + + component HoldAction: Rectangle { + id: ha + property string icon + property string label + property color iconColor + property color holdColor: Shared.Theme.red + signal confirmed() + + readonly property real holdDuration: 800 + property real holdProgress: 0 + property bool holding: false + + implicitWidth: haCol.implicitWidth + 16 + implicitHeight: haCol.implicitHeight + 14 + radius: Shared.Theme.radiusSmall + color: haMouse.containsMouse ? Shared.Theme.surface1 : "transparent" + border.width: haMouse.containsMouse || holding ? 1 : 0 + border.color: holding ? Qt.alpha(holdColor, Shared.Theme.opacityStrong) : Shared.Theme.surface2 + + Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } } + + // Fill overlay + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * ha.holdProgress + radius: Shared.Theme.radiusSmall + color: Qt.alpha(ha.holdColor, Shared.Theme.opacityLight) + } + + Column { + id: haCol + anchors.centerIn: parent + spacing: 3 + Text { anchors.horizontalCenter: parent.horizontalCenter; text: ha.icon; color: ha.holding ? ha.holdColor : ha.iconColor; font.pixelSize: 18; font.family: Shared.Theme.iconFont } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: ha.holding ? "Hold…" : ha.label; color: ha.holding ? ha.holdColor : Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSmall; font.family: Shared.Theme.fontFamily } + } + + Timer { + id: holdTimer + interval: 16 + running: ha.holding + repeat: true + onTriggered: { + ha.holdProgress += interval / ha.holdDuration; + if (ha.holdProgress >= 1.0) { + ha.holding = false; + ha.holdProgress = 0; + ha.confirmed(); + } + } + } + + MouseArea { + id: haMouse + anchors.fill: parent + hoverEnabled: true + onPressed: { ha.holding = true; ha.holdProgress = 0; } + onReleased: { ha.holding = false; ha.holdProgress = 0; } + onCanceled: { ha.holding = false; ha.holdProgress = 0; } + } + } + + // ═══════════════════════════════════════ + // DATA FETCHING + // ═══════════════════════════════════════ + + property real prevCpuActive: -1 + property real prevCpuTotal: -1 + Process { id: cpuProc; command: ["sh", "-c", "awk '/^cpu / {print $2+$4, $2+$4+$5}' /proc/stat"]; stdout: StdioCollector { + onStreamFinished: { + let p = this.text.trim().split(" "); + let active = parseFloat(p[0]), total = parseFloat(p[1]); + if (!isNaN(active) && root.prevCpuTotal > 0) { + let da = active - root.prevCpuActive; + let dt = total - root.prevCpuTotal; + if (dt > 0) { root.cpuVal = (da / dt) * 100; root.cpuHistory = root.pushHistory(root.cpuHistory, root.cpuVal / 100); } + } + root.prevCpuActive = active; + root.prevCpuTotal = total; + } + }} + + Process { id: memProc; command: ["sh", "-c", "free -m | awk '/Mem:/ {printf \"%.1f/%.0fG %.4f\", $3/1024, $2/1024, $3/$2}'"]; stdout: StdioCollector { + onStreamFinished: { let p = this.text.trim().split(" "); if (p.length >= 2) { root.memText = p[0]; root.memVal = parseFloat(p[1]) || 0; root.memHistory = root.pushHistory(root.memHistory, root.memVal); } } + }} + + Process { id: tempProc; command: ["sh", "-c", "sensors 2>/dev/null | awk '/Tctl:|Tdie:|Package id 0:/ {gsub(/\\+|°C/,\"\",$2); printf \"%d\", $2; exit}'"]; stdout: StdioCollector { + onStreamFinished: { let v = parseInt(this.text.trim()); if (!isNaN(v)) { root.tempVal = v; root.tempText = v + "°C"; } } + }} + + Process { id: gpuProc; command: [Shared.Config.gpuScript]; stdout: StdioCollector { + onStreamFinished: { try { + let d = JSON.parse(this.text); + let tt = d.tooltip || ""; + let lines = tt.split("\n"); + let usage = 0, temp = 0, power = 0; + for (let i = 0; i < lines.length; i++) { + let v = parseInt(lines[i].replace(/[^0-9]/g, "")); + if (isNaN(v)) continue; + if (lines[i].indexOf("Usage") >= 0) usage = v; + else if (lines[i].indexOf("Temp") >= 0) temp = v; + else if (lines[i].indexOf("Power") >= 0) power = v; + } + root.gpuUsage = usage; root.gpuTemp = temp; + root.gpuText = usage + "% " + temp + "°C " + power + "W"; + root.gpuHistory = root.pushHistory(root.gpuHistory, usage / 100); + } catch(e) {} } + }} + + Process { id: nvmeTempProc; command: ["sh", "-c", "for d in /sys/class/hwmon/hwmon*; do if grep -q nvme \"$d/name\" 2>/dev/null; then awk '{printf \"%d\", $1/1000}' \"$d/temp1_input\" 2>/dev/null; break; fi; done"]; stdout: StdioCollector { + onStreamFinished: { let v = parseInt(this.text.trim()); if (!isNaN(v) && v > 0) { root.nvmeTempVal = v; root.nvmeTempText = v + "°C"; } } + }} + + Process { id: diskProc; command: ["sh", "-c", + "df -h " + Shared.Config.diskMount1 + " --output=pcent,size 2>/dev/null | tail -1 && " + + "df -h " + Shared.Config.diskMount2 + " --output=pcent,size 2>/dev/null | tail -1 && " + + "df " + Shared.Config.diskMount1 + " --output=pcent 2>/dev/null | tail -1 && " + + "df " + Shared.Config.diskMount2 + " --output=pcent 2>/dev/null | tail -1" + ]; stdout: StdioCollector { + onStreamFinished: { + let lines = this.text.trim().split("\n"); + if (lines.length >= 1) root.diskRootText = lines[0].trim().replace(/\s+/g, " of "); + if (lines.length >= 2) root.diskDataText = lines[1].trim().replace(/\s+/g, " of "); + if (lines.length >= 3) root.diskRootVal = parseInt(lines[2]) / 100 || 0; + if (lines.length >= 4) root.diskDataVal = parseInt(lines[3]) / 100 || 0; + } + }} + + Process { id: updateProc; command: ["bash", "-c", "set -o pipefail; checkupdates 2>/dev/null | wc -l; echo \":$?\""]; stdout: StdioCollector { + onStreamFinished: { + let text = this.text.trim(); + let exitMatch = text.match(/:(\d+)$/); + let exit = exitMatch ? parseInt(exitMatch[1]) : 1; + let n = parseInt(text); + // checkupdates: 0 = updates available, 2 = no updates, anything else = error + if (exit !== 0 && exit !== 2) return; // error — keep previous state + if (isNaN(n) || n === 0) { root.updatesText = ""; root.updatesClass = "uptodate"; } + else if (n > 50) { root.updatesText = n.toString(); root.updatesClass = "many"; } + else { root.updatesText = n.toString(); root.updatesClass = "pending"; } + } + }} + + Process { id: alhpProc; command: ["sh", "-c", "alhp.utils -j 2>/dev/null || echo '{}'"]; stdout: StdioCollector { + onStreamFinished: { + try { + let d = JSON.parse(this.text); + let total = d.total || 0; + let stale = d.mirror_out_of_date || false; + if (stale) { root.alhpText = "stale"; root.alhpClass = "stale"; } + else if (total > 0) { root.alhpText = total.toString(); root.alhpClass = "bad"; } + else { root.alhpText = ""; root.alhpClass = "good"; } + } catch(e) { root.alhpText = "?"; root.alhpClass = "down"; } + } + }} + + Process { id: netProc; command: ["sh", "-c", "ip -4 -o addr show | grep -v '" + Shared.Config.netExcludePattern + "' | head -1 | awk '{split($4,a,\"/\"); print $2 \":\" a[1]}'"]; stdout: StdioCollector { + onStreamFinished: { let l = this.text.trim(); if (l) { let p = l.split(":"); root.networkIface = p[0] || "--"; root.networkIp = p[1] || "--"; } else { root.networkIface = "Network"; root.networkIp = "Offline"; } } + }} + + Process { id: pavuProc; command: ["pavucontrol"] } + Process { id: idleProc; command: ["pgrep", "-x", Shared.Config.idleProcess]; stdout: StdioCollector { onStreamFinished: root.idleActive = this.text.trim().length > 0 } } + Process { id: idleKill; command: ["killall", Shared.Config.idleProcess] } + Process { id: idleStart; command: ["sh", "-c", "setsid " + Shared.Config.idleProcess + " > /dev/null 2>&1 &"] } + Process { id: lockProc; command: [Shared.Config.lockCommand] } + Process { id: logoutProc; command: Shared.Config.powerActions[1].command } + Process { id: rebootProc; command: Shared.Config.powerActions[2].command } + Process { id: offProc; command: Shared.Config.powerActions[3].command } + + function rerun(proc) { proc.running = false; proc.running = true; } + Timer { interval: 5000; running: true; repeat: true; onTriggered: { rerun(cpuProc); rerun(memProc); rerun(tempProc); rerun(gpuProc); rerun(nvmeTempProc); rerun(idleProc); } } + Timer { interval: 30000; running: true; repeat: true; onTriggered: { rerun(diskProc); rerun(netProc); } } + Timer { interval: 300000; running: true; repeat: true; onTriggered: { rerun(updateProc); rerun(alhpProc); } } + + // Stagger initial launches to avoid 9 concurrent process spawns + Component.onCompleted: { + rerun(cpuProc); rerun(memProc); rerun(tempProc); + staggerTimer.running = true; + } + Timer { + id: staggerTimer + interval: 200 + onTriggered: { rerun(gpuProc); rerun(nvmeTempProc); rerun(idleProc); rerun(diskProc); rerun(netProc); rerun(updateProc); rerun(alhpProc); } + } +} diff --git a/dot_config/quickshell/bar/popouts/WeatherPopout.qml b/dot_config/quickshell/bar/popouts/WeatherPopout.qml new file mode 100644 index 0000000..2b24937 --- /dev/null +++ b/dot_config/quickshell/bar/popouts/WeatherPopout.qml @@ -0,0 +1,153 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import "../../shared" as Shared + +Item { + id: root + + implicitWidth: Shared.Theme.popoutWidth + implicitHeight: col.implicitHeight + Shared.Theme.popoutPadding * 2 + + PopoutBackground { anchors.fill: parent } + MouseArea { anchors.fill: parent } + + ColumnLayout { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Shared.Theme.popoutPadding + spacing: 6 + + // ─── Header ─── + RowLayout { + Layout.fillWidth: true + spacing: 8 + Text { + text: Shared.Weather.location + color: Shared.Theme.text + font.pixelSize: 18 + font.family: Shared.Theme.fontFamily + font.bold: true + Layout.fillWidth: true + } + Text { + text: Shared.Weather.icon + color: Shared.Theme.peach + font.pixelSize: 24 + font.family: Shared.Theme.iconFont + } + Text { + text: Shared.Weather.temp + color: Shared.Theme.peach + font.pixelSize: 18 + font.family: Shared.Theme.fontFamily + font.bold: true + } + } + + Text { + text: { + if (Shared.Weather.status === "error") return "Unable to fetch weather data"; + if (Shared.Weather.status === "stale") return Shared.Weather.description + " · stale"; + if (Shared.Weather.status === "loading") return "Loading…"; + return Shared.Weather.description; + } + color: Shared.Weather.status === "error" ? Shared.Theme.danger + : Shared.Weather.status === "stale" ? Shared.Theme.warning + : Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + + // ─── separator ─── + Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 } + + // ─── Conditions ─── + ConditionRow { label: "Feels like"; value: Shared.Weather.feelsLike } + ConditionRow { label: "Humidity"; value: Shared.Weather.humidity } + ConditionRow { label: "Wind"; value: Shared.Weather.wind } + + // ─── separator ─── + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + Layout.topMargin: 2 + Layout.bottomMargin: 6 + height: 1 + color: Shared.Theme.surface0 + } + + // ─── Forecast ─── + Text { + text: "Forecast" + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + font.bold: true + Layout.bottomMargin: 2 + } + + Repeater { + model: Shared.Weather.forecast + + RowLayout { + required property var modelData + Layout.fillWidth: true + implicitHeight: 22 + spacing: 8 + + Text { + text: modelData.day + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + Layout.preferredWidth: 36 + } + Text { + text: Shared.Weather.weatherIcon(modelData.code) + color: Shared.Theme.peach + font.pixelSize: 14 + font.family: Shared.Theme.iconFont + } + Text { + text: modelData.desc + color: Shared.Theme.surface2 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + Layout.fillWidth: true + elide: Text.ElideRight + } + Text { + text: modelData.low + "°" + color: Shared.Theme.sky + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 28 + } + Text { + text: modelData.high + "°" + color: Shared.Theme.peach + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + font.bold: true + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 28 + } + } + } + } + + component ConditionRow: RowLayout { + property string label + property string value + Layout.fillWidth: true + implicitHeight: 22 + spacing: 10 + Text { text: parent.label; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily; Layout.fillWidth: true } + Text { text: parent.value; color: Shared.Theme.text; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily; font.bold: true } + } +} diff --git a/dot_config/quickshell/launcher/.keep b/dot_config/quickshell/launcher/.keep new file mode 100644 index 0000000..e69de29 diff --git a/dot_config/quickshell/lock/.keep b/dot_config/quickshell/lock/.keep new file mode 100644 index 0000000..e69de29 diff --git a/dot_config/quickshell/lock/IdleScreen.qml b/dot_config/quickshell/lock/IdleScreen.qml new file mode 100644 index 0000000..48bb20f --- /dev/null +++ b/dot_config/quickshell/lock/IdleScreen.qml @@ -0,0 +1,104 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +// Custom apex-neon idle screen +// Activated by hypridle via: quickshell -c ~/.config/quickshell/lock/ +// Dismissed by any key/mouse input → triggers hyprlock +Scope { + id: root + + property bool active: false + + Variants { + model: Quickshell.screens + + delegate: Component { + PanelWindow { + required property var modelData + screen: modelData + + visible: root.active && modelData.name === Shared.Config.monitor + WlrLayershell.namespace: "quickshell:idle" + WlrLayershell.layer: WlrLayer.Overlay + surfaceFormat { opaque: true } + focusable: true + + anchors { + top: true + bottom: true + left: true + right: true + } + + // Dismiss on any input + Keys.onPressed: root.dismiss() + MouseArea { + anchors.fill: parent + hoverEnabled: false + onClicked: root.dismiss() + } + + // Full black backdrop + Rectangle { + anchors.fill: parent + color: "#050505" + + ColumnLayout { + anchors.centerIn: parent + spacing: 8 + + // Large clock — apex-neon cyan, breathing opacity + Text { + id: clockText + Layout.alignment: Qt.AlignHCenter + text: Qt.formatDateTime(new Date(), Shared.Config.clockSecondsFormat) + color: "#00eaff" + font.pixelSize: 96 + font.family: Shared.Theme.fontFamily + font.weight: Font.Light + font.letterSpacing: -2 + + // Breathing animation on opacity + SequentialAnimation on opacity { + running: root.active + loops: Animation.Infinite + NumberAnimation { to: 0.55; duration: 2800; easing.type: Easing.InOutSine } + NumberAnimation { to: 1.0; duration: 2800; easing.type: Easing.InOutSine } + } + } + + // Date — dim grey, understated + Text { + Layout.alignment: Qt.AlignHCenter + text: Qt.formatDateTime(new Date(), "dddd, d MMMM") + color: "#404040" + font.pixelSize: 18 + font.family: Shared.Theme.fontFamily + font.letterSpacing: 2 + } + } + + // Clock update timer + Timer { + interval: 1000 + running: root.active + repeat: true + onTriggered: clockText.text = Qt.formatDateTime(new Date(), Shared.Config.clockSecondsFormat) + } + } + } + } + } + + function show() { active = true; } + function dismiss() { + active = false; + lockProc.running = true; + } + + Process { id: lockProc; command: [Shared.Config.lockCommand] } +} diff --git a/dot_config/quickshell/notifications/NotificationDaemon.qml b/dot_config/quickshell/notifications/NotificationDaemon.qml new file mode 100644 index 0000000..95f8c1b --- /dev/null +++ b/dot_config/quickshell/notifications/NotificationDaemon.qml @@ -0,0 +1,159 @@ +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +Scope { + id: root + + // Expose for NC popout + property alias trackedNotifications: server.trackedNotifications + property bool dnd: false + + // Toast IDs currently showing as popups (capped to avoid overflow) + readonly property int maxToasts: 4 + property var toastIds: [] + property var timestamps: ({}) // notification id → arrival epoch ms + + NotificationServer { + id: server + keepOnReload: true + bodySupported: true + bodyMarkupSupported: false + actionsSupported: true + imageSupported: true + persistenceSupported: true + + onNotification: notification => { + notification.tracked = true; + root.timestamps[notification.id] = Date.now(); + root.timestampsChanged(); + + // Suppress toasts in DnD mode (still tracked for history) + if (root.dnd) return; + + // Add to toast list, evict oldest if at capacity + let ids = root.toastIds.slice(); + if (ids.length >= root.maxToasts) { + let evicted = ids.shift(); + delete toastTimer.pending[evicted]; + } + ids.push(notification.id); + root.toastIds = ids; + + // Schedule toast removal (not notification removal) + let timeout = notification.expireTimeout > 0 ? notification.expireTimeout * 1000 : 5000; + if (notification.urgency !== NotificationUrgency.Critical) { + toastTimer.createTimer(notification.id, timeout); + } + } + } + + // Toast timer manager + QtObject { + id: toastTimer + property var pending: ({}) + + function createTimer(id, timeout) { + pending[id] = Date.now() + timeout; + } + } + + Timer { + interval: 500 + running: root.toastIds.length > 0 + repeat: true + onTriggered: { + let now = Date.now(); + let changed = false; + let ids = root.toastIds.slice(); + + for (let id in toastTimer.pending) { + if (now >= toastTimer.pending[id]) { + let idx = ids.indexOf(parseInt(id)); + if (idx >= 0) { ids.splice(idx, 1); changed = true; } + delete toastTimer.pending[id]; + } + } + + if (changed) root.toastIds = ids; + } + } + + // Prune stale timestamps to prevent unbounded growth + Timer { + interval: 60000 + running: true + repeat: true + onTriggered: { + let tracked = server.trackedNotifications; + let live = new Set(); + for (let i = 0; i < tracked.values.length; i++) + live.add(tracked.values[i].id); + let ts = root.timestamps; + let pruned = false; + for (let id in ts) { + if (!live.has(parseInt(id))) { delete ts[id]; pruned = true; } + } + if (pruned) root.timestampsChanged(); + } + } + + // Toast popup window — shows only active toasts + Variants { + model: Quickshell.screens + + delegate: Component { + PanelWindow { + required property var modelData + screen: modelData + WlrLayershell.namespace: "quickshell:notifications" + surfaceFormat { opaque: false } + + visible: modelData.name === Shared.Config.monitor && root.toastIds.length > 0 + + anchors { + top: true + right: true + } + + exclusionMode: ExclusionMode.Ignore + implicitWidth: 380 + implicitHeight: toastColumn.implicitHeight + 20 + color: "transparent" + + margins { + right: Shared.Theme.barWidth + 12 + top: 8 + } + + ColumnLayout { + id: toastColumn + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 10 + anchors.rightMargin: 10 + width: 360 + spacing: 8 + + Repeater { + model: server.trackedNotifications + + Loader { + required property var modelData + active: root.toastIds.indexOf(modelData.id) >= 0 + visible: active + Layout.fillWidth: true + + sourceComponent: NotificationPopup { + notification: modelData + } + } + } + } + } + } + } +} diff --git a/dot_config/quickshell/notifications/NotificationPopup.qml b/dot_config/quickshell/notifications/NotificationPopup.qml new file mode 100644 index 0000000..59510b5 --- /dev/null +++ b/dot_config/quickshell/notifications/NotificationPopup.qml @@ -0,0 +1,163 @@ +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +// Single notification card +Rectangle { + id: root + + required property var notification + + implicitWidth: 360 + implicitHeight: content.implicitHeight + 20 + radius: Shared.Theme.radiusNormal + color: Shared.Theme.popoutBackground + border.width: 1 + border.color: notification.urgency === NotificationUrgency.Critical ? Qt.alpha(Shared.Theme.danger, Shared.Theme.opacityMedium) : Shared.Theme.borderSubtle + + // Entrance animation + opacity: 0 + scale: 0.95 + Component.onCompleted: { opacity = 1; scale = 1; } + + Behavior on opacity { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + Behavior on scale { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } + + // Dismiss on click + MouseArea { + anchors.fill: parent + onClicked: root.notification.dismiss() + } + + ColumnLayout { + id: content + anchors.fill: parent + anchors.margins: 10 + spacing: 6 + + // Header: icon + app name + close + RowLayout { + Layout.fillWidth: true + spacing: 8 + + IconImage { + source: root.notification.appIcon + implicitSize: 18 + visible: root.notification.appIcon !== "" + } + + Text { + text: root.notification.appName || "Notification" + color: Shared.Theme.overlay0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + Layout.fillWidth: true + elide: Text.ElideRight + } + + Text { + text: "\u{f0156}" + color: closeMouse.containsMouse ? Shared.Theme.danger : Shared.Theme.overlay0 + font.pixelSize: 14 + font.family: Shared.Theme.iconFont + MouseArea { + id: closeMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.notification.dismiss() + } + } + } + + // Summary (title) + Text { + visible: text !== "" + text: root.notification.summary + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontSize + font.family: Shared.Theme.fontFamily + font.bold: true + Layout.fillWidth: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + + // Body + Text { + visible: text !== "" + text: root.notification.body + textFormat: Text.PlainText + color: Shared.Theme.subtext0 + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + Layout.fillWidth: true + wrapMode: Text.WordWrap + maximumLineCount: 4 + elide: Text.ElideRight + } + + // Image + Image { + visible: root.notification.image !== "" + source: root.notification.image + Layout.fillWidth: true + Layout.preferredHeight: 120 + fillMode: Image.PreserveAspectCrop + Layout.topMargin: 2 + } + + // Actions + RowLayout { + visible: root.notification.actions.length > 0 + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 6 + + Repeater { + model: root.notification.actions + + Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: 28 + radius: 6 + color: actMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0 + + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: modelData.text + color: Shared.Theme.text + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + } + + MouseArea { + id: actMouse + anchors.fill: parent + hoverEnabled: true + onClicked: modelData.invoke() + } + } + } + } + } + + // Urgency accent bar on left edge + Rectangle { + visible: root.notification.urgency === NotificationUrgency.Critical + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: 1 + anchors.topMargin: root.radius + anchors.bottomMargin: root.radius + width: 3 + color: Shared.Theme.danger + } +} diff --git a/dot_config/quickshell/osd/Osd.qml b/dot_config/quickshell/osd/Osd.qml new file mode 100644 index 0000000..298a020 --- /dev/null +++ b/dot_config/quickshell/osd/Osd.qml @@ -0,0 +1,178 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts +import "../shared" as Shared + +Scope { + id: root + + PwObjectTracker { objects: [Pipewire.defaultAudioSink, Pipewire.defaultAudioSource] } + + property string osdIcon: "" + property real osdValue: 0 + property bool osdMuted: false + property string osdLabel: "" + property bool osdVisible: false + + function showOsd(icon, value, muted, label) { + osdIcon = icon; + osdValue = value; + osdMuted = muted; + osdLabel = label; + osdVisible = true; + hideTimer.restart(); + } + + Timer { + id: hideTimer + interval: 1500 + onTriggered: root.osdVisible = false + } + + // Event-driven audio change detection + property var sinkAudio: Pipewire.defaultAudioSink?.audio ?? null + property var sourceAudio: Pipewire.defaultAudioSource?.audio ?? null + + Connections { + target: root.sinkAudio + function onVolumeChanged() { + let a = root.sinkAudio; + if (a) root.showOsd("\u{f057e}", a.volume, a.muted, "Volume"); + } + function onMutedChanged() { + let a = root.sinkAudio; + if (a) root.showOsd(a.muted ? "\u{f057f}" : "\u{f057e}", a.volume, a.muted, a.muted ? "Muted" : "Volume"); + } + } + + Connections { + target: root.sourceAudio + function onVolumeChanged() { + let a = root.sourceAudio; + if (a) root.showOsd("\u{f036c}", a.volume, a.muted, "Mic"); + } + function onMutedChanged() { + let a = root.sourceAudio; + if (a) root.showOsd(a.muted ? "\u{f036d}" : "\u{f036c}", a.volume, a.muted, a.muted ? "Mic muted" : "Mic"); + } + } + + // Brightness monitoring via brightnessctl (auto-disables if no backlight device) + property real lastBrightness: -1 + property bool hasBrightness: false + Process { + id: brightProc + command: ["brightnessctl", "-m"] + stdout: StdioCollector { + onStreamFinished: { + let parts = this.text.trim().split(","); + if (parts.length >= 5) { + root.hasBrightness = true; + let pct = parseInt(parts[4]) / 100; + if (root.lastBrightness >= 0 && Math.abs(pct - root.lastBrightness) > 0.005) + root.showOsd("\u{f00df}", pct, false, "Brightness"); + root.lastBrightness = pct; + } + } + } + } + Timer { + interval: 500 + running: root.hasBrightness + repeat: true + onTriggered: { brightProc.running = false; brightProc.running = true; } + } + Component.onCompleted: { brightProc.running = true; } + + Variants { + model: Quickshell.screens + + delegate: Component { + PanelWindow { + required property var modelData + screen: modelData + WlrLayershell.namespace: "quickshell:osd" + surfaceFormat { opaque: false } + + visible: modelData.name === Shared.Config.monitor && root.osdVisible + + anchors { + top: true + right: true + bottom: true + } + + exclusionMode: ExclusionMode.Ignore + implicitWidth: Shared.Theme.barWidth + Shared.Theme.popoutWidth + 12 + color: "transparent" + + Rectangle { + anchors.right: parent.right + anchors.rightMargin: Shared.Theme.barWidth + 12 + anchors.verticalCenter: parent.verticalCenter + width: 44 + height: 180 + radius: 22 + color: Shared.Theme.popoutBackground + border.width: 1 + border.color: Shared.Theme.borderSubtle + + opacity: root.osdVisible ? 1.0 : 0.0 + scale: root.osdVisible ? 1.0 : 0.95 + transformOrigin: Item.Right + + Behavior on opacity { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + + ColumnLayout { + anchors.fill: parent + anchors.topMargin: 12 + anchors.bottomMargin: 12 + spacing: 8 + + // Percentage + Text { + Layout.alignment: Qt.AlignHCenter + text: root.osdMuted ? "M" : Math.round(root.osdValue * 100) + color: root.osdMuted ? Shared.Theme.overlay0 : Shared.Theme.text + font.pixelSize: Shared.Theme.fontSmall + font.family: Shared.Theme.fontFamily + font.bold: true + } + + // Vertical bar (fills bottom-up) + Rectangle { + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter + implicitWidth: 6 + radius: 3 + color: Shared.Theme.surface0 + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: root.osdMuted ? 0 : parent.height * Math.min(1, root.osdValue) + radius: 3 + color: root.osdMuted ? Shared.Theme.overlay0 : Shared.Theme.sky + Behavior on height { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } } + } + } + + // Icon + Text { + Layout.alignment: Qt.AlignHCenter + text: root.osdIcon + color: root.osdMuted ? Shared.Theme.overlay0 : Shared.Theme.sky + font.pixelSize: 16 + font.family: Shared.Theme.iconFont + } + } + } + } + } + } +} diff --git a/dot_config/quickshell/scripts/executable_gpu.sh b/dot_config/quickshell/scripts/executable_gpu.sh new file mode 100644 index 0000000..adcc46b --- /dev/null +++ b/dot_config/quickshell/scripts/executable_gpu.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# AMD GPU stats for Waybar (RDNA 4 / amdgpu) + +set -o pipefail + +read_numeric_file() { + local path="$1" + local value + [[ -r "$path" ]] || return 1 + value=$(<"$path") + [[ "$value" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$value" +} + +# Find AMD GPU hwmon +GPU_HWMON="" +for hwmon in /sys/class/hwmon/hwmon*; do + if [[ -f "$hwmon/name" ]] && grep -q "amdgpu" "$hwmon/name" 2>/dev/null; then + GPU_HWMON="$hwmon" + break + fi +done + +if [[ -z "$GPU_HWMON" ]]; then + echo '{"text":"󰢮","class":"disconnected","tooltip":"AMD GPU not found"}' + exit 0 +fi + +GPU_DEVICE=$(readlink -f "$GPU_HWMON/device" 2>/dev/null || true) + +# Read GPU stats +temp_raw=0 +for temp_sensor in temp1_input temp2_input temp3_input; do + if temp_candidate=$(read_numeric_file "$GPU_HWMON/$temp_sensor"); then + temp_raw="$temp_candidate" + break + fi +done +temp=$((temp_raw / 1000)) + +# GPU usage from /sys/class/drm +gpu_busy=0 +if [[ -n "$GPU_DEVICE" ]] && gpu_busy_candidate=$(read_numeric_file "$GPU_DEVICE/gpu_busy_percent"); then + gpu_busy="$gpu_busy_candidate" +else + for card in /sys/class/drm/card*/device/gpu_busy_percent; do + if gpu_busy_candidate=$(read_numeric_file "$card"); then + gpu_busy="$gpu_busy_candidate" + break + fi + done +fi + +# VRAM usage +vram_used=0 +vram_total=0 +if [[ -n "$GPU_DEVICE" ]] && [[ -r "$GPU_DEVICE/mem_info_vram_used" ]]; then + vram_used_raw=$(read_numeric_file "$GPU_DEVICE/mem_info_vram_used" || echo 0) + vram_total_raw=$(read_numeric_file "$GPU_DEVICE/mem_info_vram_total" || echo 0) + vram_used=$((vram_used_raw / 1024 / 1024)) + vram_total=$((vram_total_raw / 1024 / 1024)) +else + for card in /sys/class/drm/card*/device; do + if [[ -r "$card/mem_info_vram_used" ]]; then + vram_used_raw=$(read_numeric_file "$card/mem_info_vram_used" || echo 0) + vram_total_raw=$(read_numeric_file "$card/mem_info_vram_total" || echo 0) + vram_used=$((vram_used_raw / 1024 / 1024)) + vram_total=$((vram_total_raw / 1024 / 1024)) + break + fi + done +fi + +# Power usage (watts) +power_raw=0 +for power_sensor in power1_average power1_input; do + if power_candidate=$(read_numeric_file "$GPU_HWMON/$power_sensor"); then + power_raw="$power_candidate" + break + fi +done +power=$((power_raw / 1000000)) + +# Determine class based on temperature +if [[ $temp -ge 90 ]]; then + class="critical" +elif [[ $temp -ge 75 ]]; then + class="warning" +elif [[ $gpu_busy -ge 90 ]]; then + class="high" +else + class="normal" +fi + +# Format text +text="󰢮 ${temp}°C" + +# Build tooltip with actual newlines +NL=$'\n' +tooltip="AMD GPU${NL}Usage: ${gpu_busy}%${NL}Temp: ${temp}°C${NL}Power: ${power}W" +if [[ $vram_total -gt 0 ]]; then + vram_pct=$((vram_used * 100 / vram_total)) + tooltip="${tooltip}${NL}VRAM: ${vram_used}/${vram_total} MB (${vram_pct}%)" +fi + +jq -nc \ + --arg text "$text" \ + --arg class "$class" \ + --arg tooltip "$tooltip" \ + '{text: $text, class: $class, tooltip: $tooltip}' diff --git a/dot_config/quickshell/shared/Config.qml.tmpl b/dot_config/quickshell/shared/Config.qml.tmpl new file mode 100644 index 0000000..c996da7 --- /dev/null +++ b/dot_config/quickshell/shared/Config.qml.tmpl @@ -0,0 +1,62 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + readonly property string home: Quickshell.env("HOME") + + // Appearance + // apexFlavor: "neon" (dark) or "aeon" (light) — synced from chezmoi data.theme + readonly property string apexFlavor: "{{ trimPrefix "apex-" .chezmoi.config.data.theme }}" + readonly property bool transparency: true // semi-transparent backgrounds (needs Hyprland blur layerrule) + + // Monitor — quickshell bar lives on the rightmost monitor (portrait, DP-2) + readonly property string monitor: "DP-2" + + // Workspaces — show all 10; Hyprland IPC tracks per-monitor active workspace + readonly property int workspaceCount: 10 + + // Weather + readonly property string weatherLocation: "Nospelt" + readonly property bool useCelsius: true + readonly property string tempUnit: useCelsius ? "°C" : "°F" + readonly property string windUnit: useCelsius ? "km/h" : "mph" + readonly property int forecastDays: 5 + readonly property int weatherRefreshMs: 1800000 + + // Disk mounts to monitor + readonly property string diskMount1: "/" + readonly property string diskMount1Label: "/" + readonly property string diskMount2: home + "/data" + readonly property string diskMount2Label: "/data" + + // Scripts + readonly property string scriptsDir: home + "/.config/quickshell/scripts" + readonly property string gpuScript: scriptsDir + "/gpu.sh" + + // Idle daemon + readonly property string idleProcess: "hypridle" + readonly property string lockCommand: "swaylock" + + // Power commands + readonly property var powerActions: [ + { command: ["swaylock"] }, + { command: ["hyprshutdown"] }, + { command: ["hyprshutdown", "-t", "Restarting...", "--post-cmd", "systemctl reboot"] }, + { command: ["hyprshutdown", "-t", "Powering off...", "--post-cmd", "systemctl poweroff"] } + ] + + // Network filter (interfaces to exclude from IP display) + readonly property string netExcludePattern: "127.0.0\\|docker\\|br-\\|veth" + + // DateTime / Calendar + readonly property bool use24h: true + readonly property string clockFormat: use24h ? "HH:mm" : "hh:mm A" + readonly property string clockSecondsFormat: use24h ? "HH:mm:ss" : "hh:mm:ss A" + readonly property string dateFormat: "dddd, d MMMM yyyy" + readonly property string pillDateFormat: "ddd\nd" + readonly property string pillTimeFormat: use24h ? "HH\nmm" : "hh\nmm" + readonly property bool weekStartsMonday: true + readonly property var dayHeaders: weekStartsMonday ? ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] : ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] +} diff --git a/dot_config/quickshell/shared/PopoutState.qml b/dot_config/quickshell/shared/PopoutState.qml new file mode 100644 index 0000000..8b0ef3c --- /dev/null +++ b/dot_config/quickshell/shared/PopoutState.qml @@ -0,0 +1,22 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + property string active: "" + property real triggerY: 0 + + function toggle(name: string, y: real): void { + if (active === name) { + active = ""; + } else { + active = name; + triggerY = y; + } + } + + function close(): void { + active = ""; + } +} diff --git a/dot_config/quickshell/shared/Theme.qml b/dot_config/quickshell/shared/Theme.qml new file mode 100644 index 0000000..c3ebdbf --- /dev/null +++ b/dot_config/quickshell/shared/Theme.qml @@ -0,0 +1,124 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + // ─── Apex palettes ─────────────────────── + readonly property var palettes: ({ + neon: { + // Backgrounds + base: "#050505", mantle: "#0a0a0a", crust: "#000000", + surface0: "#141414", surface1: "#1e1e1e", surface2: "#262626", + overlay0: "#404040", overlay1: "#555555", + // Text + text: "#ededed", subtext0: "#b0b0b0", subtext1: "#d0d0d0", + // Accent colors — apex-neon palette + lavender: "#9d00ff", // sacred purple + mauve: "#9d00ff", // sacred purple + pink: "#ff0044", // razor red + red: "#ff0044", // razor red + peach: "#ff8899", // alert salmon + yellow: "#ffb700", // amber warning + green: "#00ff99", // toxic green + teal: "#00ff99", // toxic green + blue: "#00eaff", // tech cyan + sky: "#00eaff" // tech cyan + }, + aeon: { + // Backgrounds + base: "#f5f5f5", mantle: "#e8e8e8", crust: "#d0d0d0", + surface0: "#e0e0e0", surface1: "#d4d4d4", surface2: "#c8c8c8", + overlay0: "#737373", overlay1: "#555555", + // Text + text: "#0a0a0a", subtext0: "#444444", subtext1: "#333333", + // Accent colors — apex-aeon palette + lavender: "#7a3cff", // indigo purple + mauve: "#7a3cff", // indigo purple + pink: "#ff0044", // razor red + red: "#ff0044", // razor red + peach: "#ff4d6d", // rose error + yellow: "#d18f00", // dark amber + green: "#00b377", // forest green + teal: "#00b377", // forest green + blue: "#007a88", // deep teal + sky: "#007a88" // deep teal + } + }) + + readonly property var p: palettes[Config.apexFlavor] || palettes.neon + + // ─── Palette colors ────────────────────── + readonly property color base: p.base + readonly property color mantle: p.mantle + readonly property color crust: p.crust + readonly property color surface0: p.surface0 + readonly property color surface1: p.surface1 + readonly property color surface2: p.surface2 + readonly property color overlay0: p.overlay0 + readonly property color text: p.text + readonly property color subtext0: p.subtext0 + readonly property color subtext1: p.subtext1 + readonly property color lavender: p.lavender + readonly property color mauve: p.mauve + readonly property color pink: p.pink + readonly property color red: p.red + readonly property color peach: p.peach + readonly property color yellow: p.yellow + readonly property color green: p.green + readonly property color teal: p.teal + readonly property color blue: p.blue + readonly property color sky: p.sky + + // ─── Semantic aliases ──────────────────── + readonly property color accent: blue // tech cyan / deep teal + readonly property color success: green // toxic / forest + readonly property color warning: yellow // amber + readonly property color danger: red // razor red + readonly property color info: sky // cyan / teal + readonly property color muted: overlay0 // dim grey + + // ─── Opacity tokens ────────────────────── + readonly property real opacitySubtle: 0.08 // borders, faint dividers + readonly property real opacityLight: 0.15 // tinted backgrounds + readonly property real opacityMedium: 0.3 // active borders, overlays + readonly property real opacityStrong: 0.45 // muted/disabled elements + readonly property real opacityFill: 0.7 // progress bar fills + + // ─── Border token ──────────────────────── + readonly property bool isDark: Config.apexFlavor === "neon" + readonly property color borderSubtle: isDark ? Qt.rgba(1, 1, 1, opacitySubtle) : Qt.rgba(0, 0, 0, opacitySubtle) + + // ─── Transparency ──────────────────────── + readonly property bool transparencyEnabled: Config.transparency + readonly property color barBackground: transparencyEnabled ? Qt.alpha(mantle, 0.75) : mantle + readonly property color popoutBackground: transparencyEnabled ? Qt.alpha(mantle, 0.82) : mantle + + // ─── Layout ────────────────────────────── + readonly property int barWidth: 52 + readonly property int barInnerWidth: 40 + readonly property int barPadding: 6 + readonly property int spacing: 10 + + // Popouts + readonly property int popoutWidth: 320 + readonly property int popoutPadding: 16 + readonly property int popoutSpacing: 10 + + // Rounding + readonly property int radiusSmall: 8 + readonly property int radiusNormal: 14 + readonly property int radiusPill: 1000 + + // ─── Typography ────────────────────────── + readonly property int fontSmall: 11 + readonly property int fontSize: 13 + readonly property int fontLarge: 15 + readonly property string fontFamily: "GeistMono Nerd Font" + readonly property string iconFont: "GeistMono Nerd Font" + + // ─── Animation ─────────────────────────── + readonly property int animFast: 150 + readonly property int animNormal: 300 + readonly property int animSlow: 500 +} diff --git a/dot_config/quickshell/shared/Time.qml b/dot_config/quickshell/shared/Time.qml new file mode 100644 index 0000000..77ca75e --- /dev/null +++ b/dot_config/quickshell/shared/Time.qml @@ -0,0 +1,15 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + readonly property string clock: Qt.formatDateTime(systemClock.date, Config.clockFormat) + readonly property string clockSeconds: Qt.formatDateTime(systemClock.date, Config.clockSecondsFormat) + readonly property date date: systemClock.date + + SystemClock { + id: systemClock + precision: SystemClock.Seconds + } +} diff --git a/dot_config/quickshell/shared/Weather.qml b/dot_config/quickshell/shared/Weather.qml new file mode 100644 index 0000000..4cf7d4c --- /dev/null +++ b/dot_config/quickshell/shared/Weather.qml @@ -0,0 +1,85 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property bool useCelsius: Config.useCelsius + + property string location: Config.weatherLocation + property string temp: "--" + property string icon: "\u{f0590}" + property string description: "--" + property string feelsLike: "--" + property string humidity: "--" + property string wind: "--" + property var forecast: [] + property string status: "loading" // "loading", "ok", "error" + + function weatherIcon(code) { + code = parseInt(code); + if (code === 113) return "\u{f0599}"; + if (code === 116) return "\u{f0595}"; + if (code <= 122) return "\u{f0590}"; + if (code <= 260) return "\u{f0591}"; + if ([176,263,266,293,296,299,302,305,308,353,356,359].includes(code)) return "\u{f0597}"; + return "\u{f0598}"; + } + + Process { + id: weatherProc + command: ["sh", "-c", "curl -sf 'wttr.in/" + Config.weatherLocation + "?format=j1' 2>/dev/null"] + running: true + stdout: StdioCollector { + onStreamFinished: { + try { + let data = JSON.parse(this.text); + let cur = data.current_condition[0]; + let tempKey = root.useCelsius ? "temp_C" : "temp_F"; + let feelsKey = root.useCelsius ? "FeelsLikeC" : "FeelsLikeF"; + let windKey = root.useCelsius ? "windspeedKmph" : "windspeedMiles"; + let maxKey = root.useCelsius ? "maxtempC" : "maxtempF"; + let minKey = root.useCelsius ? "mintempC" : "mintempF"; + + root.temp = cur[tempKey] + Config.tempUnit; + root.feelsLike = cur[feelsKey] + Config.tempUnit; + root.humidity = cur.humidity + "%"; + root.wind = cur[windKey] + " " + Config.windUnit; + root.description = cur.weatherDesc[0].value; + root.location = data.nearest_area[0].areaName[0].value; + root.icon = root.weatherIcon(cur.weatherCode); + + let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + let fc = []; + let weather = data.weather || []; + for (let i = 0; i < Math.min(weather.length, Config.forecastDays); i++) { + let w = weather[i]; + let d = new Date(w.date); + fc.push({ + day: days[d.getDay()], + high: w[maxKey], + low: w[minKey], + code: w.hourly[4].weatherCode, + desc: w.hourly[4].weatherDesc[0].value + }); + } + root.forecast = fc; + root.status = "ok"; + } catch(e) { + console.warn("Weather: failed to parse response:", e); + root.status = root.temp === "--" ? "error" : "stale"; + } + } + } + } + + Timer { + interval: Config.weatherRefreshMs + running: true + repeat: true + onTriggered: weatherProc.running = true + } +} diff --git a/dot_config/quickshell/shell.qml b/dot_config/quickshell/shell.qml new file mode 100644 index 0000000..d223a50 --- /dev/null +++ b/dot_config/quickshell/shell.qml @@ -0,0 +1,13 @@ +//@ pragma UseQApplication + +import Quickshell +import "notifications" +import "osd" +import "lock" + +ShellRoot { + NotificationDaemon { id: notifDaemon } + Bar { notifModel: notifDaemon.trackedNotifications; notifDaemon: notifDaemon } + Osd {} + IdleScreen { id: idleScreen } +}