diff --git a/.gitignore b/.gitignore
index aab53d0..9c88b01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
!hypr/
!hyprpanel/
!nvim/
+!quickshell/
!waybar/
!zathura/
!mimeapps.list
diff --git a/fluxo/config.toml b/fluxo/config.toml
index 8ca6d9a..9e6ff85 100644
--- a/fluxo/config.toml
+++ b/fluxo/config.toml
@@ -1,5 +1,10 @@
# fluxo-rs configuration
# Location: ~/.config/fluxo/config.toml
+#
+# The hardware modules below emit raw pipe-delimited values rather than display
+# strings. The Quickshell config (~/.config/quickshell/services/Sys.qml) parses
+# them and does its own formatting. The previous human-readable Waybar formats
+# are preserved in config.toml.waybar-bak.
[general]
menu_command = "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\""
@@ -20,23 +25,23 @@ backlight = 12
dnd = 13
[network]
-format = "{interface} ({ip}): {rx:^4.1} MB/s {tx:^4.1} MB/s"
+format = "{interface}|{ip}|{rx:.3}|{tx:.3}"
[cpu]
-format = "CPU: {usage:^4.1}% {temp:^4.1}C"
+format = "{usage:.1}|{temp:.1}"
[memory]
-format = "MEM: {used:^4.1}/{total:^4.1}GB"
+format = "{used:.2}|{total:.2}"
[sys]
-format = "UP: {uptime} LOAD: {load1:^3.1} "
+format = "{uptime}|{load1:.2}|{load5:.2}|{load15:.2}"
[disk]
-format = "{mount} {used:^3.0}/{total:^3.0}G"
+format = "{mount}|{used:.1}|{total:.1}"
[power]
-format = "{percentage:>3}% {icon}"
+format = "{percentage}"
[audio]
format_sink_unmuted = "{name} {volume:>3}% {icon}"
@@ -66,18 +71,18 @@ enabled = false
format = "{used:>4.0}G / {total:>4.0}G"
[gpu]
-enabled = false
-format_amd = "AMD: {usage:>3.0}% {vram_used:>4.1}/{vram_total:>4.1}GB {temp:>4.1}C"
-format_intel = "iGPU: {usage:>3.0}%"
-format_nvidia = "NV: {usage:>3.0}% {vram_used:>4.1}/{vram_total:>4.1}GB {temp:>4.1}C"
+enabled = true
+format_amd = "{usage:.0}|{vram_used:.2}|{vram_total:.2}|{temp:.1}"
+format_intel = "{usage:.0}|0|0|0"
+format_nvidia = "{usage:.0}|{vram_used:.2}|{vram_total:.2}|{temp:.1}"
[keyboard]
enabled = false
format = "{layout}"
[backlight]
-enable = true
-format = " {icon} {percentage}"
+enable = true
+format = "{percentage}"
[dnd]
enabled = true
diff --git a/fluxo/config.toml.waybar-bak b/fluxo/config.toml.waybar-bak
new file mode 100644
index 0000000..8ca6d9a
--- /dev/null
+++ b/fluxo/config.toml.waybar-bak
@@ -0,0 +1,85 @@
+# fluxo-rs configuration
+# Location: ~/.config/fluxo/config.toml
+
+[general]
+menu_command = "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\""
+
+[signals]
+network = 1
+cpu = 2
+memory = 3
+gpu = 4
+sys = 5
+disk = 6
+game = 7
+audio = 8
+bt = 9
+power = 10
+mpris = 11
+backlight = 12
+dnd = 13
+
+[network]
+format = "{interface} ({ip}): {rx:^4.1} MB/s {tx:^4.1} MB/s"
+
+[cpu]
+format = "CPU: {usage:^4.1}% {temp:^4.1}C"
+
+[memory]
+format = "MEM: {used:^4.1}/{total:^4.1}GB"
+
+[sys]
+format = "UP: {uptime} LOAD: {load1:^3.1} "
+
+[disk]
+format = "{mount} {used:^3.0}/{total:^3.0}G"
+
+
+[power]
+format = "{percentage:>3}% {icon}"
+
+[audio]
+format_sink_unmuted = "{name} {volume:>3}% {icon}"
+format_sink_muted = "{name} {icon}"
+format_source_unmuted = "{name} {volume:>3}% {icon}"
+format_source_muted = "{name} {icon}"
+
+[bt]
+format_plugin = "{alias} [{left}|{right}] {anc} "
+format_connected = "{alias} "
+format_disconnected = "Disconnected "
+format_disabled = ""
+
+[game]
+format_active = ""
+format_inactive = ""
+
+[mpris]
+format = "{artist} - {title}"
+max_length = 20
+scroll = true
+scroll_speed = 500
+scroll_separator = " /// "
+
+[pool]
+enabled = false
+format = "{used:>4.0}G / {total:>4.0}G"
+
+[gpu]
+enabled = false
+format_amd = "AMD: {usage:>3.0}% {vram_used:>4.1}/{vram_total:>4.1}GB {temp:>4.1}C"
+format_intel = "iGPU: {usage:>3.0}%"
+format_nvidia = "NV: {usage:>3.0}% {vram_used:>4.1}/{vram_total:>4.1}GB {temp:>4.1}C"
+
+[keyboard]
+enabled = false
+format = "{layout}"
+
+[backlight]
+enable = true
+format = " {icon} {percentage}"
+
+[dnd]
+enabled = true
+format_dnd = ""
+format_normal = ""
diff --git a/hypr/modules/autostart.conf b/hypr/modules/autostart.conf
index bb74624..70ee5ba 100644
--- a/hypr/modules/autostart.conf
+++ b/hypr/modules/autostart.conf
@@ -2,6 +2,11 @@
# Autostart necessary processes (like notifications daemons, status bars, etc.)
# Or execute your favorite apps at launch like this:
+# Takemi shell — bar, popouts, notifications, OSD, power menu and lock screen.
+# Config lives in ~/.config/quickshell. It owns org.freedesktop.Notifications,
+# so dunst must stay masked (systemctl --user mask dunst.service).
+exec-once = uwsm app -- qs
+
# exec-once = uwsm app -- waybar
# exec-once = uwsm app -- hyprpanel
# exec-once = uwsm app -- nextcloud --background
diff --git a/hypr/modules/keybinds.conf b/hypr/modules/keybinds.conf
index 72b52cb..cecd5fb 100644
--- a/hypr/modules/keybinds.conf
+++ b/hypr/modules/keybinds.conf
@@ -18,12 +18,17 @@ bind = $mainMod, M, exec, pdfs-prompt
bind = $mainMod, B, exec, fluxo bt menu
bind = $mainMod, RETURN, exec, $terminal
bind = $mainMod SHIFT, Q, killactive,
-bind = $mainMod CTRL, L, exec, hyprlock
+bind = $mainMod CTRL, L, exec, qs ipc call shell lock
bind = $mainMod, E, exec, $fileManager
bind = $mainMod, V, togglefloating,
bind = $mainMod, F, fullscreen,
bind = $mainMod, SPACE, exec, $menu
-bind = $mainMod, P, exec, ~/.config/hypr/scripts/powermenu.sh
+bind = $mainMod, P, exec, qs ipc call shell power
+
+# Takemi shell
+bind = $mainMod SHIFT, N, exec, qs ipc call shell toggleDnd
+bind = $mainMod CTRL SHIFT, N, exec, qs ipc call shell clearNotifications
+bind = $mainMod SHIFT, R, exec, qs ipc call shell reloadConfig
# cycle keyboard layout (dvorak-intl <-> us-intl)
bind = $mainMod ALT, SPACE, exec, hyprctl switchxkblayout all next
# bind = $mainMod, t, togglesplit, # dwindle
diff --git a/hypr/modules/takemi.conf b/hypr/modules/takemi.conf
new file mode 100644
index 0000000..c651c5d
--- /dev/null
+++ b/hypr/modules/takemi.conf
@@ -0,0 +1,25 @@
+# Takemi palette — kept in step with ~/.config/quickshell/config/Theme.qml.
+# Derived from ~/.wallpapers/takemi_2.jpg: cold teal over near-black, with
+# Persona 5 crimson as the accent.
+
+$base = rgb(04070a)
+$mantle = rgb(080e14)
+$surface = rgb(0d1922)
+$surfaceAlt = rgb(14262f)
+$overlay = rgb(1d3a47)
+$outline = rgb(2b5567)
+
+$text = rgb(e2f4f8)
+$subtext = rgb(93b8c4)
+$muted = rgb(587c8a)
+
+$primary = rgb(34d3e6)
+$primaryDim = rgb(17879b)
+$glow = rgb(8ef2ff)
+$blue = rgb(3b8fd6)
+$deep = rgb(0d4b5e)
+
+$accent = rgb(ff2d40)
+$accentDim = rgb(a8121f)
+$warn = rgb(ffb03a)
+$ok = rgb(35e08f)
diff --git a/hypr/modules/theme.conf b/hypr/modules/theme.conf
index 71d8043..7a9d151 100644
--- a/hypr/modules/theme.conf
+++ b/hypr/modules/theme.conf
@@ -1,21 +1,19 @@
-# catppuccin mocha theme
-source = ~/.config/hypr/catppuccin-hyprland/themes/mocha.conf
+# Takemi — Persona 5 flavoured theme, matched to ~/.config/quickshell.
+source = ~/.config/hypr/modules/takemi.conf
# Refer to https://wiki.hyprland.org/Configuring/Variables/
# https://wiki.hyprland.org/Configuring/Variables/#general
-
general {
gaps_in = 5
- gaps_out = 5, 5, 5, 5
+ gaps_out = 6, 8, 8, 8
- border_size = 3
+ border_size = 2
- # https://wiki.hyprland.org/Configuring/Variables/#variable-types for info about colors
- col.active_border = $mauve $lavender 45deg
- col.inactive_border = rgb($surface2Alpha)
+ # Teal into crimson, on the diagonal — the shell's two voices.
+ col.active_border = $primary $accent 135deg
+ col.inactive_border = $outline
- # Set to true enable resizing windows by clicking and dragging on borders and gaps
resize_on_border = false
# Please see https://wiki.hyprland.org/Configuring/Tearing/ before you turn this on
@@ -26,58 +24,76 @@ general {
# https://wiki.hyprland.org/Configuring/Variables/#decoration
decoration {
- rounding = 12
- rounding_power = 3
+ # Persona 5 has no rounded corners. Neither do we.
+ rounding = 0
- # Change transparency of focused and unfocused windows
active_opacity = 1.0
- inactive_opacity = 1.0
+ inactive_opacity = 0.96
shadow {
enabled = true
- range = 4
+ range = 18
render_power = 3
- color = rgba(1a1a1aee)
+ offset = 0 4
+ color = rgba(04070aee)
+ color_inactive = rgba(04070a88)
}
# https://wiki.hyprland.org/Configuring/Variables/#blur
blur {
enabled = true
- size = 3
- passes = 1
+ size = 6
+ passes = 3
+ new_optimizations = true
+ ignore_opacity = true
+ xray = false
+ noise = 0.02
+ contrast = 1.1
+ brightness = 0.85
+ vibrancy = 0.25
+ vibrancy_darkness = 0.3
- vibrancy = 0.1696
+ # The bar and its popouts do their own thing.
+ popups = true
+ popups_ignorealpha = 0.4
}
}
-# https://wiki.hyprland.org/Configuring/Variables/#animations
+# https://wiki.hyprland.org/Configuring/Animations/
+# Snappy with a little overshoot — nothing here should feel soft.
animations {
enabled = yes, please :)
- # Default animations, see https://wiki.hyprland.org/Configuring/Animations/ for more
+ bezier = snap, 0.16, 1.00, 0.30, 1.00
+ bezier = overshoot, 0.34, 1.56, 0.64, 1.00
+ bezier = hard, 0.55, 0.00, 0.90, 0.35
+ bezier = linear, 0, 0, 1, 1
- bezier = easeOutQuint,0.23,1,0.32,1
- bezier = easeInOutCubic,0.65,0.05,0.36,1
- bezier = linear,0,0,1,1
- bezier = almostLinear,0.5,0.5,0.75,1.0
- bezier = quick,0.15,0,0.1,1
+ animation = global, 1, 4, snap
+ animation = border, 1, 6, snap
+ animation = borderangle, 1, 60, linear, loop
- animation = global, 1, 10, default
- animation = border, 1, 5.39, easeOutQuint
- animation = windows, 1, 4.79, easeOutQuint
- animation = windowsIn, 1, 4.1, easeOutQuint, popin 87%
- animation = windowsOut, 1, 1.49, linear, popin 87%
- animation = fadeIn, 1, 1.73, almostLinear
- animation = fadeOut, 1, 1.46, almostLinear
- animation = fade, 1, 3.03, quick
- animation = layers, 1, 3.81, easeOutQuint
- animation = layersIn, 1, 4, easeOutQuint, fade
- animation = layersOut, 1, 1.5, linear, fade
- animation = fadeLayersIn, 1, 1.79, almostLinear
- animation = fadeLayersOut, 1, 1.39, almostLinear
- animation = workspaces, 1, 1.94, almostLinear, fade
- animation = workspacesIn, 1, 1.21, almostLinear, fade
- animation = workspacesOut, 1, 1.94, almostLinear, fade
+ animation = windows, 1, 4, overshoot
+ animation = windowsIn, 1, 4.2, overshoot, popin 88%
+ animation = windowsOut, 1, 2.4, hard, popin 90%
+ animation = windowsMove, 1, 3.6, snap
+
+ animation = fade, 1, 3, snap
+ animation = fadeIn, 1, 2.4, snap
+ animation = fadeOut, 1, 1.8, hard
+ animation = fadeSwitch, 1, 2, snap
+ animation = fadeShadow, 1, 3, snap
+ animation = fadeDim, 1, 2.4, snap
+
+ animation = layers, 1, 3, overshoot
+ animation = layersIn, 1, 3.4, overshoot, slide
+ animation = layersOut, 1, 2, hard, slide
+
+ animation = workspaces, 1, 3.4, overshoot, slidevert
+ animation = workspacesIn, 1, 3.4, overshoot, slidevert
+ animation = workspacesOut, 1, 2.6, hard, slidevert
+
+ animation = specialWorkspace, 1, 3.6, overshoot, slidevert
}
# See https://wiki.hyprland.org/Configuring/Master-Layout/ for more
@@ -85,41 +101,40 @@ master {
new_status = master
}
+dwindle {
+ preserve_split = true
+}
+
# https://wiki.hyprland.org/Configuring/Variables/#misc
misc {
- force_default_wallpaper = 0 # Set to 0 or 1 to disable the anime mascot wallpapers
- disable_hyprland_logo = true # If true disables the random hyprland logo / anime girl background. :(
- # 0 = off, 1 = always, 2 = fullscreen only (G7 is a 240Hz VRR panel)
- vrr = 2
- font_family = FiraCode Nerd Font
+ force_default_wallpaper = 0
+ disable_hyprland_logo = true
+ # 0 = off, 1 = always, 2 = fullscreen only (G7 is a 240Hz VRR panel)
+ vrr = 2
+ font_family = FiraCode Nerd Font
+ background_color = $base
+ focus_on_activate = true
}
group {
- # The border around the grouped windows
- col.border_active = $mauve $lavender 45deg
- col.border_inactive = $surface1
+ col.border_active = $primary $accent 135deg
+ col.border_inactive = $outline
groupbar {
font_family = JetBrainsMono Nerd Font
font_size = 11
-
- # Increase the height so the text has room to breathe
- height = 24
-
- # Disable the default 3D gradient for a modern flat look
- gradients = false
+
+ height = 24
+ gradients = false
# --- Active Tab ---
- # Bright background with very dark text for perfect readability
- col.active = $mauve
- text_color = $crust
+ col.active = $primary
+ text_color = $base
# --- Inactive Tab ---
- # Dark surface background with dimmed, soft text
- col.inactive = $surface0
- text_color_inactive = $subtext0
+ col.inactive = $surface
+ text_color_inactive = $muted
- # Colored indicator bar, roughly half the groupbar height
- indicator_height = 15
+ indicator_height = 3
}
}
diff --git a/hypr/scripts/lid_handler.sh b/hypr/scripts/lid_handler.sh
index 37ad882..b036241 100755
--- a/hypr/scripts/lid_handler.sh
+++ b/hypr/scripts/lid_handler.sh
@@ -5,8 +5,9 @@
LID_STATE_FILE="$HOME/.config/hypr/lid_state.conf"
restart_ui() {
- # Spawn in background and detach
- systemctl restart --user waybar hyprpaper
+ # The Quickshell bar follows monitor hotplug on its own, so only the
+ # wallpaper daemon needs a kick.
+ systemctl restart --user hyprpaper
}
if [[ "$1" == "close" ]]; then
diff --git a/quickshell/README.md b/quickshell/README.md
new file mode 100644
index 0000000..716d71f
--- /dev/null
+++ b/quickshell/README.md
@@ -0,0 +1,118 @@
+# Takemi
+
+A Persona 5 flavoured Quickshell desktop for Hyprland. Cold teal and near-black
+pulled from `~/.wallpapers/takemi_2.jpg`, with crimson as the accent voice.
+Everything leans, corners are chopped rather than rounded, and headings carry
+the wallpaper's RGB split.
+
+Replaces Waybar, dunst and hyprlock.
+
+## Running
+
+```sh
+qs # starts the shell (Hyprland does this via autostart.conf)
+qs -p lockpreview.qml # the lock screen in an ordinary window, for styling
+```
+
+Hyprland starts it from `~/.config/hypr/modules/autostart.conf`.
+
+## Layout
+
+```
+shell.qml entrypoint — one bar, power menu, toasts and OSD per screen
+config/ palette, type scale, geometry, motion curves
+components/ the P5 primitives: panels, gauges, sliders, split-ink text
+services/ data sources, all singletons
+bar/ the bar and its modules
+popouts/ hover panels hanging off bar modules
+overlays/ power menu, notification toasts, volume/brightness OSD
+lock/ session lock
+assets/ halftone and stripe tiles, generated with ImageMagick
+```
+
+`P5Panel` is the base surface everything sits on. It does not auto-size —
+callers set implicit sizes from their own layout (`row.implicitWidth +
+contentPad * 2`), which keeps the shape maths out of binding loops.
+
+## Bar
+
+| Module | Left click | Right click | Middle | Scroll | Hover |
+|---|---|---|---|---|---|
+| Sigil | app menu | reload shell | | | |
+| Workspaces | switch | | | | |
+| CPU/MEM/GPU | btop | | | | resource dashboard |
+| Media | play/pause | next | raise player | next/prev | full player |
+| Clock | pin calendar | | | | calendar |
+| Tray | activate | app menu | secondary | | |
+| Network | pin panel | toggle Wi-Fi | nm-connection-editor | | networks |
+| Bluetooth | pin panel | toggle adapter | | | devices |
+| Volume | mute | pin panel | pavucontrol | volume | audio mixer |
+| Mic | mute | pin panel | | mic volume | audio mixer |
+| Brightness | | | | brightness | |
+| Battery | pin panel | | | | power detail |
+| Bell | history + mark read | do not disturb | | | history |
+| Power | power menu | | | | |
+
+Popouts open on a short dwell and stay while the pointer is over the trigger or
+the panel. Clicking pins one open.
+
+## Keybinds
+
+Set in `~/.config/hypr/modules/keybinds.conf`:
+
+- `Super+Ctrl+L` — lock
+- `Super+P` — power menu
+- `Super+Shift+N` — toggle do not disturb
+- `Super+Ctrl+Shift+N` — clear notifications
+- `Super+Shift+R` — reload the shell
+
+All of these go through Quickshell's IPC, so they work from any script:
+
+```sh
+qs ipc call shell lock
+qs ipc call shell power
+qs ipc call shell toggleDnd
+qs ipc call shell clearNotifications
+qs ipc call shell reloadConfig
+```
+
+## Data sources
+
+Hardware telemetry comes from `fluxo`, whose hardware modules are configured to
+emit raw pipe-delimited values rather than display strings — `services/Sys.qml`
+parses them and does its own formatting. The previous human-readable Waybar
+formats are preserved in `~/.config/fluxo/config.toml.waybar-bak`.
+
+Everything else is native: PipeWire for audio, BlueZ for bluetooth,
+NetworkManager for Wi-Fi, UPower for battery, MPRIS for media, and Quickshell's
+own notification server. These are event-driven, so the bar and OSD react the
+instant a volume key is pressed instead of on the next poll.
+
+## Lock screen
+
+Authenticates through `/etc/pam.d/hyprlock`, which on this box is just
+`auth include login`. The visuals live in `lock/LockFace.qml` so they can be
+styled in an ordinary window (`qs -p lockpreview.qml`) without locking the
+session.
+
+If the shell ever dies while the session is locked, the compositor keeps the
+session secured by protocol and there is no unlocker left to talk to — recovery
+means restarting Hyprland from a TTY.
+
+## What was retired
+
+- `waybar.service` — disabled. Its config is still in `~/.config/waybar`.
+- `dunst.service` — masked, so it cannot be D-Bus activated and steal
+ `org.freedesktop.Notifications`. Undo with
+ `systemctl --user unmask dunst.service`.
+- `hyprlock` — no longer bound. `hyprlock.conf` is untouched.
+
+## Optional
+
+`power-profiles-daemon` is not installed (TLP is), so the power-profile
+switching in `services/Battery.qml` stays dormant. Nothing else depends on it.
+
+The display face is Adwaita Sans Black Italic, which is what was already on the
+box. A heavy condensed grotesque — Anton, Archivo Black, Oswald — would land
+closer to the real Persona 5 type. Change `Theme.fontDisplay` and everything
+follows.
diff --git a/quickshell/assets/halftone.png b/quickshell/assets/halftone.png
new file mode 100644
index 0000000..78d7dd6
Binary files /dev/null and b/quickshell/assets/halftone.png differ
diff --git a/quickshell/assets/noise.png b/quickshell/assets/noise.png
new file mode 100644
index 0000000..c2a1e39
Binary files /dev/null and b/quickshell/assets/noise.png differ
diff --git a/quickshell/assets/scanline.png b/quickshell/assets/scanline.png
new file mode 100644
index 0000000..3bbcefd
Binary files /dev/null and b/quickshell/assets/scanline.png differ
diff --git a/quickshell/assets/stripes.png b/quickshell/assets/stripes.png
new file mode 100644
index 0000000..bdd28e2
Binary files /dev/null and b/quickshell/assets/stripes.png differ
diff --git a/quickshell/bar/ActiveWindow.qml b/quickshell/bar/ActiveWindow.qml
new file mode 100644
index 0000000..e3a48a2
--- /dev/null
+++ b/quickshell/bar/ActiveWindow.qml
@@ -0,0 +1,75 @@
+import QtQuick
+import Quickshell
+import Quickshell.Hyprland
+import "root:/config"
+import "root:/components"
+
+// Focused window title, glitching on every change the way a Persona 5 caption
+// snaps into place.
+Item {
+ id: root
+
+ required property var screenRef
+
+ readonly property HyprlandMonitor monitor: Hyprland.monitorFor(screenRef)
+ readonly property HyprlandToplevel toplevel: Hyprland.activeToplevel
+
+ // Only speak for the monitor this bar lives on.
+ readonly property bool mine: toplevel !== null
+ && (root.monitor === null || toplevel.monitor === null
+ || toplevel.monitor.id === root.monitor.id)
+
+ readonly property string title: mine && toplevel.title ? toplevel.title : "—"
+
+ // The bar shrinks this when the three clusters would otherwise collide.
+ property real maxWidth: 240
+
+ implicitWidth: Math.min(maxWidth, Math.max(70, label.implicitWidth + 4))
+ implicitHeight: 20
+
+ Behavior on implicitWidth {
+ NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutExpo }
+ }
+
+ Marquee {
+ id: label
+ anchors.fill: parent
+ text: root.title
+ color: root.mine ? Theme.subtext : Theme.muted
+ pixelSize: Theme.fsSmall
+ family: Theme.fontDisplay
+ italic: true
+ weight: Font.DemiBold
+ }
+
+ onTitleChanged: flash.restart()
+
+ // A quick crimson wipe under the title on focus change.
+ Rectangle {
+ id: wipe
+ anchors.bottom: parent.bottom
+ height: 2
+ width: 0
+ color: Theme.accent
+ opacity: 0
+ }
+
+ SequentialAnimation {
+ id: flash
+ ParallelAnimation {
+ NumberAnimation {
+ target: wipe; property: "width"
+ from: 0; to: root.width
+ duration: Theme.durBase; easing.type: Easing.OutExpo
+ }
+ NumberAnimation {
+ target: wipe; property: "opacity"
+ from: 0; to: 1; duration: Theme.durFast
+ }
+ }
+ NumberAnimation {
+ target: wipe; property: "opacity"
+ to: 0; duration: Theme.durSlow
+ }
+ }
+}
diff --git a/quickshell/bar/Bar.qml b/quickshell/bar/Bar.qml
new file mode 100644
index 0000000..6820955
--- /dev/null
+++ b/quickshell/bar/Bar.qml
@@ -0,0 +1,159 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// One bar per monitor.
+//
+// Layout is three clusters: identity and system state on the left, the clock
+// and player dead centre, controls on the right. The centre is genuinely
+// centred, so the left cluster's window title is capped at whatever space is
+// actually left over — otherwise the clusters overlap on a 1080p panel.
+PanelWindow {
+ id: root
+
+ required property var modelData
+ screen: modelData
+
+ WlrLayershell.layer: WlrLayer.Top
+ WlrLayershell.namespace: "takemi-bar"
+
+ anchors {
+ top: true
+ left: true
+ right: true
+ }
+
+ margins {
+ top: Theme.barMargin
+ left: Theme.barMargin
+ right: Theme.barMargin
+ }
+
+ implicitHeight: Theme.barHeight
+ exclusiveZone: Theme.barHeight + Theme.barMargin
+ color: "transparent"
+
+ // Half the space not taken by the centre cluster, minus breathing room.
+ readonly property real sideBudget:
+ (width - centreCluster.width) / 2 - Theme.barGap * 2
+
+ // ------------------------------------------------------------------ left
+ Row {
+ id: leftCluster
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.barGap
+
+ Sigil {
+ id: sigil
+ screenRef: root.modelData
+ }
+
+ BarPill {
+ id: spacesPill
+ accent: Theme.primary
+ padding: Theme.padS
+ interactive: false
+ implicitWidth: spacesRow.implicitWidth + contentPad * 2
+
+ Row {
+ id: spacesRow
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ spacing: Theme.padM
+
+ Workspaces {
+ id: spaces
+ anchors.verticalCenter: parent.verticalCenter
+ screenRef: root.modelData
+ }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 1
+ height: 18
+ color: Theme.alpha(Theme.outline, 0.8)
+ }
+
+ ActiveWindow {
+ anchors.verticalCenter: parent.verticalCenter
+ screenRef: root.modelData
+
+ // Everything else on this side is fixed width, so the title
+ // gets whatever remains.
+ maxWidth: Math.max(70, root.sideBudget
+ - sigil.implicitWidth
+ - resources.implicitWidth
+ - spaces.implicitWidth
+ - Theme.barGap * 2
+ - Theme.padM * 3
+ - spacesPill.leanInset)
+ }
+ }
+ }
+
+ Resources {
+ id: resources
+ screenRef: root.modelData
+ }
+ }
+
+ // ---------------------------------------------------------------- centre
+ Row {
+ id: centreCluster
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.barGap
+
+ MediaPill {
+ screenRef: root.modelData
+ }
+
+ Clock {
+ screenRef: root.modelData
+ }
+ }
+
+ // ----------------------------------------------------------------- right
+ Row {
+ id: rightCluster
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.barGap
+
+ Tray {
+ screenRef: root.modelData
+ }
+
+ Status {
+ screenRef: root.modelData
+ }
+
+ NotifButton {
+ screenRef: root.modelData
+ }
+
+ PowerButton {
+ screenRef: root.modelData
+ }
+ }
+
+ // A hairline of crimson under the whole bar — the Persona 5 underscore.
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 1
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop { position: 0.0; color: "transparent" }
+ GradientStop { position: 0.18; color: Theme.alpha(Theme.accent, 0.55) }
+ GradientStop { position: 0.5; color: Theme.alpha(Theme.primary, 0.35) }
+ GradientStop { position: 0.82; color: Theme.alpha(Theme.accent, 0.55) }
+ GradientStop { position: 1.0; color: "transparent" }
+ }
+ }
+}
diff --git a/quickshell/bar/BarPill.qml b/quickshell/bar/BarPill.qml
new file mode 100644
index 0000000..92662bd
--- /dev/null
+++ b/quickshell/bar/BarPill.qml
@@ -0,0 +1,107 @@
+import QtQuick
+import "root:/config"
+import "root:/components"
+
+// Shared chrome for every bar module: a leaning slab that lifts and lights up
+// under the pointer, and forwards clicks and scrolls to the widget.
+Item {
+ id: root
+
+ property color accent: Theme.primary
+ property bool active: false
+ property bool slash: false
+ property real lean: Theme.skew
+ property real padding: Theme.padM
+ property bool interactive: true
+ property bool halftone: false
+
+ readonly property bool hovered: hover.hovered
+ readonly property real leanInset: panel.leanInset
+
+ // Total horizontal inset a pill must add on top of its content width.
+ readonly property real contentPad: panel.contentPad
+ readonly property alias panelItem: panel
+
+ default property alias content: holder.data
+
+ signal clicked()
+ signal rightClicked()
+ signal middleClicked()
+ signal scrolled(real delta)
+
+ implicitHeight: Theme.barHeight
+
+ P5Panel {
+ id: panel
+ anchors.fill: parent
+ lean: root.lean
+ padding: root.padding
+ halftone: root.halftone
+ halftoneColor: root.accent
+ sheen: true
+
+ fill: root.active ? Theme.surfaceAlt : Theme.surface
+ fillOpacity: root.hovered ? 0.98 : 0.88
+ border: root.hovered || root.active
+ ? Theme.alpha(root.accent, 0.9)
+ : Theme.alpha(Theme.outline, 0.7)
+ borderWidth: root.hovered || root.active ? Theme.stroke : 1
+
+ slash: root.slash || root.active
+ slashColor: root.accent
+
+ Behavior on fillOpacity {
+ NumberAnimation { duration: Theme.durFast }
+ }
+ Behavior on border {
+ ColorAnimation { duration: Theme.durBase }
+ }
+
+ Item {
+ id: holder
+ anchors.fill: parent
+ }
+ }
+
+ // Lift toward the pointer. Subtle, but it makes the bar feel alive.
+ transform: Translate {
+ y: root.hovered && root.interactive ? -2 : 0
+
+ Behavior on y {
+ NumberAnimation {
+ duration: Theme.durBase
+ easing.type: Easing.OutBack
+ }
+ }
+ }
+
+ HoverHandler {
+ id: hover
+ enabled: root.interactive
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ enabled: root.interactive
+ acceptedButtons: Qt.LeftButton
+ onTapped: root.clicked()
+ }
+
+ TapHandler {
+ enabled: root.interactive
+ acceptedButtons: Qt.RightButton
+ onTapped: root.rightClicked()
+ }
+
+ TapHandler {
+ enabled: root.interactive
+ acceptedButtons: Qt.MiddleButton
+ onTapped: root.middleClicked()
+ }
+
+ WheelHandler {
+ enabled: root.interactive
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: event => root.scrolled(event.angleDelta.y)
+ }
+}
diff --git a/quickshell/bar/Clock.qml b/quickshell/bar/Clock.qml
new file mode 100644
index 0000000..08c0ebe
--- /dev/null
+++ b/quickshell/bar/Clock.qml
@@ -0,0 +1,101 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/popouts"
+
+// Centre-stage clock. Hovering drops the calendar.
+BarPill {
+ id: root
+
+ required property var screenRef
+
+ accent: Theme.accent
+ slash: true
+ padding: Theme.padS
+ implicitWidth: layout.implicitWidth + contentPad * 2
+
+ SystemClock {
+ id: clock
+ precision: SystemClock.Seconds
+ }
+
+ onClicked: calendar.pinned = !calendar.pinned
+
+ Row {
+ id: layout
+ anchors.centerIn: parent
+ spacing: Theme.padM
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: -1
+
+ Text {
+ text: Qt.formatDateTime(clock.date, "ddd").toUpperCase()
+ color: Theme.accent
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 2.5
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ text: Qt.formatDateTime(clock.date, "dd MMM").toUpperCase()
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 1
+ renderType: Text.NativeRendering
+ }
+ }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 2
+ height: 22
+ color: Theme.alpha(Theme.accent, 0.6)
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 22 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ SplitText {
+ id: timeText
+ anchors.verticalCenter: parent.verticalCenter
+ text: Qt.formatDateTime(clock.date, "HH:mm")
+ pixelSize: Theme.fsTitle
+ split: 1.4
+ }
+
+ // Seconds, kept quiet so they read as a ticking detail rather than noise.
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.verticalCenterOffset: 4
+ text: Qt.formatDateTime(clock.date, "ss")
+ color: Theme.alpha(Theme.primary, 0.8)
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+ }
+
+ // Glitch the time on the hour, because it should feel like something happened.
+ property int lastHour: -1
+ Connections {
+ target: clock
+ function onDateChanged() {
+ const h = clock.date.getHours();
+ if (root.lastHour !== -1 && h !== root.lastHour) timeText.glitch();
+ root.lastHour = h;
+ }
+ }
+
+ CalendarPopout {
+ id: calendar
+ anchorItem: root
+ triggerHovered: root.hovered
+ clockDate: clock.date
+ }
+}
diff --git a/quickshell/bar/MediaPill.qml b/quickshell/bar/MediaPill.qml
new file mode 100644
index 0000000..aa33344
--- /dev/null
+++ b/quickshell/bar/MediaPill.qml
@@ -0,0 +1,185 @@
+import QtQuick
+import QtQuick.Shapes
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+import "root:/popouts"
+
+// Now-playing module. Collapses to nothing when no player is around.
+BarPill {
+ id: root
+
+ required property var screenRef
+
+ accent: Theme.primary
+ padding: Theme.padS
+ visible: Media.hasPlayer || width > 1
+
+ implicitWidth: Media.hasPlayer
+ ? layout.implicitWidth + contentPad * 2
+ : 0
+
+ Behavior on implicitWidth {
+ NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutExpo }
+ }
+
+ clip: true
+
+ onClicked: Media.playPause()
+ onRightClicked: Media.next()
+ onMiddleClicked: Media.raise()
+ onScrolled: delta => delta > 0 ? Media.next() : Media.previous()
+
+ Row {
+ id: layout
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ spacing: Theme.padS
+
+ // Album art, chopped into the house shape.
+ Item {
+ id: artFrame
+ anchors.verticalCenter: parent.verticalCenter
+ width: 24
+ height: 24
+
+ Shape {
+ id: artMask
+ anchors.fill: parent
+ visible: false
+ layer.enabled: true
+ preferredRendererType: Shape.CurveRenderer
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ PathPolyline {
+ path: Geom.chamfer(Geom.parallelogram(24, 24, 0.18), 5, [1, 3])
+ }
+ }
+ }
+
+ Image {
+ id: art
+ anchors.fill: parent
+ source: Media.artUrl
+ fillMode: Image.PreserveAspectCrop
+ cache: true
+ asynchronous: true
+ visible: status === Image.Ready
+ layer.enabled: true
+ layer.effect: MaskedArt { maskItem: artMask }
+ }
+
+ // Fallback: the source app's glyph.
+ Icon {
+ anchors.centerIn: parent
+ visible: art.status !== Image.Ready
+ text: Media.sourceIcon
+ font.pixelSize: Theme.fsLarge
+ color: Theme.primary
+ pulsing: Media.playing
+ pulseScale: 1.12
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 0
+
+ Marquee {
+ width: 130
+ height: 14
+ text: Media.title || Media.identity
+ color: Theme.text
+ pixelSize: Theme.fsSmall
+ family: Theme.fontDisplay
+ weight: Font.DemiBold
+ italic: true
+ }
+
+ Marquee {
+ width: 130
+ height: 12
+ text: Media.artist || Media.album || Media.identity
+ color: Theme.muted
+ pixelSize: Theme.fsMicro
+ family: Theme.fontMono
+ }
+ }
+
+ // Play state, as a two-bar equaliser that freezes when paused.
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ Repeater {
+ model: 3
+
+ Rectangle {
+ required property int index
+ width: 3
+ height: 6
+ anchors.verticalCenter: parent.verticalCenter
+ color: Media.playing ? Theme.primary : Theme.muted
+
+ SequentialAnimation on height {
+ running: Media.playing
+ loops: Animation.Infinite
+ NumberAnimation {
+ to: 4 + (index * 5) % 13
+ duration: 320 + index * 90
+ easing.type: Easing.InOutSine
+ }
+ NumberAnimation {
+ to: 14 - (index * 4) % 9
+ duration: 280 + index * 70
+ easing.type: Easing.InOutSine
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durBase }
+ }
+ }
+ }
+ }
+ }
+
+ // Track progress, hairline along the bottom of the pill.
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 3
+ anchors.left: parent.left
+ anchors.leftMargin: root.leanInset
+ width: Math.max(0, (root.width - root.leanInset * 2) * Media.progress)
+ height: 2
+ color: Theme.accent
+ visible: Media.canSeek
+
+ Behavior on width {
+ NumberAnimation { duration: 480; easing.type: Easing.OutQuad }
+ }
+ }
+
+ onVisibleChanged: if (visible) titleGlitch.restart()
+
+ Connections {
+ target: Media
+ function onTrackChanged() { titleGlitch.restart(); }
+ }
+
+ SequentialAnimation {
+ id: titleGlitch
+ NumberAnimation {
+ target: artFrame; property: "rotation"
+ from: -8; to: 0; duration: Theme.durSlow; easing.type: Easing.OutBack
+ }
+ }
+
+ MediaPopout {
+ id: popout
+ anchorItem: root
+ triggerHovered: root.hovered && Media.hasPlayer
+ }
+}
diff --git a/quickshell/bar/NotifButton.qml b/quickshell/bar/NotifButton.qml
new file mode 100644
index 0000000..bd2572e
--- /dev/null
+++ b/quickshell/bar/NotifButton.qml
@@ -0,0 +1,75 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+import "root:/popouts"
+
+// Notification bell with an unread badge. Click opens the history, right click
+// toggles do-not-disturb.
+BarPill {
+ id: root
+
+ required property var screenRef
+
+ accent: Notifs.dnd ? Theme.muted : Theme.primary
+ padding: Theme.padS
+ implicitWidth: Theme.barHeight - 4 + contentPad
+
+ onClicked: {
+ popout.pinned = !popout.pinned;
+ Notifs.markRead();
+ }
+ onRightClicked: Notifs.toggleDnd()
+
+ Icon {
+ id: bell
+ anchors.centerIn: parent
+ text: Notifs.icon
+ font.pixelSize: Theme.fsLarge
+ color: {
+ if (Notifs.dnd) return Theme.muted;
+ if (Notifs.hasUnread) return Theme.accent;
+ return root.hovered ? Theme.glow : Theme.primary;
+ }
+ }
+
+ // Unread badge, chopped like everything else.
+ Rectangle {
+ visible: Notifs.unread > 0 && !Notifs.dnd
+ anchors.right: parent.right
+ anchors.rightMargin: 2
+ anchors.top: parent.top
+ anchors.topMargin: 4
+ width: Math.max(12, badge.implicitWidth + 5)
+ height: 12
+ color: Theme.accent
+
+ Text {
+ id: badge
+ anchors.centerIn: parent
+ text: Notifs.unread > 9 ? "9+" : Notifs.unread
+ color: Theme.text
+ font.family: Theme.fontMono
+ font.pixelSize: 8
+ font.weight: Font.Bold
+ renderType: Text.NativeRendering
+ }
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 12 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ Connections {
+ target: Notifs
+ function onPopupRequested() { bell.bump(); }
+ }
+
+ NotifPopout {
+ id: popout
+ anchorItem: root
+ triggerHovered: root.hovered
+ }
+}
diff --git a/quickshell/bar/PowerButton.qml b/quickshell/bar/PowerButton.qml
new file mode 100644
index 0000000..be6a56d
--- /dev/null
+++ b/quickshell/bar/PowerButton.qml
@@ -0,0 +1,42 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Far right. Opens the full-screen power menu.
+BarPill {
+ id: root
+
+ required property var screenRef
+
+ accent: Theme.accent
+ slash: true
+ padding: Theme.padS
+ active: Actions.powerMenuOpen
+ implicitWidth: Theme.barHeight - 4 + contentPad
+
+ onClicked: Actions.togglePowerMenu()
+
+ Icon {
+ id: glyph
+ anchors.centerIn: parent
+ text: ""
+ font.pixelSize: Theme.fsLarge
+ color: root.hovered || Actions.powerMenuOpen ? Theme.accent : Theme.subtext
+ }
+
+ // A crimson halo that swells under the pointer.
+ Rectangle {
+ anchors.centerIn: glyph
+ z: -1
+ width: root.hovered ? 26 : 0
+ height: width
+ radius: width / 2
+ color: Theme.alpha(Theme.accent, 0.18)
+
+ Behavior on width {
+ NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
+ }
+ }
+}
diff --git a/quickshell/bar/Resources.qml b/quickshell/bar/Resources.qml
new file mode 100644
index 0000000..32253a2
--- /dev/null
+++ b/quickshell/bar/Resources.qml
@@ -0,0 +1,114 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+import "root:/popouts"
+
+// Compact CPU / MEM / GPU readout. Each metric is a number over a segmented
+// meter; hovering opens the full gauge cluster.
+BarPill {
+ id: root
+
+ required property var screenRef
+
+ accent: Theme.heat(Math.max(Sys.cpuUsage, Sys.memPercent))
+ padding: Theme.padS
+ implicitWidth: layout.implicitWidth + contentPad * 2
+
+ onClicked: Actions.openSystemMonitor()
+
+ Row {
+ id: layout
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ spacing: Theme.padM
+
+ Metric {
+ label: "CPU"
+ value: Sys.cpuUsage
+ readout: Math.round(Sys.cpuUsage) + "%"
+ }
+
+ Divider {}
+
+ Metric {
+ label: "MEM"
+ value: Sys.memPercent
+ readout: Sys.memUsed.toFixed(1) + "G"
+ }
+
+ Divider { visible: Sys.gpuAvailable }
+
+ Metric {
+ visible: Sys.gpuAvailable
+ label: "GPU"
+ value: Sys.gpuUsage
+ readout: Math.round(Sys.gpuUsage) + "%"
+ }
+ }
+
+ component Divider: Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 1
+ height: 18
+ color: Theme.alpha(Theme.outline, 0.8)
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 18 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ component Metric: Column {
+ id: metric
+
+ property string label: ""
+ property real value: 0
+ property string readout: ""
+
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 1
+
+ Row {
+ spacing: 4
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: metric.label
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 1.5
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: metric.readout
+ color: Theme.heat(metric.value)
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ font.weight: Font.DemiBold
+ renderType: Text.NativeRendering
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durBase }
+ }
+ }
+ }
+
+ MeterBar {
+ value: metric.value
+ segments: 9
+ segmentWidth: 3
+ spacing: 2
+ height: 5
+ }
+ }
+
+ SysPopout {
+ id: popout
+ anchorItem: root
+ triggerHovered: root.hovered
+ }
+}
diff --git a/quickshell/bar/Sigil.qml b/quickshell/bar/Sigil.qml
new file mode 100644
index 0000000..5e6dbf0
--- /dev/null
+++ b/quickshell/bar/Sigil.qml
@@ -0,0 +1,45 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// The house mark, far left. Click launches the app menu; right click reloads
+// the shell, which is handy while tweaking the config.
+BarPill {
+ id: root
+
+ required property var screenRef
+
+ accent: Theme.accent
+ slash: true
+ padding: Theme.padS
+ lean: Theme.skew
+ implicitWidth: Theme.barHeight + contentPad
+
+ onClicked: Actions.launch("fuzzel")
+ onRightClicked: Actions.reloadShell()
+
+ Icon {
+ id: mark
+ anchors.centerIn: parent
+ text: ""
+ font.pixelSize: Theme.fsTitle
+ color: root.hovered ? Theme.accent : Theme.primary
+ }
+
+ // Spin the mark on click, then settle.
+ Connections {
+ target: root
+ function onClicked() { spin.restart(); }
+ }
+
+ SequentialAnimation {
+ id: spin
+ NumberAnimation {
+ target: mark; property: "rotation"
+ from: 0; to: 360; duration: Theme.durSlow
+ easing.type: Easing.OutExpo
+ }
+ }
+}
diff --git a/quickshell/bar/Status.qml b/quickshell/bar/Status.qml
new file mode 100644
index 0000000..e93a4dd
--- /dev/null
+++ b/quickshell/bar/Status.qml
@@ -0,0 +1,115 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+import "root:/popouts"
+
+// Network, bluetooth, audio, mic, backlight and battery, in one slab. Each
+// glyph carries its own popout.
+BarPill {
+ id: root
+
+ required property var screenRef
+
+ accent: Theme.primary
+ padding: Theme.padS
+ interactive: false
+ implicitWidth: layout.implicitWidth + contentPad * 2
+
+ Row {
+ id: layout
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ spacing: Theme.padM
+
+ StatusIcon {
+ id: netIcon
+ icon: Net.icon
+ label: Sys.netUp ? Sys.rate(Sys.netRx + Sys.netTx) : ""
+ labelWidth: 62
+ tint: Theme.primary
+ dim: !Sys.netUp
+ onClicked: netPopout.pinned = !netPopout.pinned
+ onRightClicked: Net.toggleWifi()
+ onMiddleClicked: Actions.openNetworkSettings()
+ }
+
+ StatusIcon {
+ id: btIcon
+ icon: Bt.icon
+ tint: Bt.connected.length > 0 ? Theme.blue : Theme.primary
+ dim: !Bt.enabled
+ onClicked: btPopout.pinned = !btPopout.pinned
+ onRightClicked: Bt.toggle()
+ }
+
+ StatusIcon {
+ id: volIcon
+ icon: Audio.icon
+ label: Audio.muted ? "" : Audio.volumePercent + "%"
+ labelWidth: Audio.muted ? 0 : 34
+ alert: Audio.muted
+ onClicked: Audio.toggleMute()
+ onRightClicked: audioPopout.pinned = !audioPopout.pinned
+ onMiddleClicked: Actions.openAudioSettings()
+ onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05)
+ }
+
+ StatusIcon {
+ id: micIcon
+ icon: Audio.micIcon
+ alert: Audio.micMuted
+ onClicked: Audio.toggleMicMute()
+ onRightClicked: audioPopout.pinned = !audioPopout.pinned
+ onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05))
+ }
+
+ StatusIcon {
+ id: brightIcon
+ visible: Brightness.available
+ icon: Brightness.icon
+ label: Math.round(Brightness.percent) + "%"
+ labelWidth: 34
+ tint: Theme.warn
+ onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5)
+ }
+
+ StatusIcon {
+ id: battIcon
+ visible: Battery.available
+ icon: Battery.icon
+ label: Math.round(Battery.percent) + "%"
+ labelWidth: 34
+ tint: Battery.tint
+ alert: Battery.critical
+ pulsing: Battery.critical
+ onClicked: battPopout.pinned = !battPopout.pinned
+ }
+ }
+
+ NetPopout {
+ id: netPopout
+ anchorItem: netIcon
+ triggerHovered: netIcon.hovered
+ }
+
+ BtPopout {
+ id: btPopout
+ anchorItem: btIcon
+ triggerHovered: btIcon.hovered
+ }
+
+ AudioPopout {
+ id: audioPopout
+ anchorItem: volIcon
+ triggerHovered: volIcon.hovered || micIcon.hovered
+ }
+
+ BatteryPopout {
+ id: battPopout
+ anchorItem: battIcon
+ triggerHovered: battIcon.hovered
+ visible: Battery.available && shown
+ }
+}
diff --git a/quickshell/bar/StatusIcon.qml b/quickshell/bar/StatusIcon.qml
new file mode 100644
index 0000000..8b6d44b
--- /dev/null
+++ b/quickshell/bar/StatusIcon.qml
@@ -0,0 +1,108 @@
+import QtQuick
+import "root:/config"
+import "root:/components"
+
+// One indicator inside the status cluster: a glyph, an optional readout, and a
+// crimson underline that wipes in on hover.
+Item {
+ id: root
+
+ property string icon: ""
+ property string label: ""
+ property color tint: Theme.primary
+ property bool alert: false
+ property bool dim: false
+ property bool pulsing: false
+
+ // Fixed label width. Readouts that change width constantly (throughput,
+ // percentages) would otherwise shove the whole cluster around.
+ property real labelWidth: 0
+
+ readonly property bool hovered: hover.hovered
+
+ signal clicked()
+ signal rightClicked()
+ signal middleClicked()
+ signal scrolled(real delta)
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: Theme.barHeight - Theme.padS * 2
+
+ Row {
+ id: layout
+ anchors.centerIn: parent
+ spacing: 5
+
+ Icon {
+ id: glyph
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.icon
+ pulsing: root.pulsing
+ color: {
+ if (root.alert) return Theme.accent;
+ if (root.dim) return Theme.muted;
+ if (root.hovered) return Theme.glow;
+ return root.tint;
+ }
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.label !== ""
+ text: root.label
+ width: root.labelWidth > 0 ? root.labelWidth : implicitWidth
+ horizontalAlignment: Text.AlignRight
+ elide: Text.ElideRight
+ color: root.dim ? Theme.muted : Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ font.weight: Font.DemiBold
+ renderType: Text.NativeRendering
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durBase }
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ height: 2
+ width: root.hovered ? layout.implicitWidth : 0
+ color: root.alert ? Theme.accent : root.tint
+
+ Behavior on width {
+ NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo }
+ }
+ }
+
+ scale: root.hovered ? 1.06 : 1.0
+ Behavior on scale {
+ NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
+ }
+
+ onAlertChanged: if (alert) glyph.bump()
+
+ HoverHandler {
+ id: hover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ acceptedButtons: Qt.LeftButton
+ onTapped: root.clicked()
+ }
+ TapHandler {
+ acceptedButtons: Qt.RightButton
+ onTapped: root.rightClicked()
+ }
+ TapHandler {
+ acceptedButtons: Qt.MiddleButton
+ onTapped: root.middleClicked()
+ }
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: event => root.scrolled(event.angleDelta.y)
+ }
+}
diff --git a/quickshell/bar/Tray.qml b/quickshell/bar/Tray.qml
new file mode 100644
index 0000000..17d2bff
--- /dev/null
+++ b/quickshell/bar/Tray.qml
@@ -0,0 +1,93 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.SystemTray
+import Quickshell.Widgets
+import "root:/config"
+import "root:/components"
+
+// StatusNotifier tray. Left click activates, right click opens the app's own
+// menu, middle click is the secondary action.
+BarPill {
+ id: root
+
+ required property var screenRef
+
+ accent: Theme.blue
+ padding: Theme.padS
+ interactive: false
+ visible: SystemTray.items.values.length > 0
+ implicitWidth: visible ? layout.implicitWidth + contentPad * 2 : 0
+
+ Row {
+ id: layout
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ spacing: Theme.padM
+
+ Repeater {
+ model: SystemTray.items
+
+ Item {
+ id: entry
+
+ required property SystemTrayItem modelData
+
+ width: 18
+ height: 18
+ anchors.verticalCenter: parent.verticalCenter
+
+ IconImage {
+ id: img
+ anchors.fill: parent
+ source: entry.modelData.icon
+ asynchronous: true
+ opacity: itemHover.hovered ? 1.0 : 0.82
+
+ Behavior on opacity {
+ NumberAnimation { duration: Theme.durFast }
+ }
+ }
+
+ scale: itemHover.hovered ? 1.18 : 1.0
+ Behavior on scale {
+ NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
+ }
+
+ HoverHandler {
+ id: itemHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ acceptedButtons: Qt.LeftButton
+ onTapped: {
+ if (entry.modelData.onlyMenu) {
+ menuAnchor.open();
+ } else {
+ entry.modelData.activate();
+ }
+ }
+ }
+
+ TapHandler {
+ acceptedButtons: Qt.RightButton
+ onTapped: menuAnchor.open()
+ }
+
+ TapHandler {
+ acceptedButtons: Qt.MiddleButton
+ onTapped: entry.modelData.secondaryActivate()
+ }
+
+ QsMenuAnchor {
+ id: menuAnchor
+ menu: entry.modelData.menu
+ anchor.item: entry
+ anchor.edges: Edges.Bottom
+ anchor.gravity: Edges.Bottom
+ anchor.margins.top: Theme.popoutGap
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/bar/Workspaces.qml b/quickshell/bar/Workspaces.qml
new file mode 100644
index 0000000..fb80ec6
--- /dev/null
+++ b/quickshell/bar/Workspaces.qml
@@ -0,0 +1,138 @@
+import QtQuick
+import Quickshell
+import Quickshell.Hyprland
+import "root:/config"
+import "root:/components"
+
+// Workspace indicator: leaning ticks that stretch open when focused. Occupied
+// workspaces glow teal, urgent ones snap to crimson.
+Item {
+ id: root
+
+ required property var screenRef
+
+ readonly property HyprlandMonitor monitor: Hyprland.monitorFor(screenRef)
+ readonly property int focusedId: monitor?.activeWorkspace?.id ?? -1
+
+ // Live workspaces on this monitor, keyed by id, ignoring special workspaces.
+ readonly property var occupancy: {
+ const map = {};
+ for (const w of (Hyprland.workspaces?.values ?? [])) {
+ if (w.id < 1) continue;
+ if (root.monitor && w.monitor && w.monitor.id !== root.monitor.id) continue;
+ map[w.id] = {
+ count: w.toplevels?.values?.length ?? 0,
+ urgent: w.urgent,
+ fullscreen: w.hasFullscreen
+ };
+ }
+ return map;
+ }
+
+ // Always show 1..5, plus anything beyond that which is actually in use.
+ readonly property var slots: {
+ const ids = [1, 2, 3, 4, 5];
+ for (const key in root.occupancy) {
+ const id = parseInt(key);
+ if (ids.indexOf(id) === -1) ids.push(id);
+ }
+ return ids.sort((a, b) => a - b);
+ }
+
+ implicitWidth: row.implicitWidth
+ implicitHeight: 20
+
+ Row {
+ id: row
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 6
+
+ Repeater {
+ model: root.slots
+
+ Item {
+ id: pip
+
+ required property var modelData
+ readonly property int wsId: modelData
+ readonly property var info: root.occupancy[wsId]
+ readonly property bool occupied: info !== undefined && info.count > 0
+ readonly property bool urgent: info !== undefined && info.urgent
+ readonly property bool focused: root.focusedId === wsId
+ readonly property bool hovered: pipHover.hovered
+
+ width: focused ? 34 : (occupied ? 16 : 10)
+ height: 18
+
+ Behavior on width {
+ NumberAnimation {
+ duration: Theme.durSlow
+ easing.type: Easing.OutBack
+ easing.overshoot: 1.6
+ }
+ }
+
+ Rectangle {
+ id: tick
+ anchors.fill: parent
+ color: {
+ if (pip.urgent) return Theme.accent;
+ if (pip.focused) return Theme.primary;
+ if (pip.hovered) return Theme.glow;
+ if (pip.occupied) return Theme.alpha(Theme.primary, 0.55);
+ return Theme.alpha(Theme.outline, 0.75);
+ }
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(
+ 1, -Theme.skew, 0, pip.height * Theme.skew,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1)
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durBase }
+ }
+ }
+
+ // The focused workspace wears its number.
+ Text {
+ anchors.centerIn: parent
+ opacity: pip.focused ? 1 : 0
+ visible: opacity > 0.01
+ text: pip.wsId
+ color: Theme.base
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsSmall
+ font.weight: Theme.weightDisplay
+ font.italic: true
+ renderType: Text.NativeRendering
+
+ Behavior on opacity {
+ NumberAnimation { duration: Theme.durBase }
+ }
+ }
+
+ // Fullscreen marker.
+ Rectangle {
+ visible: pip.info !== undefined && pip.info.fullscreen && !pip.focused
+ width: 4; height: 4
+ color: Theme.accent
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.bottom
+ anchors.topMargin: 1
+ }
+
+ HoverHandler {
+ id: pipHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: Hyprland.dispatch("workspace " + pip.wsId)
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/components/Gauge.qml b/quickshell/components/Gauge.qml
new file mode 100644
index 0000000..76b3af5
--- /dev/null
+++ b/quickshell/components/Gauge.qml
@@ -0,0 +1,120 @@
+import QtQuick
+import QtQuick.Shapes
+import "root:/config"
+
+// Radial gauge used across the resource popout. Sweeps a 270° arc with a
+// tick-marked rail behind it and the reading set in the middle.
+Item {
+ id: root
+
+ property real value: 0 // 0..100
+ property string label: ""
+ property string readout: Math.round(value) + "%"
+ property string sub: ""
+ property color arcColor: Theme.heat(value)
+ property real thickness: 7
+ property real startAngle: 135
+ property real sweep: 270
+ property int ticks: 24
+
+ implicitWidth: 96
+ implicitHeight: 96
+
+ readonly property real _r: Math.min(width, height) / 2 - thickness / 2 - 4
+ readonly property real _cx: width / 2
+ readonly property real _cy: height / 2
+
+ // Rail
+ Shape {
+ anchors.fill: parent
+ preferredRendererType: Shape.CurveRenderer
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: Theme.alpha(Theme.outline, 0.5)
+ strokeWidth: root.thickness
+ capStyle: ShapePath.FlatCap
+ PathAngleArc {
+ centerX: root._cx; centerY: root._cy
+ radiusX: root._r; radiusY: root._r
+ startAngle: root.startAngle
+ sweepAngle: root.sweep
+ }
+ }
+ }
+
+ // Tick marks around the rail.
+ Repeater {
+ model: root.ticks
+ Rectangle {
+ required property int index
+ readonly property real a: (root.startAngle + root.sweep * (index / (root.ticks - 1))) * Math.PI / 180
+ readonly property real rr: root._r + root.thickness / 2 + 3
+ width: 2
+ height: index % 4 === 0 ? 6 : 3
+ color: Theme.alpha(Theme.primary, index % 4 === 0 ? 0.55 : 0.25)
+ x: root._cx + Math.cos(a) * rr - width / 2
+ y: root._cy + Math.sin(a) * rr - height / 2
+ rotation: (root.startAngle + root.sweep * (index / (root.ticks - 1))) + 90
+ }
+ }
+
+ // Value arc
+ Shape {
+ anchors.fill: parent
+ preferredRendererType: Shape.CurveRenderer
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: root.arcColor
+ strokeWidth: root.thickness
+ capStyle: ShapePath.FlatCap
+ PathAngleArc {
+ id: valueArc
+ centerX: root._cx; centerY: root._cy
+ radiusX: root._r; radiusY: root._r
+ startAngle: root.startAngle
+ sweepAngle: root.sweep * Math.max(0, Math.min(100, root.value)) / 100
+
+ Behavior on sweepAngle {
+ NumberAnimation {
+ duration: Theme.durSlow
+ easing.type: Easing.OutExpo
+ }
+ }
+ }
+ }
+ }
+
+ Column {
+ anchors.centerIn: parent
+ spacing: -2
+
+ SplitText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root.readout
+ pixelSize: Theme.fsLarge
+ color: Theme.text
+ split: 1.2
+ splitOpacity: 0.6
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root.label
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 2
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: root.sub !== ""
+ text: root.sub
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+ }
+}
diff --git a/quickshell/components/Geom.qml b/quickshell/components/Geom.qml
new file mode 100644
index 0000000..79ab20e
--- /dev/null
+++ b/quickshell/components/Geom.qml
@@ -0,0 +1,100 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+
+// Polygon maths for the Persona 5 panel language: everything leans, and corners
+// get chopped rather than rounded.
+Singleton {
+ id: root
+
+ // A right-leaning parallelogram filling w x h. `lean` is the horizontal
+ // offset applied to the top edge, as a fraction of height.
+ function parallelogram(w: real, h: real, lean: real): var {
+ const off = h * lean;
+ return [
+ Qt.point(off, 0),
+ Qt.point(w, 0),
+ Qt.point(w - off, h),
+ Qt.point(0, h)
+ ];
+ }
+
+ // Same, leaning the other way.
+ function parallelogramL(w: real, h: real, lean: real): var {
+ const off = h * lean;
+ return [
+ Qt.point(0, 0),
+ Qt.point(w - off, 0),
+ Qt.point(w, h),
+ Qt.point(off, h)
+ ];
+ }
+
+ function rect(w: real, h: real): var {
+ return [Qt.point(0, 0), Qt.point(w, 0), Qt.point(w, h), Qt.point(0, h)];
+ }
+
+ // Move `dist` px from `p` toward `q`.
+ function toward(p: point, q: point, dist: real): point {
+ const dx = q.x - p.x;
+ const dy = q.y - p.y;
+ const len = Math.hypot(dx, dy);
+ if (len < 0.0001) return p;
+ const t = Math.min(dist, len / 2) / len;
+ return Qt.point(p.x + dx * t, p.y + dy * t);
+ }
+
+ // Chop the listed corners of a polygon. `corners` is an array of indices;
+ // pass null to chop every corner.
+ function chamfer(pts: var, size: real, corners: var): var {
+ if (size <= 0) return root.closed(pts);
+ const n = pts.length;
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const p = pts[i];
+ if (corners !== null && corners.indexOf(i) === -1) {
+ out.push(p);
+ continue;
+ }
+ out.push(root.toward(p, pts[(i - 1 + n) % n], size));
+ out.push(root.toward(p, pts[(i + 1) % n], size));
+ }
+ return root.closed(out);
+ }
+
+ function closed(pts: var): var {
+ if (pts.length === 0) return pts;
+ const out = pts.slice();
+ out.push(pts[0]);
+ return out;
+ }
+
+ // Shrink a polygon toward its centroid — used for inner strokes.
+ function inset(pts: var, amount: real): var {
+ let cx = 0, cy = 0;
+ for (const p of pts) { cx += p.x; cy += p.y; }
+ cx /= pts.length; cy /= pts.length;
+ return pts.map(p => {
+ const dx = cx - p.x, dy = cy - p.y;
+ const len = Math.hypot(dx, dy);
+ if (len < 0.0001) return p;
+ const t = amount / len;
+ return Qt.point(p.x + dx * t, p.y + dy * t);
+ });
+ }
+
+ // A jagged "torn paper" edge, the Persona 5 speech-bubble signature.
+ // Returns a polygon `w` x `h` whose bottom edge is serrated.
+ function torn(w: real, h: real, teeth: int, depth: real): var {
+ const out = [Qt.point(0, 0), Qt.point(w, 0)];
+ const step = w / teeth;
+ for (let i = 0; i < teeth; i++) {
+ const x1 = w - i * step - step / 2;
+ const x2 = w - (i + 1) * step;
+ out.push(Qt.point(x1, h));
+ out.push(Qt.point(x2, h - depth));
+ }
+ return out;
+ }
+}
diff --git a/quickshell/components/Icon.qml b/quickshell/components/Icon.qml
new file mode 100644
index 0000000..6782e24
--- /dev/null
+++ b/quickshell/components/Icon.qml
@@ -0,0 +1,45 @@
+import QtQuick
+import "root:/config"
+
+// Nerd Font glyph with an optional pulse, used for every status indicator.
+Text {
+ id: root
+
+ property bool pulsing: false
+ property real pulseScale: 1.18
+
+ font.family: Theme.fontIcon
+ font.pixelSize: Theme.fsLarge
+ color: Theme.primary
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignHCenter
+ renderType: Text.NativeRendering
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durBase }
+ }
+
+ SequentialAnimation on scale {
+ running: root.pulsing
+ loops: Animation.Infinite
+ alwaysRunToEnd: true
+ NumberAnimation { to: root.pulseScale; duration: 620; easing.type: Easing.OutExpo }
+ NumberAnimation { to: 1.0; duration: 620; easing.type: Easing.InOutQuad }
+ }
+
+ function bump() {
+ bumpAnim.restart();
+ }
+
+ SequentialAnimation {
+ id: bumpAnim
+ NumberAnimation {
+ target: root; property: "scale"
+ to: 1.35; duration: Theme.durFast; easing.type: Easing.OutExpo
+ }
+ NumberAnimation {
+ target: root; property: "scale"
+ to: 1.0; duration: Theme.durSlow; easing.type: Easing.OutBack
+ }
+ }
+}
diff --git a/quickshell/components/Marquee.qml b/quickshell/components/Marquee.qml
new file mode 100644
index 0000000..2fcb668
--- /dev/null
+++ b/quickshell/components/Marquee.qml
@@ -0,0 +1,62 @@
+import QtQuick
+import "root:/config"
+
+// Horizontally scrolling text that only moves when it actually overflows, and
+// pauses at each end so titles stay readable.
+Item {
+ id: root
+
+ property string text: ""
+ property int pixelSize: Theme.fsSmall
+ property string family: Theme.fontDisplay
+ property int weight: Theme.weightBody
+ property bool italic: false
+ property color color: Theme.text
+ property real speed: 26 // px per second
+ property int pause: 1400 // ms held at each end
+ property bool running: true
+
+ readonly property bool overflowing: label.implicitWidth > width + 1
+
+ clip: true
+ implicitHeight: label.implicitHeight
+ implicitWidth: label.implicitWidth
+
+ Text {
+ id: label
+ y: 0
+ height: root.height
+ text: root.text
+ color: root.color
+ verticalAlignment: Text.AlignVCenter
+ font.family: root.family
+ font.pixelSize: root.pixelSize
+ font.weight: root.weight
+ font.italic: root.italic
+ renderType: Text.NativeRendering
+ }
+
+ SequentialAnimation {
+ id: scroll
+ running: root.running && root.overflowing && root.visible
+ loops: Animation.Infinite
+
+ PauseAnimation { duration: root.pause }
+ NumberAnimation {
+ target: label; property: "x"
+ from: 0; to: Math.min(0, root.width - label.implicitWidth)
+ duration: Math.max(1, Math.abs(root.width - label.implicitWidth) / root.speed * 1000)
+ easing.type: Easing.Linear
+ }
+ PauseAnimation { duration: root.pause }
+ NumberAnimation {
+ target: label; property: "x"
+ to: 0
+ duration: Theme.durSlow
+ easing.type: Easing.OutExpo
+ }
+ }
+
+ onOverflowingChanged: if (!overflowing) label.x = 0
+ onTextChanged: { label.x = 0; scroll.restart(); }
+}
diff --git a/quickshell/components/MaskedArt.qml b/quickshell/components/MaskedArt.qml
new file mode 100644
index 0000000..ad71156
--- /dev/null
+++ b/quickshell/components/MaskedArt.qml
@@ -0,0 +1,14 @@
+import QtQuick
+import QtQuick.Effects
+
+// Layer effect that clips whatever it is applied to into the silhouette of
+// `maskItem` — used to chop album art into the shell's panel shape.
+MultiEffect {
+ property Item maskItem
+
+ maskEnabled: maskItem !== null
+ maskSource: maskItem
+ // Hard edge — the chopped corners should read as cuts, not fades.
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 0.0
+}
diff --git a/quickshell/components/MeterBar.qml b/quickshell/components/MeterBar.qml
new file mode 100644
index 0000000..39fbef1
--- /dev/null
+++ b/quickshell/components/MeterBar.qml
@@ -0,0 +1,59 @@
+import QtQuick
+import "root:/config"
+
+// A segmented load meter — slanted ticks that light up left to right. Reads as
+// a gauge at a glance and keeps the panel language consistent.
+Item {
+ id: root
+
+ property real value: 0 // 0..100
+ property int segments: 14
+ property real segmentWidth: 4
+ property real spacing: 3
+ property color activeColor: Theme.heat(value)
+ property color idleColor: Theme.alpha(Theme.outline, 0.55)
+ property real lean: Theme.skew
+
+ readonly property int litCount: Math.round(Math.max(0, Math.min(100, value)) / 100 * segments)
+
+ implicitWidth: segments * segmentWidth + (segments - 1) * spacing
+ implicitHeight: 14
+
+ Row {
+ anchors.fill: parent
+ spacing: root.spacing
+
+ Repeater {
+ model: root.segments
+
+ Item {
+ required property int index
+ width: root.segmentWidth
+ height: root.height
+
+ Rectangle {
+ width: root.segmentWidth
+ height: root.height
+ color: parent.index < root.litCount ? root.activeColor : root.idleColor
+
+ // Peak segments glow, so a pegged CPU is visible peripherally.
+ opacity: parent.index < root.litCount
+ ? (parent.index >= root.segments - 2 ? 1.0 : 0.9)
+ : 1.0
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(
+ 1, -root.lean, 0, root.height * root.lean,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1)
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durBase }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/components/NotifCard.qml b/quickshell/components/NotifCard.qml
new file mode 100644
index 0000000..6c2ef9a
--- /dev/null
+++ b/quickshell/components/NotifCard.qml
@@ -0,0 +1,191 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.Notifications
+import "root:/config"
+import "root:/services"
+
+// One notification, used both in the history list and as a floating toast.
+Item {
+ id: root
+
+ required property var notif
+ property bool showActions: true
+
+ readonly property color tone: root.notif
+ ? Notifs.urgencyColor(root.notif.urgency)
+ : Theme.primary
+
+ signal dismissed()
+
+ implicitHeight: panel.height
+
+ P5Panel {
+ id: panel
+ width: parent.width
+ height: layout.implicitHeight + Theme.padM * 2
+ lean: 0.05
+ chop: 12
+ fill: Theme.surface
+ fillOpacity: 0.97
+ border: Theme.alpha(root.tone, hover.hovered ? 0.95 : 0.5)
+ slash: true
+ slashColor: root.tone
+ slashWidth: 4
+ halftone: root.notif?.urgency === NotificationUrgency.Critical
+ halftoneColor: Theme.accent
+ halftoneOpacity: 0.12
+ padding: Theme.padM
+
+ Column {
+ id: layout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 3
+
+ // App line
+ Row {
+ spacing: Theme.padS
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Notifs.appIconFor(root.notif)
+ color: root.tone
+ font.pixelSize: Theme.fsBody
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: (root.notif?.appName || "System").toUpperCase()
+ color: root.tone
+ font.family: Theme.fontMono
+ font.pixelSize: 8
+ font.letterSpacing: 2
+ renderType: Text.NativeRendering
+ }
+ }
+
+ Text {
+ width: parent.width
+ text: root.notif?.summary || ""
+ color: Theme.text
+ elide: Text.ElideRight
+ maximumLineCount: 2
+ wrapMode: Text.WordWrap
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsBody
+ font.weight: Font.Black
+ font.italic: true
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ width: parent.width
+ visible: text !== ""
+ text: root.notif?.body || ""
+ color: Theme.subtext
+ elide: Text.ElideRight
+ maximumLineCount: 4
+ wrapMode: Text.WordWrap
+ textFormat: Text.StyledText
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsSmall
+ renderType: Text.NativeRendering
+ }
+
+ // Inline image, when the sender supplied one.
+ Image {
+ visible: source != "" && status === Image.Ready
+ source: root.notif?.image || ""
+ sourceSize.height: 90
+ fillMode: Image.PreserveAspectFit
+ asynchronous: true
+ }
+
+ Row {
+ visible: root.showActions && (root.notif?.actions?.length ?? 0) > 0
+ spacing: Theme.padS
+ topPadding: Theme.padS
+
+ Repeater {
+ model: root.notif?.actions ?? []
+
+ Item {
+ id: actionChip
+ required property var modelData
+
+ implicitWidth: actionLabel.implicitWidth + Theme.padM
+ implicitHeight: 22
+
+ Rectangle {
+ anchors.fill: parent
+ color: actionHover.hovered
+ ? Theme.alpha(root.tone, 0.3)
+ : Theme.alpha(root.tone, 0.1)
+ border.width: 1
+ border.color: Theme.alpha(root.tone, 0.6)
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 22 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ Text {
+ id: actionLabel
+ anchors.centerIn: parent
+ text: actionChip.modelData.text
+ color: Theme.text
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsMicro
+ font.italic: true
+ font.weight: Font.DemiBold
+ renderType: Text.NativeRendering
+ }
+
+ HoverHandler {
+ id: actionHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: {
+ actionChip.modelData.invoke();
+ root.dismissed();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Close affordance.
+ Icon {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ visible: hover.hovered
+ text: ""
+ color: closeHover.hovered ? Theme.accent : Theme.muted
+ font.pixelSize: Theme.fsBody
+
+ HoverHandler {
+ id: closeHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: root.dismissed()
+ }
+ }
+ }
+
+ HoverHandler {
+ id: hover
+ }
+
+ // Middle click dismisses, matching the old dunst muscle memory.
+ TapHandler {
+ acceptedButtons: Qt.MiddleButton
+ onTapped: root.dismissed()
+ }
+}
diff --git a/quickshell/components/P5Button.qml b/quickshell/components/P5Button.qml
new file mode 100644
index 0000000..8890547
--- /dev/null
+++ b/quickshell/components/P5Button.qml
@@ -0,0 +1,106 @@
+import QtQuick
+import "root:/config"
+
+// Chopped, leaning button. On hover it lunges toward the pointer and flips to
+// crimson-on-white; on press it snaps flat.
+Item {
+ id: root
+
+ property string text: ""
+ property string icon: ""
+ property color accent: Theme.primary
+ property color hoverFill: Theme.accent
+ property bool destructive: false
+ property real lean: Theme.skew
+ property real lunge: 6
+ property bool wide: false
+
+ signal clicked()
+
+ readonly property bool hovered: hover.hovered
+ readonly property bool pressed: tap.pressed
+
+ implicitWidth: wide ? 260 : (row.implicitWidth + panel.contentPad * 2)
+ implicitHeight: 44
+
+ P5Panel {
+ id: panel
+ anchors.fill: parent
+ lean: root.lean
+ padding: Theme.padS
+ fill: root.hovered ? (root.destructive ? root.hoverFill : Theme.text) : Theme.surface
+ fillOpacity: root.hovered ? 1.0 : 0.9
+ border: root.hovered ? "transparent" : Theme.alpha(root.accent, 0.55)
+ slash: !root.hovered
+ slashColor: root.destructive ? Theme.accent : root.accent
+ halftone: root.hovered
+ halftoneColor: root.destructive ? Theme.text : Theme.base
+ halftoneOpacity: 0.14
+
+ Behavior on fillOpacity {
+ NumberAnimation { duration: Theme.durFast }
+ }
+
+ Row {
+ id: row
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ spacing: Theme.padM
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.icon !== ""
+ text: root.icon
+ font.pixelSize: Theme.fsTitle
+ color: root.hovered
+ ? (root.destructive ? Theme.text : Theme.base)
+ : root.accent
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.text
+ color: root.hovered
+ ? (root.destructive ? Theme.text : Theme.base)
+ : Theme.text
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsBody
+ font.weight: Theme.weightDisplay
+ font.italic: true
+ font.letterSpacing: 1.5
+ font.capitalization: Font.AllUppercase
+ renderType: Text.NativeRendering
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durFast }
+ }
+ }
+ }
+ }
+
+ transform: Translate {
+ x: root.pressed ? 0 : (root.hovered ? root.lunge : 0)
+
+ Behavior on x {
+ NumberAnimation {
+ duration: Theme.durBase
+ easing.type: Easing.OutBack
+ }
+ }
+ }
+
+ scale: root.pressed ? 0.97 : 1.0
+ Behavior on scale {
+ NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutExpo }
+ }
+
+ HoverHandler {
+ id: hover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ id: tap
+ onTapped: root.clicked()
+ }
+}
diff --git a/quickshell/components/P5Panel.qml b/quickshell/components/P5Panel.qml
new file mode 100644
index 0000000..bc119b0
--- /dev/null
+++ b/quickshell/components/P5Panel.qml
@@ -0,0 +1,165 @@
+import QtQuick
+import QtQuick.Shapes
+import QtQuick.Effects
+import "root:/config"
+import "root:/components"
+
+// The base surface for everything in this shell: a leaning, corner-chopped slab
+// with an optional halftone wash and a crimson slash along its leading edge.
+Item {
+ id: root
+
+ property color fill: Theme.surface
+ property real fillOpacity: 0.94
+ property color border: Theme.outline
+ property real borderWidth: Theme.stroke
+
+ // Lean of the vertical edges, as a fraction of height. 0 = upright.
+ property real lean: Theme.skew
+ property bool leanLeft: false
+
+ // Corner chop size, and which corners get chopped (indices into the
+ // polygon, clockwise from the top-left). Default chops the two corners that
+ // read as "cut" against the lean.
+ property real chop: Theme.cut
+ property var chopCorners: [1, 3]
+
+ // Crimson slash down the leading edge — the Persona 5 tell.
+ property bool slash: false
+ property color slashColor: Theme.accent
+ property real slashWidth: 4
+
+ // Screen-printed dot wash.
+ property bool halftone: false
+ property color halftoneColor: Theme.primary
+ property real halftoneOpacity: 0.07
+
+ // Inner glow along the top edge, for panels that should feel backlit.
+ property bool sheen: false
+
+ readonly property real leanInset: height * Math.abs(lean)
+ readonly property var polygon: leanLeft
+ ? Geom.parallelogramL(width, height, lean)
+ : Geom.parallelogram(width, height, lean)
+
+ // Child content lives inside `contentItem`, inset on both sides far enough
+ // to clear the slanted edges. Panels do not auto-size: callers set implicit
+ // sizes from their own layout (`row.implicitWidth + contentPad * 2`), which
+ // keeps the shape maths out of every binding loop.
+ default property alias content: contentHolder.data
+ readonly property alias contentItem: contentHolder
+ property real padding: Theme.padM
+
+ readonly property real contentPad: padding + leanInset
+
+ Shape {
+ id: body
+ anchors.fill: parent
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: false
+
+ ShapePath {
+ fillColor: Theme.alpha(root.fill, root.fillOpacity)
+ strokeColor: root.borderWidth > 0 ? root.border : "transparent"
+ strokeWidth: root.borderWidth
+ joinStyle: ShapePath.MiterJoin
+ capStyle: ShapePath.FlatCap
+
+ PathPolyline {
+ path: Geom.chamfer(root.polygon, root.chop, root.chopCorners)
+ }
+ }
+ }
+
+ // Halftone wash, masked to the panel silhouette.
+ Loader {
+ anchors.fill: parent
+ active: root.halftone
+ asynchronous: true
+ sourceComponent: Item {
+ Shape {
+ id: maskShape
+ anchors.fill: parent
+ visible: false
+ layer.enabled: true
+ layer.smooth: true
+ preferredRendererType: Shape.CurveRenderer
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ PathPolyline {
+ path: Geom.chamfer(root.polygon, root.chop, root.chopCorners)
+ }
+ }
+ }
+
+ Image {
+ anchors.fill: parent
+ source: Theme.texHalftone
+ fillMode: Image.Tile
+ opacity: root.halftoneOpacity
+ smooth: false
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ colorization: 1.0
+ colorizationColor: root.halftoneColor
+ maskEnabled: true
+ maskSource: maskShape
+ }
+ }
+ }
+ }
+
+ // Backlit top edge.
+ Loader {
+ anchors.fill: parent
+ active: root.sheen
+ sourceComponent: Shape {
+ preferredRendererType: Shape.CurveRenderer
+ ShapePath {
+ strokeColor: "transparent"
+ fillGradient: LinearGradient {
+ x1: 0; y1: 0; x2: 0; y2: root.height
+ GradientStop { position: 0.0; color: Theme.alpha(Theme.primary, 0.16) }
+ GradientStop { position: 0.55; color: "transparent" }
+ }
+ PathPolyline {
+ path: Geom.chamfer(root.polygon, root.chop, root.chopCorners)
+ }
+ }
+ }
+ }
+
+ // Leading-edge slash.
+ Loader {
+ anchors.fill: parent
+ active: root.slash
+ sourceComponent: Shape {
+ preferredRendererType: Shape.CurveRenderer
+ ShapePath {
+ fillColor: root.slashColor
+ strokeColor: "transparent"
+ PathPolyline {
+ path: {
+ const off = root.height * root.lean;
+ const w = root.slashWidth;
+ return root.leanLeft
+ ? Geom.closed([Qt.point(0, 0), Qt.point(w, 0),
+ Qt.point(off + w, root.height), Qt.point(off, root.height)])
+ : Geom.closed([Qt.point(off, 0), Qt.point(off + w, 0),
+ Qt.point(w, root.height), Qt.point(0, root.height)]);
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: contentHolder
+ anchors.fill: parent
+ anchors.leftMargin: root.contentPad
+ anchors.rightMargin: root.contentPad
+ anchors.topMargin: root.padding
+ anchors.bottomMargin: root.padding
+ }
+}
diff --git a/quickshell/components/P5Slider.qml b/quickshell/components/P5Slider.qml
new file mode 100644
index 0000000..2cb351a
--- /dev/null
+++ b/quickshell/components/P5Slider.qml
@@ -0,0 +1,99 @@
+import QtQuick
+import "root:/config"
+
+// Leaning slider with a chopped handle. Drag or click anywhere on the rail.
+Item {
+ id: root
+
+ property real value: 0 // 0..1
+ property color accent: Theme.primary
+ property bool enabledControl: true
+
+ signal moved(real value)
+
+ implicitHeight: 18
+ implicitWidth: 200
+
+ readonly property real _lean: Theme.skew * height
+
+ // Rail
+ Rectangle {
+ id: rail
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 6
+ color: Theme.alpha(Theme.outline, 0.7)
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 6 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ // Fill
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ width: Math.max(0, Math.min(1, root.value)) * root.width
+ height: 6
+ color: root.enabledControl ? root.accent : Theme.muted
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 6 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+
+ Behavior on width {
+ enabled: !drag.active
+ NumberAnimation { duration: Theme.durFast }
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durBase }
+ }
+ }
+
+ // Handle
+ Rectangle {
+ id: handle
+ width: 6
+ height: hover.hovered || drag.active ? 18 : 14
+ color: root.enabledControl ? Theme.text : Theme.muted
+ anchors.verticalCenter: parent.verticalCenter
+ x: Math.max(0, Math.min(1, root.value)) * root.width - width / 2
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, height * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+
+ Behavior on height {
+ NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutBack }
+ }
+ Behavior on x {
+ enabled: !drag.active
+ NumberAnimation { duration: Theme.durFast }
+ }
+ }
+
+ HoverHandler {
+ id: hover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: event => root.moved(Math.max(0, Math.min(1, event.position.x / root.width)))
+ }
+
+ DragHandler {
+ id: drag
+ target: null
+ xAxis.enabled: true
+ yAxis.enabled: false
+ onCentroidChanged: {
+ if (!active) return;
+ root.moved(Math.max(0, Math.min(1, centroid.position.x / root.width)));
+ }
+ }
+}
diff --git a/quickshell/components/Popout.qml b/quickshell/components/Popout.qml
new file mode 100644
index 0000000..f920541
--- /dev/null
+++ b/quickshell/components/Popout.qml
@@ -0,0 +1,135 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+
+// Hover-driven panel that hangs off a bar item. Opens on a short dwell, stays
+// open while the pointer is over either the trigger or the panel itself, and
+// can be pinned with a click. Slides in from behind the bar with an overshoot.
+PopupWindow {
+ id: root
+
+ required property Item anchorItem
+ property bool triggerHovered: false
+ property bool pinned: false
+ property int showDelay: 140
+ property int hideDelay: 200
+ property int fromEdge: Edges.Bottom
+ property real contentWidth: Theme.popoutWidth
+ property real contentHeight: 200
+
+ // Extra surface around the content so the entry overshoot and glow are not
+ // clipped by the window edge.
+ readonly property int bleed: 24
+
+ default property alias body: contentHolder.data
+
+ readonly property bool wantsOpen: triggerHovered || panelHover.hovered || pinned
+
+ signal dismissedFully()
+
+ anchor.item: anchorItem
+ anchor.edges: fromEdge
+ anchor.gravity: fromEdge
+ anchor.adjustment: PopupAdjustment.SlideX
+ anchor.margins.top: Theme.popoutGap
+ anchor.margins.bottom: Theme.popoutGap
+
+ implicitWidth: contentWidth + bleed * 2
+ implicitHeight: contentHeight + bleed * 2
+ color: "transparent"
+ visible: shown
+
+ property bool shown: false
+
+ onWantsOpenChanged: {
+ if (wantsOpen) {
+ hideTimer.stop();
+ showTimer.restart();
+ } else {
+ showTimer.stop();
+ hideTimer.restart();
+ }
+ }
+
+ Timer {
+ id: showTimer
+ interval: root.showDelay
+ onTriggered: root.open()
+ }
+
+ Timer {
+ id: hideTimer
+ interval: root.hideDelay
+ onTriggered: root.dismiss()
+ }
+
+ function open() {
+ exitAnim.stop();
+ shown = true;
+ enterAnim.restart();
+ }
+
+ function dismiss() {
+ if (!shown) return;
+ enterAnim.stop();
+ exitAnim.restart();
+ }
+
+ Item {
+ id: contentHolder
+ x: root.bleed
+ y: root.bleed
+ width: root.contentWidth
+ height: root.contentHeight
+
+ opacity: 0
+ transformOrigin: Item.Top
+ scale: 0.94
+
+ // The handler has to live on the content item, not the window: a
+ // HoverHandler parented to the PopupWindow itself latches on and the
+ // panel never learns the pointer left.
+ HoverHandler {
+ id: panelHover
+ }
+ }
+
+ ParallelAnimation {
+ id: enterAnim
+ NumberAnimation {
+ target: contentHolder; property: "opacity"
+ to: 1.0; duration: Theme.durBase; easing.type: Easing.OutExpo
+ }
+ NumberAnimation {
+ target: contentHolder; property: "scale"
+ to: 1.0; duration: Theme.durSlow; easing.type: Easing.OutBack
+ }
+ NumberAnimation {
+ target: contentHolder; property: "y"
+ from: root.bleed - 14; to: root.bleed
+ duration: Theme.durSlow; easing.type: Easing.OutBack
+ }
+ }
+
+ ParallelAnimation {
+ id: exitAnim
+ NumberAnimation {
+ target: contentHolder; property: "opacity"
+ to: 0.0; duration: Theme.durFast; easing.type: Easing.InQuad
+ }
+ NumberAnimation {
+ target: contentHolder; property: "scale"
+ to: 0.96; duration: Theme.durFast; easing.type: Easing.InQuad
+ }
+ NumberAnimation {
+ target: contentHolder; property: "y"
+ to: root.bleed - 8; duration: Theme.durFast; easing.type: Easing.InQuad
+ }
+ onFinished: {
+ root.shown = false;
+ root.pinned = false;
+ contentHolder.y = root.bleed;
+ root.dismissedFully();
+ }
+ }
+}
diff --git a/quickshell/components/SplitText.qml b/quickshell/components/SplitText.qml
new file mode 100644
index 0000000..503aad0
--- /dev/null
+++ b/quickshell/components/SplitText.qml
@@ -0,0 +1,96 @@
+import QtQuick
+import "root:/config"
+
+// Display text with the wallpaper's RGB split baked in: a crimson and a cyan
+// ghost sitting a pixel off the white body. Drives most headings in the shell.
+Item {
+ id: root
+
+ property string text: ""
+ property int pixelSize: Theme.fsLarge
+ property int weight: Theme.weightDisplay
+ property string family: Theme.fontDisplay
+ property bool italic: true
+ property color color: Theme.text
+ property real split: 1.6
+ property real splitOpacity: 0.85
+ property int elide: Text.ElideNone
+ property int horizontalAlignment: Text.AlignLeft
+ property real letterSpacing: 0.5
+
+ implicitWidth: body.implicitWidth
+ implicitHeight: body.implicitHeight
+
+ Text {
+ id: ghostRed
+ anchors.fill: parent
+ anchors.leftMargin: -root.split
+ anchors.topMargin: root.split * 0.4
+ text: root.text
+ color: Theme.splitRed
+ opacity: root.splitOpacity
+ elide: root.elide
+ horizontalAlignment: root.horizontalAlignment
+ verticalAlignment: Text.AlignVCenter
+ font.family: root.family
+ font.pixelSize: root.pixelSize
+ font.weight: root.weight
+ font.italic: root.italic
+ font.letterSpacing: root.letterSpacing
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ id: ghostCyan
+ anchors.fill: parent
+ anchors.leftMargin: root.split
+ anchors.topMargin: -root.split * 0.4
+ text: root.text
+ color: Theme.splitCyan
+ opacity: root.splitOpacity
+ elide: root.elide
+ horizontalAlignment: root.horizontalAlignment
+ verticalAlignment: Text.AlignVCenter
+ font.family: root.family
+ font.pixelSize: root.pixelSize
+ font.weight: root.weight
+ font.italic: root.italic
+ font.letterSpacing: root.letterSpacing
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ id: body
+ anchors.fill: parent
+ text: root.text
+ color: root.color
+ elide: root.elide
+ horizontalAlignment: root.horizontalAlignment
+ verticalAlignment: Text.AlignVCenter
+ font.family: root.family
+ font.pixelSize: root.pixelSize
+ font.weight: root.weight
+ font.italic: root.italic
+ font.letterSpacing: root.letterSpacing
+ renderType: Text.NativeRendering
+ }
+
+ // Kick the ghosts outward briefly — used on track changes, mode flips, etc.
+ function glitch() {
+ glitchAnim.restart();
+ }
+
+ SequentialAnimation {
+ id: glitchAnim
+ NumberAnimation {
+ target: root; property: "split"
+ to: 5.5; duration: Theme.durFast
+ easing.type: Easing.OutExpo
+ }
+ NumberAnimation {
+ target: root; property: "split"
+ to: 1.6; duration: Theme.durSlow
+ easing.type: Easing.OutBack
+ }
+ }
+}
diff --git a/quickshell/components/TintEffect.qml b/quickshell/components/TintEffect.qml
new file mode 100644
index 0000000..63dc603
--- /dev/null
+++ b/quickshell/components/TintEffect.qml
@@ -0,0 +1,11 @@
+import QtQuick
+import QtQuick.Effects
+
+// Layer effect that recolours whatever it is applied to — used to tint the
+// white texture tiles without shipping a copy per colour.
+MultiEffect {
+ property color tintColor: "white"
+
+ colorization: 1.0
+ colorizationColor: tintColor
+}
diff --git a/quickshell/config/Theme.qml b/quickshell/config/Theme.qml
new file mode 100644
index 0000000..13245e0
--- /dev/null
+++ b/quickshell/config/Theme.qml
@@ -0,0 +1,101 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+
+// Takemi palette: cold teal/cyan over near-black, with Persona 5 crimson as the
+// alert/accent voice. Derived from ~/.wallpapers/takemi_2.jpg.
+Singleton {
+ id: root
+
+ // ---------------------------------------------------------------- colors
+ readonly property color base: "#04070a"
+ readonly property color mantle: "#080e14"
+ readonly property color surface: "#0d1922"
+ readonly property color surfaceAlt: "#14262f"
+ readonly property color overlay: "#1d3a47"
+ readonly property color outline: "#2b5567"
+
+ readonly property color text: "#e2f4f8"
+ readonly property color subtext: "#93b8c4"
+ readonly property color muted: "#587c8a"
+
+ readonly property color primary: "#34d3e6" // electric teal — the house colour
+ readonly property color primaryDim: "#17879b"
+ readonly property color glow: "#8ef2ff"
+ readonly property color blue: "#3b8fd6"
+ readonly property color deep: "#0d4b5e"
+
+ readonly property color accent: "#ff2d40" // P5 crimson
+ readonly property color accentDim: "#a8121f"
+ readonly property color warn: "#ffb03a"
+ readonly property color ok: "#35e08f"
+
+ // Chromatic aberration pair, straight off the wallpaper's RGB split.
+ readonly property color splitRed: "#ff2b4d"
+ readonly property color splitCyan: "#25e8ff"
+
+ function alpha(c: color, a: real): color {
+ return Qt.rgba(c.r, c.g, c.b, a);
+ }
+
+ // Heat ramp for load/usage readouts.
+ function heat(pct: real): color {
+ if (pct >= 90) return root.accent;
+ if (pct >= 75) return root.warn;
+ if (pct >= 45) return root.glow;
+ return root.primary;
+ }
+
+ // ----------------------------------------------------------------- fonts
+ readonly property string fontDisplay: "Archivo Black"
+ readonly property string fontMono: "JetBrainsMono Nerd Font"
+ readonly property string fontIcon: "JetBrainsMono Nerd Font"
+
+ readonly property int weightDisplay: Font.Black
+ readonly property int weightBody: Font.Medium
+
+ readonly property int fsMicro: 9
+ readonly property int fsSmall: 11
+ readonly property int fsBody: 12
+ readonly property int fsLarge: 15
+ readonly property int fsTitle: 20
+ readonly property int fsHuge: 34
+ readonly property int fsMega: 92
+
+ // -------------------------------------------------------------- geometry
+ readonly property int barHeight: 40
+ readonly property int barMargin: 8
+ readonly property int barGap: 8
+
+ // Horizontal offset per unit of height. tan(14deg) — every panel leans.
+ readonly property real skew: 0.249
+ readonly property int cut: 9 // corner chamfer, in px
+ readonly property int stroke: 2
+
+ readonly property int padS: 6
+ readonly property int padM: 12
+ readonly property int padL: 20
+ readonly property int padXL: 32
+
+ readonly property int popoutWidth: 380
+ readonly property int popoutGap: 10
+
+ // ------------------------------------------------------------ animation
+ // P5 motion is snappy and slightly overshooting — never soft.
+ readonly property int durFast: 110
+ readonly property int durBase: 190
+ readonly property int durSlow: 340
+ readonly property int durLazy: 620
+
+ readonly property var easeSnap: [0.16, 1.0, 0.3, 1.0, 1, 1] // outExpo-ish
+ readonly property var easeBack: [0.34, 1.56, 0.64, 1.0, 1, 1] // overshoot
+ readonly property var easeIn: [0.55, 0.0, 0.9, 0.35, 1, 1]
+
+ // -------------------------------------------------------------- textures
+ readonly property url texHalftone: "root:/assets/halftone.png"
+ readonly property url texStripes: "root:/assets/stripes.png"
+ readonly property url texScanline: "root:/assets/scanline.png"
+
+ readonly property url wallpaper: "file:///home/narl/.wallpapers/takemi_2.jpg"
+}
diff --git a/quickshell/lock/Lock.qml b/quickshell/lock/Lock.qml
new file mode 100644
index 0000000..1c15484
--- /dev/null
+++ b/quickshell/lock/Lock.qml
@@ -0,0 +1,25 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import "root:/services"
+import "root:/lock"
+
+// Session lock. Replaces hyprlock; authenticates through the existing
+// /etc/pam.d/hyprlock stack, which is just `auth include login`.
+//
+// The visuals live in LockFace so they can be exercised in an ordinary window
+// (see lockpreview.qml) without locking the session to test a colour.
+WlSessionLock {
+ id: lock
+
+ locked: Actions.lockRequested
+
+ WlSessionLockSurface {
+ color: "black"
+
+ LockFace {
+ anchors.fill: parent
+ onUnlocked: Actions.lockRequested = false
+ }
+ }
+}
diff --git a/quickshell/lock/LockFace.qml b/quickshell/lock/LockFace.qml
new file mode 100644
index 0000000..41d1d5d
--- /dev/null
+++ b/quickshell/lock/LockFace.qml
@@ -0,0 +1,419 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Services.Pam
+import "root:/config"
+import "root:/components"
+import "root:/services"
+import "root:/lock"
+
+// The lock screen's whole face, kept separate from WlSessionLockSurface so it
+// can be instantiated in an ordinary window for development without actually
+// locking the session.
+Item {
+ id: surface
+
+ // Emitted once PAM has accepted the password.
+ signal unlocked()
+
+ property string entry: ""
+ property string status: ""
+ property bool failed: false
+ property bool busy: pam.active
+
+ Rectangle {
+ anchors.fill: parent
+ color: Theme.base
+ }
+
+ // ------------------------------------------------------- background
+ Image {
+ id: wall
+ anchors.fill: parent
+ source: Theme.wallpaper
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: false
+ cache: true
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ blurEnabled: true
+ blur: 0.55
+ blurMax: 48
+ saturation: -0.15
+ brightness: -0.35
+ }
+ }
+
+ // Teal wash, pulling the photo toward the shell's palette.
+ Rectangle {
+ anchors.fill: parent
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: Theme.alpha(Theme.deep, 0.55) }
+ GradientStop { position: 0.6; color: Theme.alpha(Theme.base, 0.75) }
+ GradientStop { position: 1.0; color: Theme.alpha(Theme.base, 0.92) }
+ }
+ }
+
+ Image {
+ anchors.fill: parent
+ source: Theme.texHalftone
+ fillMode: Image.Tile
+ opacity: 0.05
+ smooth: false
+ layer.enabled: true
+ layer.effect: TintEffect { tintColor: Theme.primary }
+ }
+
+ // Crimson stripe band running behind the prompt — the Persona 5 title
+ // treatment. Declared before the auth block so it stays underneath it.
+ Item {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: authBlock.top
+ anchors.topMargin: -6
+ height: 42
+ clip: true
+ opacity: 0.5
+
+ Image {
+ anchors.fill: parent
+ source: Theme.texStripes
+ fillMode: Image.Tile
+ smooth: false
+ layer.enabled: true
+ layer.effect: TintEffect { tintColor: Theme.accent }
+ }
+ }
+
+ // ------------------------------------------------------------ clock
+ SystemClock {
+ id: clock
+ precision: SystemClock.Minutes
+ }
+
+ Column {
+ id: timeBlock
+ anchors.left: parent.left
+ anchors.leftMargin: parent.width * 0.1
+ anchors.top: parent.top
+ anchors.topMargin: parent.height * 0.18
+ spacing: -18
+
+ SplitText {
+ text: Qt.formatDateTime(clock.date, "HH")
+ pixelSize: Theme.fsMega * 1.7
+ split: 5
+ splitOpacity: 0.85
+ }
+
+ SplitText {
+ text: Qt.formatDateTime(clock.date, "mm")
+ pixelSize: Theme.fsMega * 1.7
+ color: Theme.primary
+ split: 5
+ splitOpacity: 0.6
+ }
+
+ Text {
+ text: Qt.formatDateTime(clock.date, "dddd, dd MMMM").toUpperCase()
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsBody
+ font.letterSpacing: 6
+ topPadding: 34
+ renderType: Text.NativeRendering
+ }
+ }
+
+ // ------------------------------------------------------ status rail
+ Column {
+ anchors.right: parent.right
+ anchors.rightMargin: parent.width * 0.1
+ anchors.top: parent.top
+ anchors.topMargin: parent.height * 0.2
+ spacing: Theme.padM
+ width: 300
+
+ RailRow {
+ width: parent.width
+ visible: Battery.available
+ glyph: Battery.icon
+ label: Math.round(Battery.percent) + "%"
+ detail: Battery.timeLabel
+ tone: Battery.tint
+ }
+
+ RailRow {
+ width: parent.width
+ glyph: Net.icon
+ label: Net.label
+ detail: Sys.netUp ? Sys.netIp : "no link"
+ tone: Sys.netUp ? Theme.primary : Theme.muted
+ }
+
+ RailRow {
+ width: parent.width
+ visible: Media.hasPlayer
+ glyph: Media.playing ? "" : ""
+ label: Media.title
+ detail: Media.artist
+ tone: Theme.primary
+ }
+
+ RailRow {
+ width: parent.width
+ visible: Notifs.count > 0
+ glyph: ""
+ label: Notifs.count + " notification" + (Notifs.count === 1 ? "" : "s")
+ detail: Notifs.history.length > 0 ? Notifs.history[0].summary : ""
+ tone: Theme.accent
+ }
+ }
+
+ // ------------------------------------------------------- auth panel
+ Item {
+ id: authBlock
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: parent.height * 0.14
+ width: 460
+ height: 150
+
+ transform: Translate { id: shakeShift }
+
+ SplitText {
+ id: prompt
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ text: surface.failed
+ ? (surface.status || "WRONG").toUpperCase()
+ : (surface.busy ? "CHECKING…" : "WHO GOES THERE")
+ color: surface.failed ? Theme.accent : Theme.text
+ pixelSize: Theme.fsTitle
+ letterSpacing: 4
+ horizontalAlignment: Text.AlignHCenter
+ split: surface.failed ? 4 : 1.6
+ }
+
+ // Password field: one chopped block per character.
+ P5Panel {
+ id: field
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: prompt.bottom
+ anchors.topMargin: Theme.padL
+ width: 380
+ height: 56
+ lean: 0.12
+ chop: 16
+ fill: Theme.mantle
+ fillOpacity: 0.94
+ border: surface.failed
+ ? Theme.accent
+ : (input.activeFocus ? Theme.primary : Theme.outline)
+ borderWidth: 2
+ slash: true
+ slashColor: surface.failed ? Theme.accent : Theme.primary
+ slashWidth: 6
+ halftone: true
+ halftoneColor: surface.failed ? Theme.accent : Theme.primary
+ halftoneOpacity: 0.08
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 6
+ visible: surface.entry.length > 0
+
+ Repeater {
+ model: Math.min(surface.entry.length, 22)
+
+ Rectangle {
+ required property int index
+ width: 10
+ height: 20
+ color: surface.failed ? Theme.accent : Theme.primary
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 20 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+
+ // Each new block snaps in.
+ Component.onCompleted: pop.start()
+ NumberAnimation {
+ id: pop
+ target: parent
+ property: "scale"
+ from: 0.2; to: 1
+ duration: Theme.durBase
+ easing.type: Easing.OutBack
+ }
+ }
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: surface.entry.length === 0 && !surface.busy
+ text: "PASSWORD"
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ font.letterSpacing: 5
+ renderType: Text.NativeRendering
+ }
+
+ // Busy sweep.
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 6
+ width: 60
+ height: 3
+ color: Theme.primary
+ visible: surface.busy
+
+ XAnimator on x {
+ running: surface.busy
+ loops: Animation.Infinite
+ from: 20
+ to: field.width - 80
+ duration: 700
+ easing.type: Easing.InOutQuad
+ }
+ }
+
+ // The real input, invisible but focused.
+ TextInput {
+ id: input
+ anchors.fill: parent
+ opacity: 0
+ focus: true
+ echoMode: TextInput.Password
+ enabled: !surface.busy
+ activeFocusOnTab: true
+
+ onTextChanged: {
+ surface.entry = text;
+ if (surface.failed && text.length === 0) surface.failed = false;
+ }
+
+ onAccepted: surface.submit()
+
+ Keys.onEscapePressed: {
+ input.text = "";
+ surface.failed = false;
+ }
+ }
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: field.bottom
+ anchors.topMargin: Theme.padM
+ spacing: Theme.padS
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""
+ color: Theme.muted
+ font.pixelSize: Theme.fsBody
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Quickshell.env("USER") || "narl"
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ font.letterSpacing: 3
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+
+ // Keep focus on the field no matter what.
+ Timer {
+ interval: 250
+ running: true
+ repeat: true
+ onTriggered: if (!input.activeFocus && !surface.busy) input.forceActiveFocus()
+ }
+
+ function submit() {
+ if (surface.busy || surface.entry.length === 0) return;
+ surface.failed = false;
+ surface.status = "";
+ pam.start();
+ }
+
+ PamContext {
+ id: pam
+
+ config: "hyprlock"
+ user: Quickshell.env("USER") || "narl"
+
+ onPamMessage: {
+ if (pam.responseRequired) pam.respond(surface.entry);
+ else if (pam.message) surface.status = pam.message;
+ }
+
+ onCompleted: result => {
+ if (result === PamResult.Success) {
+ surface.unlocked();
+ input.text = "";
+ surface.entry = "";
+ surface.failed = false;
+ } else {
+ surface.failed = true;
+ if (surface.status === "") surface.status = "Access denied";
+ input.text = "";
+ surface.entry = "";
+ shake.restart();
+ }
+ }
+
+ onError: err => {
+ surface.failed = true;
+ surface.status = "PAM error";
+ input.text = "";
+ surface.entry = "";
+ shake.restart();
+ }
+ }
+
+ SequentialAnimation {
+ id: shake
+ loops: 2
+ NumberAnimation {
+ target: shakeShift; property: "x"
+ to: -16; duration: 55; easing.type: Easing.OutQuad
+ }
+ NumberAnimation {
+ target: shakeShift; property: "x"
+ to: 16; duration: 55; easing.type: Easing.OutQuad
+ }
+ NumberAnimation {
+ target: shakeShift; property: "x"
+ to: 0; duration: 90; easing.type: Easing.OutBack
+ }
+ }
+
+ // Entry flourish when the lock comes up.
+ Component.onCompleted: intro.start()
+
+ ParallelAnimation {
+ id: intro
+ NumberAnimation {
+ target: timeBlock; property: "opacity"
+ from: 0; to: 1; duration: Theme.durLazy; easing.type: Easing.OutExpo
+ }
+ NumberAnimation {
+ target: authBlock; property: "opacity"
+ from: 0; to: 1; duration: Theme.durLazy; easing.type: Easing.OutExpo
+ }
+ NumberAnimation {
+ target: shakeShift; property: "y"
+ from: 40; to: 0; duration: Theme.durLazy; easing.type: Easing.OutBack
+ }
+ }
+}
diff --git a/quickshell/lock/RailRow.qml b/quickshell/lock/RailRow.qml
new file mode 100644
index 0000000..42e1deb
--- /dev/null
+++ b/quickshell/lock/RailRow.qml
@@ -0,0 +1,64 @@
+import QtQuick
+import "root:/config"
+import "root:/components"
+
+// One line of the lock screen's status rail.
+Item {
+ id: rail
+
+ property string glyph: ""
+ property string label: ""
+ property string detail: ""
+ property color tone: Theme.primary
+
+ implicitHeight: visible ? 38 : 0
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 3
+ height: 26
+ color: rail.tone
+ }
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.padM
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.padS
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: rail.glyph
+ color: rail.tone
+ font.pixelSize: Theme.fsLarge
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: -2
+
+ Text {
+ text: rail.label
+ color: Theme.text
+ elide: Text.ElideRight
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsSmall
+ font.italic: true
+ font.weight: Font.DemiBold
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ visible: rail.detail !== ""
+ text: rail.detail
+ color: Theme.muted
+ elide: Text.ElideRight
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+}
diff --git a/quickshell/lockpreview.qml b/quickshell/lockpreview.qml
new file mode 100644
index 0000000..2d167d8
--- /dev/null
+++ b/quickshell/lockpreview.qml
@@ -0,0 +1,23 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/lock"
+
+// Development harness: renders the lock screen in an ordinary window so it can
+// be styled and screenshotted without locking the session.
+//
+// qs -p lockpreview.qml
+//
+// PAM still runs for real here, so a correct password simply closes the window.
+ShellRoot {
+ FloatingWindow {
+ implicitWidth: 1600
+ implicitHeight: 900
+ color: Theme.base
+
+ LockFace {
+ anchors.fill: parent
+ onUnlocked: console.log("PAM accepted — the real lock would release here")
+ }
+ }
+}
diff --git a/quickshell/overlays/Osd.qml b/quickshell/overlays/Osd.qml
new file mode 100644
index 0000000..ac6e303
--- /dev/null
+++ b/quickshell/overlays/Osd.qml
@@ -0,0 +1,182 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Volume / mic / brightness heads-up, bottom centre. Appears on change and
+// fades out on its own.
+PanelWindow {
+ id: root
+
+ required property var modelData
+ screen: modelData
+
+ readonly property bool primaryScreen: Quickshell.screens.length === 0
+ || Quickshell.screens[0] === modelData
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "takemi-osd"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+
+ anchors { bottom: true }
+ margins.bottom: 90
+
+ implicitWidth: 300
+ implicitHeight: 78
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ visible: primaryScreen && (shown || fade.running)
+
+ property bool shown: false
+ property string kind: "volume"
+
+ // Suppress the burst of change signals that fires while services warm up.
+ property bool armed: false
+ Timer {
+ interval: 1500
+ running: true
+ onTriggered: root.armed = true
+ }
+
+ readonly property string glyph: {
+ switch (root.kind) {
+ case "mic": return Audio.micIcon;
+ case "brightness": return Brightness.icon;
+ default: return Audio.icon;
+ }
+ }
+
+ readonly property string title: {
+ switch (root.kind) {
+ case "mic": return Audio.micMuted ? "Mic muted" : "Microphone";
+ case "brightness": return "Brightness";
+ default: return Audio.muted ? "Muted" : "Volume";
+ }
+ }
+
+ readonly property real level: {
+ switch (root.kind) {
+ case "mic": return Audio.micMuted ? 0 : Audio.micVolume;
+ case "brightness": return Brightness.percent / 100;
+ default: return Audio.muted ? 0 : Audio.volume;
+ }
+ }
+
+ readonly property color tone: {
+ switch (root.kind) {
+ case "mic": return Audio.micMuted ? Theme.accent : Theme.blue;
+ case "brightness": return Theme.warn;
+ default: return Audio.muted ? Theme.accent : Theme.primary;
+ }
+ }
+
+ function flash(k: string) {
+ if (!root.armed) return;
+ root.kind = k;
+ root.shown = true;
+ glyphIcon.bump();
+ hold.restart();
+ }
+
+ Timer {
+ id: hold
+ interval: 1600
+ onTriggered: root.shown = false
+ }
+
+ Connections {
+ target: Audio
+ function onVolumeBumped() { root.flash("volume"); }
+ function onMicBumped() { root.flash("mic"); }
+ }
+
+ Connections {
+ target: Brightness
+ function onBumped() { root.flash("brightness"); }
+ }
+
+ P5Panel {
+ id: panel
+ anchors.fill: parent
+ anchors.margins: 4
+ fill: Theme.mantle
+ fillOpacity: 0.97
+ border: Theme.alpha(root.tone, 0.85)
+ borderWidth: Theme.stroke
+ slash: true
+ slashColor: root.tone
+ slashWidth: 5
+ halftone: true
+ halftoneColor: root.tone
+ halftoneOpacity: 0.08
+ sheen: true
+ lean: 0.09
+ chop: 14
+ padding: Theme.padM
+
+ opacity: root.shown ? 1 : 0
+ scale: root.shown ? 1 : 0.9
+
+ Behavior on opacity {
+ NumberAnimation { id: fade; duration: Theme.durBase }
+ }
+ Behavior on scale {
+ NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutBack }
+ }
+
+ Icon {
+ id: glyphIcon
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.glyph
+ color: root.tone
+ font.pixelSize: 30
+ width: 36
+ }
+
+ Column {
+ anchors.left: glyphIcon.right
+ anchors.leftMargin: Theme.padM
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+
+ Row {
+ width: parent.width
+
+ Text {
+ text: root.title.toUpperCase()
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 2
+ renderType: Text.NativeRendering
+ }
+
+ Item {
+ width: parent.width - 90
+ height: 1
+ }
+
+ SplitText {
+ text: Math.round(root.level * 100) + "%"
+ pixelSize: Theme.fsBody
+ split: 1
+ splitOpacity: 0.5
+ }
+ }
+
+ MeterBar {
+ width: parent.width
+ height: 10
+ segments: 20
+ segmentWidth: (width - 19 * 3) / 20
+ spacing: 3
+ value: root.level * 100
+ activeColor: root.tone
+ }
+ }
+ }
+}
diff --git a/quickshell/overlays/PowerMenu.qml b/quickshell/overlays/PowerMenu.qml
new file mode 100644
index 0000000..23aee4b
--- /dev/null
+++ b/quickshell/overlays/PowerMenu.qml
@@ -0,0 +1,285 @@
+import QtQuick
+import QtQuick.Shapes
+import Quickshell
+import Quickshell.Wayland
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Full-screen power menu. Crimson stripes sweep in behind a stack of leaning
+// buttons; Escape or a click on the backdrop dismisses it.
+PanelWindow {
+ id: root
+
+ required property var modelData
+ screen: modelData
+
+ visible: Actions.powerMenuOpen || closeAnim.running
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "takemi-power"
+ WlrLayershell.keyboardFocus: Actions.powerMenuOpen
+ ? WlrKeyboardFocus.Exclusive
+ : WlrKeyboardFocus.None
+
+ anchors { top: true; bottom: true; left: true; right: true }
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+
+ readonly property bool open: Actions.powerMenuOpen
+
+ onOpenChanged: {
+ if (open) {
+ openAnim.restart();
+ } else {
+ closeAnim.restart();
+ }
+ }
+
+ // ------------------------------------------------------------- backdrop
+ Rectangle {
+ id: backdrop
+ anchors.fill: parent
+ color: Theme.alpha(Theme.base, 0.82)
+ opacity: 0
+
+ TapHandler {
+ onTapped: Actions.closePowerMenu()
+ }
+ }
+
+ // Diagonal stripe field sweeping in from the left.
+ Image {
+ id: stripes
+ anchors.fill: parent
+ source: Theme.texStripes
+ fillMode: Image.Tile
+ opacity: 0
+ smooth: false
+
+ layer.enabled: true
+ layer.effect: TintEffect {
+ tintColor: Theme.accent
+ }
+
+ transform: Translate {
+ id: stripeShift
+ x: -root.width
+ }
+ }
+
+ // A crimson wedge behind the buttons.
+ Shape {
+ id: wedge
+ anchors.fill: parent
+ opacity: 0
+ preferredRendererType: Shape.CurveRenderer
+
+ ShapePath {
+ fillColor: Theme.alpha(Theme.accentDim, 0.35)
+ strokeColor: "transparent"
+ PathPolyline {
+ path: [
+ Qt.point(root.width * 0.18, 0),
+ Qt.point(root.width * 0.52, 0),
+ Qt.point(root.width * 0.34, root.height),
+ Qt.point(0, root.height),
+ Qt.point(root.width * 0.18, 0)
+ ]
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------- title
+ Column {
+ id: title
+ anchors.left: parent.left
+ anchors.leftMargin: root.width * 0.12
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.verticalCenterOffset: -60
+ spacing: -14
+ opacity: 0
+
+ SplitText {
+ text: "TAKE"
+ pixelSize: Theme.fsMega
+ split: 4
+ splitOpacity: 0.9
+ }
+
+ SplitText {
+ text: "YOUR"
+ pixelSize: Theme.fsMega
+ color: Theme.accent
+ split: 4
+ splitOpacity: 0.5
+ }
+
+ SplitText {
+ text: "TIME."
+ pixelSize: Theme.fsMega
+ split: 4
+ splitOpacity: 0.9
+ }
+
+ Text {
+ text: "— TAE TAKEMI"
+ color: Theme.primary
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ font.letterSpacing: 5
+ topPadding: 24
+ renderType: Text.NativeRendering
+ }
+ }
+
+ // -------------------------------------------------------------- actions
+ Column {
+ id: actions
+ anchors.right: parent.right
+ anchors.rightMargin: root.width * 0.12
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.padM
+
+ Repeater {
+ id: buttonRepeater
+
+ model: [
+ { label: "Lock", icon: "", danger: false, act: "lock" },
+ { label: "Suspend", icon: "", danger: false, act: "suspend" },
+ { label: "Hibernate", icon: "", danger: false, act: "hibernate" },
+ { label: "Log Out", icon: "", danger: true, act: "logout" },
+ { label: "Reboot", icon: "", danger: true, act: "reboot" },
+ { label: "Shut Down", icon: "", danger: true, act: "shutdown" }
+ ]
+
+ // A carrier item owns the staggered slide so the button keeps its
+ // own hover transform.
+ Item {
+ id: entry
+ required property var modelData
+ required property int index
+
+ width: 280
+ height: 52
+
+ opacity: 0
+ transform: Translate { id: slide; x: 90 }
+
+ P5Button {
+ anchors.fill: parent
+ text: entry.modelData.label
+ icon: entry.modelData.icon
+ destructive: entry.modelData.danger
+ accent: entry.modelData.danger ? Theme.accent : Theme.primary
+ lunge: -10
+ onClicked: root.invoke(entry.modelData.act)
+ }
+
+ // Staggered entry, one button after another.
+ Connections {
+ target: root
+ function onOpenChanged() {
+ if (root.open) entryAnim.restart();
+ else entry.opacity = 0;
+ }
+ }
+
+ SequentialAnimation {
+ id: entryAnim
+ PauseAnimation { duration: 40 + entry.index * 55 }
+ ParallelAnimation {
+ NumberAnimation {
+ target: entry; property: "opacity"
+ to: 1; duration: Theme.durBase
+ }
+ NumberAnimation {
+ target: slide; property: "x"
+ from: 90; to: 0
+ duration: Theme.durSlow
+ easing.type: Easing.OutBack
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Hint line.
+ Text {
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 40
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "ESC TO CANCEL"
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 4
+ opacity: backdrop.opacity
+ renderType: Text.NativeRendering
+ }
+
+ function invoke(what: string) {
+ Actions.closePowerMenu();
+ switch (what) {
+ case "lock": Actions.lock(); break;
+ case "suspend": Actions.suspend(); break;
+ case "hibernate": Actions.hibernate(); break;
+ case "logout": Actions.logout(); break;
+ case "reboot": Actions.reboot(); break;
+ case "shutdown": Actions.shutdown(); break;
+ }
+ }
+
+ // Escape closes. The layer takes exclusive focus while open.
+ Item {
+ anchors.fill: parent
+ focus: root.open
+ Keys.onEscapePressed: Actions.closePowerMenu()
+ }
+
+ ParallelAnimation {
+ id: openAnim
+ NumberAnimation {
+ target: backdrop; property: "opacity"
+ to: 1; duration: Theme.durBase; easing.type: Easing.OutQuad
+ }
+ NumberAnimation {
+ target: stripes; property: "opacity"
+ to: 0.1; duration: Theme.durSlow
+ }
+ NumberAnimation {
+ target: stripeShift; property: "x"
+ from: -root.width; to: 0
+ duration: Theme.durLazy; easing.type: Easing.OutExpo
+ }
+ NumberAnimation {
+ target: wedge; property: "opacity"
+ to: 1; duration: Theme.durSlow; easing.type: Easing.OutExpo
+ }
+ NumberAnimation {
+ target: title; property: "opacity"
+ to: 1; duration: Theme.durSlow
+ }
+ }
+
+ ParallelAnimation {
+ id: closeAnim
+ NumberAnimation {
+ target: backdrop; property: "opacity"
+ to: 0; duration: Theme.durFast
+ }
+ NumberAnimation {
+ target: stripes; property: "opacity"
+ to: 0; duration: Theme.durFast
+ }
+ NumberAnimation {
+ target: wedge; property: "opacity"
+ to: 0; duration: Theme.durFast
+ }
+ NumberAnimation {
+ target: title; property: "opacity"
+ to: 0; duration: Theme.durFast
+ }
+ }
+}
diff --git a/quickshell/overlays/Toasts.qml b/quickshell/overlays/Toasts.qml
new file mode 100644
index 0000000..4341c59
--- /dev/null
+++ b/quickshell/overlays/Toasts.qml
@@ -0,0 +1,148 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Services.Notifications
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Floating notification stack under the bar, top right. Replaces dunst.
+PanelWindow {
+ id: root
+
+ required property var modelData
+ screen: modelData
+
+ // Only the primary bar screen shows toasts, to avoid duplicates.
+ readonly property bool primaryScreen: Quickshell.screens.length === 0
+ || Quickshell.screens[0] === modelData
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "takemi-toasts"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+
+ anchors { top: true; right: true }
+ margins {
+ top: Theme.barHeight + Theme.barMargin * 2
+ right: Theme.barMargin
+ }
+
+ implicitWidth: 400
+ implicitHeight: Math.max(1, stack.implicitHeight)
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ visible: primaryScreen && stack.children.length > 0
+
+ // Toasts currently on screen, newest first.
+ property list live: []
+ property int maxLive: 5
+
+ Connections {
+ target: Notifs
+ enabled: root.primaryScreen
+
+ function onPopupRequested(notif) {
+ root.live = [notif, ...root.live].slice(0, root.maxLive);
+ }
+ }
+
+ function retire(notif) {
+ const idx = root.live.indexOf(notif);
+ if (idx === -1) return;
+ const next = root.live.slice();
+ next.splice(idx, 1);
+ root.live = next;
+ }
+
+ Column {
+ id: stack
+ anchors.right: parent.right
+ anchors.top: parent.top
+ width: parent.width
+ spacing: Theme.padS
+
+ Repeater {
+ model: root.live
+
+ Item {
+ id: toast
+ required property var modelData
+
+ width: stack.width
+ height: card.implicitHeight
+
+ NotifCard {
+ id: card
+ width: parent.width
+ notif: toast.modelData
+ onDismissed: {
+ Notifs.dismiss(toast.modelData);
+ root.retire(toast.modelData);
+ }
+ }
+
+ // Countdown rule that drains along the bottom edge.
+ Rectangle {
+ id: fuse
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ height: 2
+ width: parent.width
+ color: Theme.alpha(card.tone, 0.7)
+ visible: life.running
+ }
+
+ NumberAnimation {
+ id: life
+ target: fuse
+ property: "width"
+ from: toast.width
+ to: 0
+ running: false
+ duration: Notifs.timeoutFor(toast.modelData)
+ easing.type: Easing.Linear
+ onFinished: root.retire(toast.modelData)
+ }
+
+ // Slide in from the right, Persona-style.
+ opacity: 0
+ transform: Translate { id: shove; x: 60 }
+
+ Component.onCompleted: {
+ enter.start();
+ const ms = Notifs.timeoutFor(toast.modelData);
+ if (ms > 0) life.start();
+ }
+
+ ParallelAnimation {
+ id: enter
+ NumberAnimation {
+ target: toast; property: "opacity"
+ to: 1; duration: Theme.durBase
+ }
+ NumberAnimation {
+ target: shove; property: "x"
+ from: 60; to: 0
+ duration: Theme.durSlow
+ easing.type: Easing.OutBack
+ }
+ }
+
+ // Hovering a toast pauses its fuse.
+ HoverHandler {
+ id: toastHover
+ onHoveredChanged: {
+ if (!life.duration) return;
+ if (hovered) life.pause();
+ else if (life.paused) life.resume();
+ }
+ }
+
+ TapHandler {
+ acceptedButtons: Qt.LeftButton
+ onTapped: root.retire(toast.modelData)
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/popouts/AudioPopout.qml b/quickshell/popouts/AudioPopout.qml
new file mode 100644
index 0000000..3313092
--- /dev/null
+++ b/quickshell/popouts/AudioPopout.qml
@@ -0,0 +1,275 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.Pipewire
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Output and input levels, the sink picker, and a per-app mixer.
+Popout {
+ id: root
+
+ contentWidth: 360
+ contentHeight: surface.implicitContentHeight + Theme.padM * 2
+
+ PopoutSurface {
+ id: surface
+ anchors.fill: parent
+ tone: Theme.primary
+
+ readonly property real implicitContentHeight: header.height + column.implicitHeight + Theme.padM * 2
+
+ PopoutHeader {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ title: "Audio"
+ icon: Audio.icon
+ accent: Theme.primary
+ subtitle: Audio.sinkName
+ }
+
+ Column {
+ id: column
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Theme.padM
+
+ // ------------------------------------------------------- output
+ LevelRow {
+ width: parent.width
+ icon: Audio.icon
+ label: "Output"
+ readout: Audio.muted ? "MUTED" : Audio.volumePercent + "%"
+ value: Audio.volume
+ muted: Audio.muted
+ accent: Theme.primary
+ onMoved: v => Audio.setVolume(v)
+ onToggled: Audio.toggleMute()
+ }
+
+ // -------------------------------------------------------- input
+ LevelRow {
+ width: parent.width
+ icon: Audio.micIcon
+ label: "Input"
+ readout: Audio.micMuted ? "MUTED" : Audio.micPercent + "%"
+ value: Audio.micVolume
+ muted: Audio.micMuted
+ accent: Theme.blue
+ onMoved: v => Audio.setMicVolume(v)
+ onToggled: Audio.toggleMicMute()
+ }
+
+ SectionRule {
+ width: parent.width
+ text: "Outputs"
+ visible: Audio.sinks.length > 1
+ }
+
+ Repeater {
+ model: Audio.sinks.length > 1 ? Audio.sinks : []
+
+ PickRow {
+ required property var modelData
+ width: column.width
+ label: modelData.nickname || modelData.description || modelData.name
+ icon: ""
+ selected: Audio.sink === modelData
+ onClicked: Audio.setSink(modelData)
+ }
+ }
+
+ SectionRule {
+ width: parent.width
+ text: "Applications"
+ visible: Audio.streams.length > 0
+ }
+
+ Repeater {
+ model: Audio.streams
+
+ LevelRow {
+ required property var modelData
+ width: column.width
+ icon: ""
+ label: Audio.streamName(modelData)
+ readout: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : ""
+ value: modelData.audio?.volume ?? 0
+ muted: modelData.audio?.muted ?? false
+ accent: Theme.glow
+ onMoved: v => { if (modelData.audio) modelData.audio.volume = v; }
+ onToggled: { if (modelData.audio) modelData.audio.muted = !modelData.audio.muted; }
+ }
+ }
+
+ P5Button {
+ text: "Mixer"
+ icon: ""
+ onClicked: Actions.openAudioSettings()
+ }
+ }
+ }
+
+ // ------------------------------------------------------------- pieces
+ component LevelRow: Item {
+ id: levelRow
+
+ property string icon: ""
+ property string label: ""
+ property string readout: ""
+ property real value: 0
+ property bool muted: false
+ property color accent: Theme.primary
+
+ signal moved(real value)
+ signal toggled()
+
+ implicitHeight: 42
+
+ Row {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Theme.padS
+
+ Icon {
+ id: levelIcon
+ anchors.verticalCenter: parent.verticalCenter
+ text: levelRow.icon
+ color: levelRow.muted ? Theme.accent : levelRow.accent
+ font.pixelSize: Theme.fsLarge
+
+ TapHandler { onTapped: levelRow.toggled() }
+ HoverHandler { cursorShape: Qt.PointingHandCursor }
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: levelRow.width - levelIcon.width - readoutText.width - Theme.padS * 3
+ text: levelRow.label
+ elide: Text.ElideRight
+ color: Theme.text
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsSmall
+ font.weight: Font.DemiBold
+ font.italic: true
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ id: readoutText
+ anchors.verticalCenter: parent.verticalCenter
+ text: levelRow.readout
+ color: levelRow.muted ? Theme.accent : Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.weight: Font.Bold
+ renderType: Text.NativeRendering
+ }
+ }
+
+ P5Slider {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ value: levelRow.value
+ accent: levelRow.accent
+ enabledControl: !levelRow.muted
+ onMoved: v => levelRow.moved(v)
+ }
+ }
+
+ component SectionRule: Item {
+ property string text: ""
+ implicitHeight: visible ? 18 : 0
+
+ Text {
+ id: sectionLabel
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: parent.text.toUpperCase()
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 2
+ renderType: Text.NativeRendering
+ }
+
+ Rectangle {
+ anchors.left: sectionLabel.right
+ anchors.leftMargin: Theme.padS
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ height: 1
+ color: Theme.alpha(Theme.outline, 0.6)
+ }
+ }
+
+ component PickRow: Item {
+ id: pickRow
+
+ property string label: ""
+ property string icon: ""
+ property bool selected: false
+
+ signal clicked()
+
+ implicitHeight: 26
+
+ Rectangle {
+ anchors.fill: parent
+ color: pickHover.hovered
+ ? Theme.alpha(Theme.primary, 0.14)
+ : (pickRow.selected ? Theme.alpha(Theme.primary, 0.07) : "transparent")
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 3
+ height: pickRow.selected ? 18 : 0
+ color: Theme.accent
+
+ Behavior on height {
+ NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
+ }
+ }
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.padM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.padS
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: pickRow.icon
+ color: pickRow.selected ? Theme.primary : Theme.muted
+ font.pixelSize: Theme.fsBody
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: pickRow.label
+ color: pickRow.selected ? Theme.text : Theme.subtext
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsSmall
+ font.italic: true
+ renderType: Text.NativeRendering
+ }
+ }
+
+ HoverHandler {
+ id: pickHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: pickRow.clicked()
+ }
+ }
+}
diff --git a/quickshell/popouts/BatteryPopout.qml b/quickshell/popouts/BatteryPopout.qml
new file mode 100644
index 0000000..b15fa62
--- /dev/null
+++ b/quickshell/popouts/BatteryPopout.qml
@@ -0,0 +1,119 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Charge level, time estimate, draw rate and health.
+Popout {
+ id: root
+
+ contentWidth: 310
+ contentHeight: 296
+
+ PopoutSurface {
+ anchors.fill: parent
+ tone: Battery.tint
+
+ PopoutHeader {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ title: "Power"
+ icon: Battery.icon
+ accent: Battery.tint
+ subtitle: Battery.charging ? "CHARGING" : (Battery.onBattery ? "ON BATTERY" : "AC")
+ }
+
+ Row {
+ id: readout
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Theme.padM
+
+ Gauge {
+ width: 92; height: 92
+ value: Battery.percent
+ label: "CHARGE"
+ arcColor: Battery.tint
+ readout: Math.round(Battery.percent) + "%"
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.padS
+
+ SplitText {
+ text: Battery.timeLabel
+ pixelSize: Theme.fsBody
+ split: 1.1
+ splitOpacity: 0.55
+ }
+
+ Text {
+ text: Battery.changeRate > 0
+ ? Battery.changeRate.toFixed(1) + " W " + (Battery.charging ? "in" : "draw")
+ : "idle"
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ visible: Battery.health > 0
+ text: "Health " + Math.round(Battery.health) + "%"
+ color: Battery.health < 70 ? Theme.warn : Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+
+ MeterBar {
+ id: bar
+ anchors.top: readout.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 12
+ segments: 20
+ segmentWidth: (width - 19 * 3) / 20
+ spacing: 3
+ value: Battery.percent
+ activeColor: Battery.tint
+ }
+
+ P5Button {
+ id: suspendButton
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ text: "Suspend"
+ icon: ""
+ onClicked: {
+ root.pinned = false;
+ Actions.suspend();
+ }
+ }
+
+ Text {
+ anchors.top: bar.bottom
+ anchors.topMargin: Theme.padS
+ anchors.bottom: suspendButton.top
+ anchors.bottomMargin: Theme.padS
+ anchors.left: parent.left
+ anchors.right: parent.right
+ text: Battery.detail
+ wrapMode: Text.WordWrap
+ verticalAlignment: Text.AlignTop
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+ }
+}
diff --git a/quickshell/popouts/BtPopout.qml b/quickshell/popouts/BtPopout.qml
new file mode 100644
index 0000000..1e97ca3
--- /dev/null
+++ b/quickshell/popouts/BtPopout.qml
@@ -0,0 +1,192 @@
+import QtQuick
+import Quickshell
+import Quickshell.Bluetooth
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Adapter toggle, scan, and the device list with battery levels where BlueZ
+// reports them.
+Popout {
+ id: root
+
+ contentWidth: 340
+ contentHeight: 280
+
+ // Discovery is expensive; run it only while the panel is open.
+ onShownChanged: {
+ if (!shown && Bt.discovering && Bt.adapter) Bt.adapter.discovering = false;
+ }
+
+ PopoutSurface {
+ anchors.fill: parent
+ tone: Bt.enabled ? Theme.blue : Theme.muted
+
+ PopoutHeader {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ title: "Bluetooth"
+ icon: Bt.icon
+ accent: Theme.blue
+ subtitle: Bt.adapter ? Bt.adapter.name : "NO ADAPTER"
+ }
+
+ Row {
+ id: controls
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ spacing: Theme.padS
+
+ P5Button {
+ text: Bt.enabled ? "On" : "Off"
+ icon: Bt.enabled ? "" : ""
+ accent: Bt.enabled ? Theme.blue : Theme.muted
+ onClicked: Bt.toggle()
+ }
+
+ P5Button {
+ text: Bt.discovering ? "Scanning" : "Scan"
+ icon: ""
+ accent: Bt.discovering ? Theme.accent : Theme.primary
+ onClicked: Bt.toggleScan()
+ }
+ }
+
+ Text {
+ id: listLabel
+ anchors.top: controls.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ text: Bt.devices.length > 0 ? "DEVICES" : "NO DEVICES"
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 2
+ renderType: Text.NativeRendering
+ }
+
+ ListView {
+ anchors.top: listLabel.bottom
+ anchors.topMargin: Theme.padS
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ clip: true
+ spacing: 2
+ model: Bt.devices
+
+ delegate: Item {
+ id: devRow
+
+ required property BluetoothDevice modelData
+
+ width: ListView.view.width
+ height: 34
+
+ Rectangle {
+ anchors.fill: parent
+ color: devHover.hovered
+ ? Theme.alpha(Theme.blue, 0.16)
+ : (devRow.modelData.connected ? Theme.alpha(Theme.blue, 0.08) : "transparent")
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 3
+ height: devRow.modelData.connected ? 22 : 0
+ color: Theme.accent
+
+ Behavior on height {
+ NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
+ }
+ }
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.padM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.padS
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Bt.deviceIcon(devRow.modelData)
+ color: devRow.modelData.connected ? Theme.blue : Theme.subtext
+ font.pixelSize: Theme.fsLarge
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: -2
+
+ Text {
+ text: Bt.deviceLabel(devRow.modelData)
+ color: devRow.modelData.connected ? Theme.text : Theme.subtext
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsSmall
+ font.italic: true
+ font.weight: devRow.modelData.connected ? Font.Black : Font.Medium
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ text: {
+ if (devRow.modelData.pairing) return "pairing…";
+ if (devRow.modelData.connected) return "connected";
+ if (devRow.modelData.paired || devRow.modelData.bonded) return "paired";
+ return devRow.modelData.address;
+ }
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: 8
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+
+ // Device battery, where BlueZ exposes it.
+ Row {
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.padS
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 3
+ visible: devRow.modelData.batteryAvailable
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""
+ color: devRow.modelData.battery > 0.25 ? Theme.ok : Theme.warn
+ font.pixelSize: Theme.fsSmall
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Math.round(devRow.modelData.battery * 100) + "%"
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+ }
+
+ HoverHandler {
+ id: devHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ acceptedButtons: Qt.LeftButton
+ onTapped: Bt.toggleDevice(devRow.modelData)
+ }
+
+ TapHandler {
+ acceptedButtons: Qt.RightButton
+ onTapped: if (devRow.modelData.paired) devRow.modelData.forget()
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/popouts/CalendarPopout.qml b/quickshell/popouts/CalendarPopout.qml
new file mode 100644
index 0000000..fc51d86
--- /dev/null
+++ b/quickshell/popouts/CalendarPopout.qml
@@ -0,0 +1,260 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Month grid with the current day slashed in crimson, plus uptime and a couple
+// of world clocks.
+Popout {
+ id: root
+
+ property date clockDate: new Date()
+
+ contentWidth: 320
+ contentHeight: 352
+
+ // Month currently on display, as an offset from the real one.
+ property int monthOffset: 0
+
+ onShownChanged: if (!shown) monthOffset = 0
+
+ readonly property date viewDate: {
+ const d = new Date(root.clockDate);
+ d.setDate(1);
+ d.setMonth(d.getMonth() + root.monthOffset);
+ return d;
+ }
+
+ readonly property int viewYear: viewDate.getFullYear()
+ readonly property int viewMonth: viewDate.getMonth()
+
+ // Monday-first grid, 6 rows of 7.
+ readonly property var cells: {
+ const first = new Date(root.viewYear, root.viewMonth, 1);
+ const lead = (first.getDay() + 6) % 7;
+ const start = new Date(root.viewYear, root.viewMonth, 1 - lead);
+ const out = [];
+ for (let i = 0; i < 42; i++) {
+ const d = new Date(start);
+ d.setDate(start.getDate() + i);
+ out.push({
+ day: d.getDate(),
+ inMonth: d.getMonth() === root.viewMonth,
+ today: d.toDateString() === root.clockDate.toDateString()
+ });
+ }
+ return out;
+ }
+
+ PopoutSurface {
+ anchors.fill: parent
+ tone: Theme.accent
+
+ PopoutHeader {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ title: Qt.formatDateTime(root.viewDate, "MMMM")
+ icon: ""
+ accent: Theme.accent
+ subtitle: Qt.formatDateTime(root.viewDate, "yyyy")
+ }
+
+ // Month stepper.
+ Row {
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padS
+ anchors.right: parent.right
+ spacing: Theme.padS
+ z: 2
+
+ StepButton {
+ glyph: ""
+ onClicked: root.monthOffset--
+ }
+
+ StepButton {
+ glyph: ""
+ onClicked: root.monthOffset++
+ }
+ }
+
+ // Weekday header, clear of the month stepper above it.
+ Row {
+ id: dayNames
+ anchors.top: header.bottom
+ anchors.topMargin: 36
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ Repeater {
+ model: ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
+
+ Text {
+ required property var modelData
+ required property int index
+
+ width: dayNames.width / 7
+ horizontalAlignment: Text.AlignHCenter
+ text: modelData
+ color: index >= 5 ? Theme.alpha(Theme.accent, 0.8) : Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: 8
+ font.letterSpacing: 1
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+
+ Grid {
+ id: grid
+ anchors.top: dayNames.bottom
+ anchors.topMargin: Theme.padS
+ anchors.left: parent.left
+ anchors.right: parent.right
+ columns: 7
+
+ Repeater {
+ model: root.cells
+
+ Item {
+ id: cell
+ required property var modelData
+
+ width: grid.width / 7
+ height: 30
+
+ // Today gets a leaning crimson slab.
+ Rectangle {
+ anchors.centerIn: parent
+ width: 26
+ height: 24
+ visible: cell.modelData.today
+ color: Theme.accent
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 24 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: cell.modelData.day
+ color: {
+ if (cell.modelData.today) return Theme.text;
+ if (!cell.modelData.inMonth) return Theme.alpha(Theme.muted, 0.45);
+ return Theme.subtext;
+ }
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsSmall
+ font.italic: true
+ font.weight: cell.modelData.today ? Font.Black : Font.Medium
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+ }
+
+ // Footer: uptime and a second time zone.
+ Row {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ Column {
+ width: parent.width / 2
+ spacing: -2
+
+ Text {
+ text: "UPTIME"
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: 8
+ font.letterSpacing: 1.5
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ text: Sys.uptime
+ color: Theme.text
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ font.weight: Font.DemiBold
+ renderType: Text.NativeRendering
+ }
+ }
+
+ Column {
+ width: parent.width / 2
+ spacing: -2
+
+ Text {
+ anchors.right: parent.right
+ text: "TOKYO"
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: 8
+ font.letterSpacing: 1.5
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ anchors.right: parent.right
+ text: {
+ // Tokyo is UTC+9 year-round.
+ const utc = root.clockDate.getTime()
+ + root.clockDate.getTimezoneOffset() * 60000;
+ const tokyo = new Date(utc + 9 * 3600000);
+ return Qt.formatDateTime(tokyo, "HH:mm");
+ }
+ color: Theme.primary
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ font.weight: Font.DemiBold
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+ }
+
+ component StepButton: Item {
+ id: step
+
+ property string glyph: ""
+ signal clicked()
+
+ implicitWidth: 22
+ implicitHeight: 22
+
+ Rectangle {
+ anchors.fill: parent
+ color: stepHover.hovered ? Theme.alpha(Theme.accent, 0.25) : "transparent"
+ border.width: 1
+ border.color: Theme.alpha(Theme.outline, 0.7)
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 22 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ Icon {
+ anchors.centerIn: parent
+ text: step.glyph
+ color: stepHover.hovered ? Theme.text : Theme.subtext
+ font.pixelSize: Theme.fsBody
+ }
+
+ HoverHandler {
+ id: stepHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: step.clicked()
+ }
+ }
+}
diff --git a/quickshell/popouts/MediaPopout.qml b/quickshell/popouts/MediaPopout.qml
new file mode 100644
index 0000000..ef48c22
--- /dev/null
+++ b/quickshell/popouts/MediaPopout.qml
@@ -0,0 +1,337 @@
+import QtQuick
+import QtQuick.Shapes
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Full player: cover art, transport, seek bar, and a picker when more than one
+// player is running.
+Popout {
+ id: root
+
+ contentWidth: 400
+ contentHeight: 200 + (Media.players.length > 1 ? 34 : 0)
+
+ PopoutSurface {
+ id: surface
+ anchors.fill: parent
+ tone: Theme.primary
+
+ PopoutHeader {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ title: "Now Playing"
+ icon: Media.sourceIcon
+ subtitle: Media.identity
+ }
+
+ // Cover art, chopped.
+ Item {
+ id: artFrame
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ width: 108
+ height: 108
+
+ Shape {
+ id: artMask
+ anchors.fill: parent
+ visible: false
+ layer.enabled: true
+ preferredRendererType: Shape.CurveRenderer
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ PathPolyline {
+ path: Geom.chamfer(Geom.parallelogram(108, 108, 0.1), 18, [1, 3])
+ }
+ }
+ }
+
+ Image {
+ id: art
+ anchors.fill: parent
+ source: Media.artUrl
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ cache: true
+ visible: status === Image.Ready
+ layer.enabled: true
+ layer.effect: MaskedArt { maskItem: artMask }
+ }
+
+ // Placeholder when the player exposes no artwork.
+ Shape {
+ anchors.fill: parent
+ visible: art.status !== Image.Ready
+ preferredRendererType: Shape.CurveRenderer
+ ShapePath {
+ fillColor: Theme.surface
+ strokeColor: Theme.alpha(Theme.primary, 0.5)
+ strokeWidth: 2
+ PathPolyline {
+ path: Geom.chamfer(Geom.parallelogram(108, 108, 0.1), 18, [1, 3])
+ }
+ }
+ }
+
+ Icon {
+ anchors.centerIn: parent
+ visible: art.status !== Image.Ready
+ text: Media.sourceIcon
+ font.pixelSize: 42
+ color: Theme.alpha(Theme.primary, 0.7)
+ }
+
+ // Spinning rule around the art while playing.
+ Rectangle {
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ width: 30
+ height: 3
+ color: Theme.accent
+ visible: Media.playing
+ }
+ }
+
+ Column {
+ id: info
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: artFrame.right
+ anchors.leftMargin: Theme.padM
+ anchors.right: parent.right
+ spacing: 2
+
+ Marquee {
+ width: parent.width
+ height: 24
+ text: Media.title || "Nothing playing"
+ color: Theme.text
+ pixelSize: Theme.fsLarge
+ family: Theme.fontDisplay
+ weight: Font.Black
+ italic: true
+ }
+
+ Marquee {
+ width: parent.width
+ height: 16
+ text: Media.artist
+ color: Theme.primary
+ pixelSize: Theme.fsSmall
+ family: Theme.fontDisplay
+ italic: true
+ weight: Font.DemiBold
+ }
+
+ Marquee {
+ width: parent.width
+ height: 14
+ text: Media.album
+ color: Theme.muted
+ pixelSize: Theme.fsMicro
+ family: Theme.fontMono
+ }
+
+ Item { width: 1; height: Theme.padS }
+
+ // Transport
+ Row {
+ spacing: Theme.padM
+
+ TransportButton {
+ glyph: ""
+ enabledControl: Media.canGoPrevious
+ onClicked: Media.previous()
+ }
+
+ TransportButton {
+ glyph: Media.playing ? "" : ""
+ primary: true
+ enabledControl: Media.hasPlayer
+ onClicked: Media.playPause()
+ }
+
+ TransportButton {
+ glyph: ""
+ enabledControl: Media.canGoNext
+ onClicked: Media.next()
+ }
+
+ TransportButton {
+ glyph: ""
+ enabledControl: Media.hasPlayer
+ onClicked: Media.raise()
+ }
+ }
+ }
+
+ // Seek bar across the bottom.
+ Item {
+ id: seekArea
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: picker.visible ? picker.top : parent.bottom
+ anchors.bottomMargin: Theme.padS
+ height: 26
+ visible: Media.hasPlayer
+
+ Text {
+ id: elapsed
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: Media.timeString(Media.position)
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ id: total
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: Media.timeString(Media.length)
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+
+ P5Slider {
+ anchors.left: elapsed.right
+ anchors.right: total.left
+ anchors.leftMargin: Theme.padS
+ anchors.rightMargin: Theme.padS
+ anchors.verticalCenter: parent.verticalCenter
+ value: Media.progress
+ accent: Theme.accent
+ enabledControl: Media.canSeek
+ onMoved: v => Media.seekTo(v)
+ }
+ }
+
+ // Player picker, only when it matters.
+ Row {
+ id: picker
+ anchors.left: parent.left
+ anchors.bottom: parent.bottom
+ spacing: Theme.padS
+ visible: Media.players.length > 1
+
+ Repeater {
+ model: Media.players
+
+ Item {
+ id: chip
+ required property var modelData
+
+ readonly property bool current: Media.active === modelData
+
+ implicitWidth: chipLabel.implicitWidth + Theme.padM
+ implicitHeight: 22
+
+ Rectangle {
+ anchors.fill: parent
+ color: chip.current
+ ? Theme.alpha(Theme.primary, 0.22)
+ : (chipHover.hovered ? Theme.alpha(Theme.primary, 0.1) : "transparent")
+ border.width: 1
+ border.color: chip.current ? Theme.primary : Theme.alpha(Theme.outline, 0.7)
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, 22 * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ Text {
+ id: chipLabel
+ anchors.centerIn: parent
+ text: chip.modelData.identity
+ color: chip.current ? Theme.text : Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+
+ HoverHandler {
+ id: chipHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: Media.select(chip.modelData)
+ }
+ }
+ }
+ }
+ }
+
+ component TransportButton: Item {
+ id: tb
+
+ property string glyph: ""
+ property bool primary: false
+ property bool enabledControl: true
+
+ signal clicked()
+
+ implicitWidth: primary ? 38 : 30
+ implicitHeight: primary ? 38 : 30
+
+ Rectangle {
+ anchors.fill: parent
+ color: {
+ if (!tb.enabledControl) return "transparent";
+ if (tbHover.hovered) return tb.primary ? Theme.accent : Theme.alpha(Theme.primary, 0.22);
+ return tb.primary ? Theme.alpha(Theme.primary, 0.18) : "transparent";
+ }
+ border.width: tb.primary ? 2 : 1
+ border.color: tb.enabledControl
+ ? (tbHover.hovered ? Theme.text : Theme.alpha(Theme.primary, 0.6))
+ : Theme.alpha(Theme.outline, 0.4)
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, tb.height * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.durFast }
+ }
+ }
+
+ Icon {
+ anchors.centerIn: parent
+ text: tb.glyph
+ font.pixelSize: tb.primary ? Theme.fsTitle : Theme.fsLarge
+ color: {
+ if (!tb.enabledControl) return Theme.alpha(Theme.muted, 0.5);
+ if (tbHover.hovered && tb.primary) return Theme.text;
+ return Theme.primary;
+ }
+ }
+
+ scale: tbHover.hovered && tb.enabledControl ? 1.12 : 1.0
+ Behavior on scale {
+ NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
+ }
+
+ HoverHandler {
+ id: tbHover
+ enabled: tb.enabledControl
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ enabled: tb.enabledControl
+ onTapped: tb.clicked()
+ }
+ }
+}
diff --git a/quickshell/popouts/NetPopout.qml b/quickshell/popouts/NetPopout.qml
new file mode 100644
index 0000000..5ca4836
--- /dev/null
+++ b/quickshell/popouts/NetPopout.qml
@@ -0,0 +1,252 @@
+import QtQuick
+import Quickshell
+import Quickshell.Networking
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Link state, throughput, and the Wi-Fi picker.
+Popout {
+ id: root
+
+ contentWidth: 360
+ contentHeight: 300
+
+ // Scan only while the popout is actually on screen.
+ onShownChanged: Net.setScanning(shown)
+
+ PopoutSurface {
+ anchors.fill: parent
+ tone: Sys.netUp ? Theme.primary : Theme.muted
+
+ PopoutHeader {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ title: "Network"
+ icon: Net.icon
+ subtitle: Sys.netUp ? Sys.netIp : "OFFLINE"
+ }
+
+ // Throughput
+ Row {
+ id: rates
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Theme.padM
+
+ RateBlock {
+ width: (parent.width - Theme.padM) / 2
+ glyph: ""
+ label: "Down"
+ value: Sys.rate(Sys.netRx)
+ tone: Theme.primary
+ }
+
+ RateBlock {
+ width: (parent.width - Theme.padM) / 2
+ glyph: ""
+ label: "Up"
+ value: Sys.rate(Sys.netTx)
+ tone: Theme.accent
+ }
+ }
+
+ Row {
+ id: controls
+ anchors.top: rates.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ spacing: Theme.padS
+
+ P5Button {
+ text: Net.wifiEnabled ? "Wi-Fi On" : "Wi-Fi Off"
+ icon: Net.wifiEnabled ? "" : ""
+ accent: Net.wifiEnabled ? Theme.primary : Theme.muted
+ onClicked: Net.toggleWifi()
+ }
+
+ P5Button {
+ text: "Settings"
+ icon: ""
+ onClicked: Actions.openNetworkSettings()
+ }
+ }
+
+ Text {
+ id: apsLabel
+ anchors.top: controls.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ text: Net.scanning ? "SCANNING…" : "NETWORKS"
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 2
+ renderType: Text.NativeRendering
+ }
+
+ ListView {
+ anchors.top: apsLabel.bottom
+ anchors.topMargin: Theme.padS
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ clip: true
+ spacing: 2
+ model: Net.wifiNetworks
+
+ delegate: Item {
+ id: apRow
+
+ required property var modelData
+
+ width: ListView.view.width
+ height: 30
+
+ Rectangle {
+ anchors.fill: parent
+ color: apHover.hovered
+ ? Theme.alpha(Theme.primary, 0.14)
+ : (apRow.modelData.connected ? Theme.alpha(Theme.primary, 0.07) : "transparent")
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 3
+ height: apRow.modelData.connected ? 20 : 0
+ color: Theme.accent
+
+ Behavior on height {
+ NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
+ }
+ }
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.padM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.padS
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Net.signalIcon(apRow.modelData.signalStrength ?? 0)
+ color: apRow.modelData.connected ? Theme.primary : Theme.subtext
+ font.pixelSize: Theme.fsBody
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: apRow.modelData.name
+ color: apRow.modelData.connected ? Theme.text : Theme.subtext
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsSmall
+ font.italic: true
+ font.weight: apRow.modelData.connected ? Font.Black : Font.Medium
+ renderType: Text.NativeRendering
+ }
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: Net.secured(apRow.modelData)
+ text: ""
+ color: Theme.muted
+ font.pixelSize: Theme.fsMicro
+ }
+ }
+
+ Text {
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.padS
+ anchors.verticalCenter: parent.verticalCenter
+ text: apRow.modelData.known ? "SAVED" : ""
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: 8
+ font.letterSpacing: 1
+ renderType: Text.NativeRendering
+ }
+
+ HoverHandler {
+ id: apHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: {
+ if (apRow.modelData.connected) {
+ apRow.modelData.disconnect();
+ } else {
+ // Saved networks connect directly; new secured ones
+ // need a passphrase, which NetworkManager's own
+ // agent will prompt for.
+ apRow.modelData.connect();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ component RateBlock: Item {
+ id: rateBlock
+
+ property string glyph: ""
+ property string label: ""
+ property string value: ""
+ property color tone: Theme.primary
+
+ implicitHeight: 40
+
+ Rectangle {
+ anchors.fill: parent
+ color: Theme.alpha(rateBlock.tone, 0.08)
+ border.width: 1
+ border.color: Theme.alpha(rateBlock.tone, 0.35)
+
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -Theme.skew, 0, rateBlock.height * Theme.skew,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.padS
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: rateBlock.glyph
+ color: rateBlock.tone
+ font.pixelSize: Theme.fsLarge
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: -1
+
+ Text {
+ text: rateBlock.label.toUpperCase()
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: 8
+ font.letterSpacing: 1.5
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ text: rateBlock.value
+ color: Theme.text
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsSmall
+ font.weight: Font.DemiBold
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/popouts/NotifPopout.qml b/quickshell/popouts/NotifPopout.qml
new file mode 100644
index 0000000..5fcda11
--- /dev/null
+++ b/quickshell/popouts/NotifPopout.qml
@@ -0,0 +1,113 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.Notifications
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// Notification history and the do-not-disturb switch.
+Popout {
+ id: root
+
+ contentWidth: 400
+ contentHeight: 380
+
+ PopoutSurface {
+ anchors.fill: parent
+ tone: Notifs.dnd ? Theme.muted : Theme.primary
+
+ PopoutHeader {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ title: "Notifications"
+ icon: Notifs.icon
+ accent: Notifs.dnd ? Theme.muted : Theme.primary
+ subtitle: Notifs.count + " HELD"
+ }
+
+ Row {
+ id: controls
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ spacing: Theme.padS
+
+ P5Button {
+ text: Notifs.dnd ? "Silenced" : "Silence"
+ icon: Notifs.dnd ? "" : ""
+ accent: Notifs.dnd ? Theme.accent : Theme.primary
+ onClicked: Notifs.toggleDnd()
+ }
+
+ P5Button {
+ text: "Clear"
+ icon: ""
+ destructive: true
+ onClicked: Notifs.clearAll()
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: Notifs.count === 0
+ text: "NOTHING TO REPORT"
+ color: Theme.muted
+ font.family: Theme.fontDisplay
+ font.pixelSize: Theme.fsBody
+ font.italic: true
+ font.weight: Font.Black
+ font.letterSpacing: 3
+ renderType: Text.NativeRendering
+ }
+
+ ListView {
+ anchors.top: controls.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ clip: true
+ spacing: Theme.padS
+ model: Notifs.history
+
+ delegate: NotifCard {
+ required property var modelData
+ width: ListView.view.width
+ notif: modelData
+ onDismissed: Notifs.dismiss(modelData)
+ }
+
+ add: Transition {
+ NumberAnimation {
+ properties: "x"
+ from: 40
+ duration: Theme.durSlow
+ easing.type: Easing.OutBack
+ }
+ NumberAnimation {
+ property: "opacity"
+ from: 0; to: 1
+ duration: Theme.durBase
+ }
+ }
+
+ remove: Transition {
+ NumberAnimation {
+ property: "opacity"
+ to: 0
+ duration: Theme.durFast
+ }
+ }
+
+ displaced: Transition {
+ NumberAnimation {
+ properties: "y"
+ duration: Theme.durBase
+ easing.type: Easing.OutExpo
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/popouts/PopoutHeader.qml b/quickshell/popouts/PopoutHeader.qml
new file mode 100644
index 0000000..17932b1
--- /dev/null
+++ b/quickshell/popouts/PopoutHeader.qml
@@ -0,0 +1,73 @@
+import QtQuick
+import "root:/config"
+import "root:/components"
+
+// Title bar for a popout: glyph, split-ink heading, right-aligned status text,
+// and a crimson rule with a chopped tail.
+Item {
+ id: root
+
+ property string title: ""
+ property string subtitle: ""
+ property string icon: ""
+ property color accent: Theme.primary
+
+ implicitHeight: 34
+
+ Row {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ spacing: Theme.padS
+
+ Icon {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.icon !== ""
+ text: root.icon
+ color: root.accent
+ font.pixelSize: Theme.fsTitle
+ }
+
+ SplitText {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.title.toUpperCase()
+ pixelSize: Theme.fsLarge
+ letterSpacing: 1.5
+ split: 1.3
+ splitOpacity: 0.7
+ }
+ }
+
+ Text {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.topMargin: 6
+ visible: root.subtitle !== ""
+ text: root.subtitle
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 1
+ renderType: Text.NativeRendering
+ }
+
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.rightMargin: 22
+ height: 2
+ color: Theme.alpha(root.accent, 0.8)
+ }
+
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ width: 16
+ height: 2
+ color: Theme.accent
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -1.4, 0, 2.8,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ }
+}
diff --git a/quickshell/popouts/PopoutSurface.qml b/quickshell/popouts/PopoutSurface.qml
new file mode 100644
index 0000000..00eaa2d
--- /dev/null
+++ b/quickshell/popouts/PopoutSurface.qml
@@ -0,0 +1,24 @@
+import QtQuick
+import "root:/config"
+import "root:/components"
+
+// The slab every popout sits on. Pure preset — it declares no children of its
+// own, so P5Panel's default content property stays usable by callers.
+P5Panel {
+ property color tone: Theme.primary
+
+ fill: Theme.mantle
+ fillOpacity: 0.97
+ border: Theme.alpha(tone, 0.75)
+ borderWidth: Theme.stroke
+ halftone: true
+ halftoneColor: tone
+ halftoneOpacity: 0.06
+ sheen: true
+ slash: true
+ slashColor: tone
+ slashWidth: 5
+ lean: 0.06
+ chop: 16
+ padding: Theme.padM
+}
diff --git a/quickshell/popouts/SysPopout.qml b/quickshell/popouts/SysPopout.qml
new file mode 100644
index 0000000..6b26d48
--- /dev/null
+++ b/quickshell/popouts/SysPopout.qml
@@ -0,0 +1,142 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/components"
+import "root:/services"
+
+// The resource dashboard: four gauges, then the details that do not fit on one.
+Popout {
+ id: root
+
+ contentWidth: 420
+ contentHeight: 268
+
+ PopoutSurface {
+ anchors.fill: parent
+ tone: Theme.heat(Math.max(Sys.cpuUsage, Sys.memPercent))
+
+ PopoutHeader {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ title: "System"
+ icon: ""
+ accent: Theme.primary
+ subtitle: "UP " + Sys.uptime + " · " + Sys.procs + " PROCS"
+ }
+
+ Row {
+ id: gauges
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.padM
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Theme.padM
+
+ Gauge {
+ width: 88; height: 88
+ value: Sys.cpuUsage
+ label: "CPU"
+ sub: Math.round(Sys.cpuTemp) + "°C"
+ }
+
+ Gauge {
+ width: 88; height: 88
+ value: Sys.memPercent
+ label: "MEM"
+ sub: Sys.gb(Sys.memUsed) + "/" + Sys.gb(Sys.memTotal)
+ }
+
+ Gauge {
+ width: 88; height: 88
+ visible: Sys.gpuAvailable
+ value: Sys.gpuUsage
+ label: "GPU"
+ sub: Math.round(Sys.gpuTemp) + "°C"
+ }
+
+ Gauge {
+ width: 88; height: 88
+ value: Sys.diskPercent
+ label: "DISK"
+ sub: Sys.diskFree + " free"
+ }
+ }
+
+ Column {
+ anchors.top: gauges.bottom
+ anchors.topMargin: Theme.padM
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: 3
+
+ InfoRow {
+ width: parent.width
+ key: "Processor"
+ value: Sys.cpuModel
+ }
+
+ InfoRow {
+ width: parent.width
+ key: "Load"
+ value: Sys.load1.toFixed(2) + " " + Sys.load5.toFixed(2) + " " + Sys.load15.toFixed(2)
+ }
+
+ InfoRow {
+ width: parent.width
+ visible: Sys.gpuAvailable
+ key: "VRAM"
+ value: Sys.gb(Sys.gpuVramUsed) + " / " + Sys.gb(Sys.gpuVramTotal)
+ }
+
+ InfoRow {
+ width: parent.width
+ key: "Root"
+ value: Sys.gb(Sys.diskUsed) + " of " + Sys.gb(Sys.diskTotal) + " used"
+ }
+
+ InfoRow {
+ width: parent.width
+ key: "Network"
+ value: Sys.netUp
+ ? Sys.netInterface + " ↓ " + Sys.rate(Sys.netRx) + " ↑ " + Sys.rate(Sys.netTx)
+ : "offline"
+ }
+ }
+ }
+
+ component InfoRow: Item {
+ id: infoRow
+
+ property string key: ""
+ property string value: ""
+
+ implicitHeight: visible ? 17 : 0
+
+ Text {
+ id: keyLabel
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: infoRow.key.toUpperCase()
+ color: Theme.muted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ font.letterSpacing: 1.5
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ anchors.right: parent.right
+ anchors.left: keyLabel.right
+ anchors.leftMargin: Theme.padM
+ anchors.verticalCenter: parent.verticalCenter
+ text: infoRow.value
+ horizontalAlignment: Text.AlignRight
+ elide: Text.ElideRight
+ color: Theme.subtext
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fsMicro
+ renderType: Text.NativeRendering
+ }
+ }
+}
diff --git a/quickshell/services/Actions.qml b/quickshell/services/Actions.qml
new file mode 100644
index 0000000..34ba949
--- /dev/null
+++ b/quickshell/services/Actions.qml
@@ -0,0 +1,69 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "root:/services"
+
+// Shell-wide UI state and the handful of external commands the shell fires.
+// Also exposes an IPC surface so Hyprland keybinds can drive the same actions
+// (`qs ipc call shell lock`, `qs ipc call shell power`, ...).
+Singleton {
+ id: root
+
+ // ------------------------------------------------------------- UI state
+ property bool powerMenuOpen: false
+ property bool lockRequested: false
+ property bool sidebarOpen: false
+
+ signal osdRequested(string kind)
+
+ function togglePowerMenu() { root.powerMenuOpen = !root.powerMenuOpen; }
+ function closePowerMenu() { root.powerMenuOpen = false; }
+ function lock() { root.powerMenuOpen = false; root.lockRequested = true; }
+
+ // ------------------------------------------------------------- commands
+ // Each slot gets its own Process so a slow one cannot cancel another.
+ Process { id: slotA }
+ Process { id: slotB }
+ Process { id: slotC }
+
+ property int _slot: 0
+
+ function run(argv: var) {
+ const slots = [slotA, slotB, slotC];
+ const p = slots[root._slot % slots.length];
+ root._slot++;
+ p.running = false;
+ p.command = argv;
+ p.running = true;
+ }
+
+ function launch(app: string) {
+ root.run(["uwsm", "app", "--", app]);
+ }
+
+ // --------------------------------------------------------------- power
+ function logout() { root.run(["uwsm", "stop"]); }
+ function reboot() { root.run(["systemctl", "reboot"]); }
+ function shutdown() { root.run(["systemctl", "poweroff"]); }
+ function suspend() { root.lock(); root.run(["systemctl", "suspend"]); }
+ function hibernate() { root.lock(); root.run(["systemctl", "hibernate"]); }
+ function reloadShell() { Quickshell.reload(true); }
+
+ // ------------------------------------------------------------- helpers
+ function openAudioSettings() { root.launch("pavucontrol"); }
+ function openSystemMonitor() { root.run(["alacritty", "-e", "btop"]); }
+ function openNetworkSettings() { root.run(["nm-connection-editor"]); }
+ function copyText(s: string) { root.run(["wl-copy", "--", s]); }
+
+ IpcHandler {
+ target: "shell"
+
+ function lock(): void { root.lock(); }
+ function power(): void { root.togglePowerMenu(); }
+ function reloadConfig(): void { root.reloadShell(); }
+ function toggleDnd(): void { Notifs.toggleDnd(); }
+ function clearNotifications(): void { Notifs.clearAll(); }
+ }
+}
diff --git a/quickshell/services/Audio.qml b/quickshell/services/Audio.qml
new file mode 100644
index 0000000..090e28a
--- /dev/null
+++ b/quickshell/services/Audio.qml
@@ -0,0 +1,99 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.Pipewire
+
+// Live audio state straight from PipeWire, so the bar and OSD react the instant
+// a volume key is pressed rather than on the next poll.
+Singleton {
+ id: root
+
+ readonly property PwNode sink: Pipewire.defaultAudioSink
+ readonly property PwNode source: Pipewire.defaultAudioSource
+
+ // Binding node.audio.* only works while the node is tracked.
+ PwObjectTracker {
+ objects: [root.sink, root.source].filter(n => n !== null)
+ }
+
+ readonly property bool ready: sink !== null && sink.audio !== null
+
+ readonly property real volume: sink?.audio?.volume ?? 0
+ readonly property bool muted: sink?.audio?.muted ?? true
+ readonly property int volumePercent: Math.round(volume * 100)
+ readonly property string sinkName: sink?.nickname || sink?.description || sink?.name || "No output"
+
+ readonly property real micVolume: source?.audio?.volume ?? 0
+ readonly property bool micMuted: source?.audio?.muted ?? true
+ readonly property int micPercent: Math.round(micVolume * 100)
+ readonly property string sourceName: source?.nickname || source?.description || source?.name || "No input"
+
+ // Every sink on the box, for the output picker in the audio popout.
+ readonly property var sinks: Pipewire.nodes.values.filter(n =>
+ n.isSink && !n.isStream && n.audio !== null)
+
+ // Application streams, for the per-app mixer.
+ readonly property var streams: Pipewire.nodes.values.filter(n =>
+ n.isStream && n.audio !== null && n.isSink === false)
+
+ PwObjectTracker {
+ objects: root.streams
+ }
+
+ signal volumeBumped()
+ signal micBumped()
+
+ onVolumePercentChanged: volumeBumped()
+ onMutedChanged: volumeBumped()
+ onMicPercentChanged: micBumped()
+ onMicMutedChanged: micBumped()
+
+ function setVolume(v: real) {
+ if (!sink?.audio) return;
+ sink.audio.volume = Math.max(0, Math.min(1.5, v));
+ }
+
+ function changeVolume(delta: real) {
+ setVolume(volume + delta);
+ }
+
+ function toggleMute() {
+ if (!sink?.audio) return;
+ sink.audio.muted = !sink.audio.muted;
+ }
+
+ function setMicVolume(v: real) {
+ if (!source?.audio) return;
+ source.audio.volume = Math.max(0, Math.min(1.5, v));
+ }
+
+ function toggleMicMute() {
+ if (!source?.audio) return;
+ source.audio.muted = !source.audio.muted;
+ }
+
+ function setSink(node: PwNode) {
+ Pipewire.preferredDefaultAudioSink = node;
+ }
+
+ function streamName(node: PwNode): string {
+ if (!node) return "";
+ return node.properties?.["application.name"]
+ || node.properties?.["media.name"]
+ || node.nickname
+ || node.description
+ || node.name;
+ }
+
+ // Nerd Font glyph for the current output level.
+ readonly property string icon: {
+ if (muted) return "";
+ if (volumePercent === 0) return "";
+ if (volumePercent < 34) return "";
+ if (volumePercent < 67) return "";
+ return "";
+ }
+
+ readonly property string micIcon: micMuted ? "" : ""
+}
diff --git a/quickshell/services/Battery.qml b/quickshell/services/Battery.qml
new file mode 100644
index 0000000..0633a1d
--- /dev/null
+++ b/quickshell/services/Battery.qml
@@ -0,0 +1,101 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.UPower
+import "root:/config"
+import "root:/services"
+
+// Battery and power-profile state. UPower gives live percentage and time
+// estimates; Sys.powerDetail adds the TLP/AC line fluxo reports.
+Singleton {
+ id: root
+
+ readonly property UPowerDevice device: UPower.displayDevice
+ readonly property bool available: device !== null && device.isLaptopBattery && device.isPresent
+ readonly property bool onBattery: UPower.onBattery
+
+ readonly property real percent: device?.percentage !== undefined ? device.percentage * 100 : 0
+ readonly property int state: device?.state ?? UPowerDeviceState.Unknown
+
+ readonly property bool charging: state === UPowerDeviceState.Charging
+ || state === UPowerDeviceState.PendingCharge
+ readonly property bool full: state === UPowerDeviceState.FullyCharged
+
+ readonly property real changeRate: device?.changeRate ?? 0 // watts
+ readonly property real health: device?.healthSupported ? (device?.healthPercentage ?? 0) : 0
+
+ readonly property bool low: available && !charging && percent <= 20
+ readonly property bool critical: available && !charging && percent <= 10
+
+ readonly property real secondsLeft: charging
+ ? (device?.timeToFull ?? 0)
+ : (device?.timeToEmpty ?? 0)
+
+ readonly property string timeLabel: {
+ if (root.full) return "Full";
+ if (!(root.secondsLeft > 0)) return charging ? "Charging" : "Estimating…";
+ const mins = Math.round(root.secondsLeft / 60);
+ const h = Math.floor(mins / 60);
+ const m = mins % 60;
+ const dur = h > 0 ? h + "h " + m + "m" : m + "m";
+ return root.charging ? dur + " to full" : dur + " left";
+ }
+
+ readonly property string detail: Sys.powerDetail
+
+ // Ten-step battery glyph ramp, plus the charging variants.
+ readonly property string icon: {
+ if (!root.available) return "";
+ const p = Math.max(0, Math.min(100, root.percent));
+ const step = Math.min(10, Math.floor(p / 10));
+ if (root.charging) {
+ const charge = ["", "", "", "", "", "", "", "", "", "", ""];
+ return charge[step];
+ }
+ const drain = ["", "", "", "", "", "", "", "", "", "", ""];
+ return drain[step];
+ }
+
+ readonly property color tint: {
+ if (root.charging || root.full) return Theme.ok;
+ if (root.critical) return Theme.accent;
+ if (root.low) return Theme.warn;
+ return Theme.primary;
+ }
+
+ // ------------------------------------------------------- power profiles
+ readonly property bool hasPerformance: PowerProfiles.hasPerformanceProfile
+ readonly property int profile: PowerProfiles.profile
+
+ readonly property string profileLabel: {
+ switch (root.profile) {
+ case PowerProfile.PowerSaver: return "Power Saver";
+ case PowerProfile.Performance: return "Performance";
+ default: return "Balanced";
+ }
+ }
+
+ readonly property string profileIcon: {
+ switch (root.profile) {
+ case PowerProfile.PowerSaver: return "";
+ case PowerProfile.Performance: return "";
+ default: return "";
+ }
+ }
+
+ function setProfile(p: int) {
+ PowerProfiles.profile = p;
+ }
+
+ function cycleProfile() {
+ if (root.profile === PowerProfile.PowerSaver) {
+ root.setProfile(PowerProfile.Balanced);
+ } else if (root.profile === PowerProfile.Balanced
+ && PowerProfiles.hasPerformanceProfile) {
+ root.setProfile(PowerProfile.Performance);
+ } else {
+ root.setProfile(PowerProfile.PowerSaver);
+ }
+ }
+}
diff --git a/quickshell/services/Brightness.qml b/quickshell/services/Brightness.qml
new file mode 100644
index 0000000..820a9ad
--- /dev/null
+++ b/quickshell/services/Brightness.qml
@@ -0,0 +1,75 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+// Backlight control. Reads the sysfs value directly so the OSD is exact, and
+// writes through brightnessctl, which already owns the udev permissions here.
+Singleton {
+ id: root
+
+ readonly property string device: "amdgpu_bl1"
+
+ property int raw: 0
+ property int maxRaw: 65535
+ readonly property real percent: maxRaw > 0 ? raw / maxRaw * 100 : 0
+ readonly property bool available: maxRaw > 0
+
+ signal bumped()
+
+ FileView {
+ id: maxFile
+ path: "/sys/class/backlight/" + root.device + "/max_brightness"
+ watchChanges: false
+ onLoaded: {
+ const v = parseInt(text().trim());
+ if (v > 0) root.maxRaw = v;
+ }
+ }
+
+ FileView {
+ id: currentFile
+ path: "/sys/class/backlight/" + root.device + "/brightness"
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: {
+ const v = parseInt(text().trim());
+ if (!isNaN(v) && v !== root.raw) {
+ root.raw = v;
+ root.bumped();
+ }
+ }
+ }
+
+ // sysfs writes do not always fire inotify for backlight, so poll gently too.
+ Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ onTriggered: currentFile.reload()
+ }
+
+ Process {
+ id: setter
+ }
+
+ function setPercent(p: real) {
+ const clamped = Math.max(1, Math.min(100, Math.round(p)));
+ setter.running = false;
+ setter.command = ["brightnessctl", "-d", root.device, "-q", "set", clamped + "%"];
+ setter.running = true;
+ }
+
+ function change(delta: real) {
+ setPercent(percent + delta);
+ }
+
+ readonly property string icon: {
+ const p = root.percent;
+ if (p >= 80) return "";
+ if (p >= 55) return "";
+ if (p >= 30) return "";
+ return "";
+ }
+}
diff --git a/quickshell/services/Bt.qml b/quickshell/services/Bt.qml
new file mode 100644
index 0000000..4c9375f
--- /dev/null
+++ b/quickshell/services/Bt.qml
@@ -0,0 +1,80 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Bluetooth
+
+// Bluetooth via BlueZ. Connection and pairing are driven directly from the
+// popout; `fluxo bt` stays available as a CLI for the existing keybind.
+Singleton {
+ id: root
+
+ readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
+ readonly property bool available: adapter !== null
+ readonly property bool enabled: adapter?.enabled ?? false
+ readonly property bool discovering: adapter?.discovering ?? false
+
+ readonly property var devices: adapter
+ ? adapter.devices.values.slice().sort((a, b) => {
+ if (a.connected !== b.connected) return a.connected ? -1 : 1;
+ if (a.paired !== b.paired) return a.paired ? -1 : 1;
+ return (a.deviceName || a.name || "").localeCompare(b.deviceName || b.name || "");
+ })
+ : []
+
+ readonly property var connected: devices.filter(d => d.connected)
+ readonly property BluetoothDevice primary: connected.length > 0 ? connected[0] : null
+
+ readonly property string label: {
+ if (!available) return "No adapter";
+ if (!enabled) return "Off";
+ if (connected.length === 0) return "Disconnected";
+ if (connected.length === 1) return root.deviceLabel(primary);
+ return connected.length + " devices";
+ }
+
+ readonly property string icon: {
+ if (!available || !enabled) return "";
+ if (connected.length > 0) return "";
+ return "";
+ }
+
+ function deviceLabel(d: BluetoothDevice): string {
+ if (!d) return "";
+ return d.deviceName || d.name || d.address;
+ }
+
+ // Nerd Font glyph matching BlueZ's icon hint.
+ function deviceIcon(d: BluetoothDevice): string {
+ const i = d?.icon || "";
+ if (i.includes("headset") || i.includes("headphone")) return "";
+ if (i.includes("audio")) return "";
+ if (i.includes("phone")) return "";
+ if (i.includes("mouse")) return "";
+ if (i.includes("keyboard")) return "";
+ if (i.includes("computer")) return "";
+ if (i.includes("watch")) return "";
+ if (i.includes("input-gaming")) return "";
+ if (i.includes("printer")) return "";
+ return "";
+ }
+
+ function toggle() {
+ if (adapter) adapter.enabled = !adapter.enabled;
+ }
+
+ function toggleScan() {
+ if (adapter) adapter.discovering = !adapter.discovering;
+ }
+
+ function toggleDevice(d: BluetoothDevice) {
+ if (!d) return;
+ if (d.connected) {
+ d.disconnect();
+ } else if (d.paired || d.bonded) {
+ d.connect();
+ } else {
+ d.pair();
+ }
+ }
+}
diff --git a/quickshell/services/Media.qml b/quickshell/services/Media.qml
new file mode 100644
index 0000000..c57ddfb
--- /dev/null
+++ b/quickshell/services/Media.qml
@@ -0,0 +1,99 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.Mpris
+
+// MPRIS. Prefers whichever player is actually playing, falling back to the last
+// one that was, so the bar does not flip between Spotify and a stray browser tab.
+Singleton {
+ id: root
+
+ property string preferredId: ""
+
+ readonly property var players: Mpris.players?.values ?? []
+ readonly property bool hasPlayer: active !== null
+
+ readonly property MprisPlayer active: {
+ if (players.length === 0) return null;
+
+ // An explicit pick wins, while it still exists.
+ if (root.preferredId !== "") {
+ for (const p of players) if (p.dbusName === root.preferredId) return p;
+ }
+ for (const p of players) if (p.isPlaying) return p;
+ return players[0];
+ }
+
+ readonly property string title: active?.trackTitle || ""
+ readonly property string artist: active?.trackArtist || ""
+ readonly property string album: active?.trackAlbum || ""
+ readonly property string artUrl: active?.trackArtUrl || ""
+ readonly property string identity: active?.identity || ""
+ readonly property bool playing: active?.isPlaying ?? false
+
+ readonly property bool canGoNext: active?.canGoNext ?? false
+ readonly property bool canGoPrevious: active?.canGoPrevious ?? false
+ readonly property bool canSeek: (active?.canSeek ?? false) && (active?.lengthSupported ?? false)
+
+ readonly property real length: active?.lengthSupported ? (active?.length ?? 0) : 0
+ readonly property real position: active?.positionSupported ? (active?.position ?? 0) : 0
+ readonly property real progress: length > 0 ? Math.min(1, position / length) : 0
+
+ readonly property string label: {
+ if (!hasPlayer) return "Nothing playing";
+ if (title === "") return identity || "Unknown track";
+ if (artist === "") return title;
+ return artist + " — " + title;
+ }
+
+ signal trackChanged()
+
+ onTitleChanged: trackChanged()
+
+ // MPRIS position is pull-only; tick it so the seek bar moves.
+ Timer {
+ interval: 500
+ running: root.playing && root.canSeek
+ repeat: true
+ onTriggered: if (root.active) root.active.positionChanged()
+ }
+
+ function playPause() { if (active?.canTogglePlaying) active.togglePlaying(); }
+ function next() { if (canGoNext) active.next(); }
+ function previous() { if (canGoPrevious) active.previous(); }
+ function seekTo(fraction: real) {
+ if (!canSeek || length <= 0) return;
+ active.position = Math.max(0, Math.min(length, fraction * length));
+ }
+ function raise() { if (active?.canRaise) active.raise(); }
+
+ function select(player: MprisPlayer) {
+ root.preferredId = player ? player.dbusName : "";
+ }
+
+ // Nerd Font glyph for the source app, so the bar reads at a glance.
+ readonly property string sourceIcon: {
+ const id = (identity || "").toLowerCase();
+ if (id.includes("spotify")) return "";
+ if (id.includes("firefox")) return "";
+ if (id.includes("chrom") || id.includes("brave")) return "";
+ if (id.includes("mpv")) return "";
+ if (id.includes("vlc")) return "";
+ if (id.includes("jellyfin")) return "";
+ if (id.includes("youtube")) return "";
+ return "";
+ }
+
+ function timeString(micros: real): string {
+ if (!(micros > 0)) return "0:00";
+ const total = Math.floor(micros / 1000000);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ if (m >= 60) {
+ const h = Math.floor(m / 60);
+ return h + ":" + String(m % 60).padStart(2, "0") + ":" + String(s).padStart(2, "0");
+ }
+ return m + ":" + String(s).padStart(2, "0");
+ }
+}
diff --git a/quickshell/services/Net.qml b/quickshell/services/Net.qml
new file mode 100644
index 0000000..64f5732
--- /dev/null
+++ b/quickshell/services/Net.qml
@@ -0,0 +1,96 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Networking
+
+// NetworkManager state for the network popout. Throughput and the active IP
+// come from Sys (fluxo); this covers device topology and the Wi-Fi picker.
+Singleton {
+ id: root
+
+ readonly property bool wifiEnabled: Networking.wifiEnabled
+ readonly property bool wifiHardwareEnabled: Networking.wifiHardwareEnabled
+ readonly property int connectivity: Networking.connectivity
+
+ readonly property var devices: Networking.devices?.values ?? []
+
+ readonly property var wifiDevices: devices.filter(d => d.type === DeviceType.Wifi)
+ readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired)
+
+ readonly property WifiDevice wifi: wifiDevices.length > 0 ? wifiDevices[0] : null
+ readonly property WiredDevice wired: wiredDevices.length > 0 ? wiredDevices[0] : null
+
+ readonly property bool wiredUp: wired?.connected ?? false
+
+ readonly property Network activeWifi: {
+ if (!wifi) return null;
+ const nets = wifi.networks?.values ?? [];
+ for (const n of nets) if (n.connected) return n;
+ return null;
+ }
+
+ // Strongest-first, one entry per SSID, current network pinned to the top.
+ readonly property var wifiNetworks: {
+ if (!wifi) return [];
+ const seen = {};
+ const out = [];
+ const nets = (wifi.networks?.values ?? []).slice().sort((a, b) =>
+ (b.signalStrength ?? 0) - (a.signalStrength ?? 0));
+ for (const n of nets) {
+ if (!n.name || seen[n.name]) continue;
+ seen[n.name] = true;
+ out.push(n);
+ }
+ return out.sort((a, b) => {
+ if (a.connected !== b.connected) return a.connected ? -1 : 1;
+ return (b.signalStrength ?? 0) - (a.signalStrength ?? 0);
+ });
+ }
+
+ readonly property bool scanning: wifi?.scannerEnabled ?? false
+
+ readonly property string icon: {
+ if (root.wiredUp) return "";
+ if (!root.wifiEnabled) return "";
+ if (!root.activeWifi) return "";
+ const s = root.activeWifi.signalStrength ?? 0;
+ // Strength is only sampled while a scan is running, which the shell
+ // does on demand. Connected-but-unsampled should read as full, not dead.
+ if (s <= 0) return "";
+ if (s >= 80) return "";
+ if (s >= 60) return "";
+ if (s >= 40) return "";
+ if (s >= 20) return "";
+ return "";
+ }
+
+ readonly property string label: {
+ if (root.wiredUp) return "Ethernet";
+ if (!root.wifiEnabled) return "Wi-Fi off";
+ if (root.activeWifi) return root.activeWifi.name;
+ return "Offline";
+ }
+
+ function signalIcon(strength: int): string {
+ if (strength >= 80) return "";
+ if (strength >= 60) return "";
+ if (strength >= 40) return "";
+ if (strength >= 20) return "";
+ return "";
+ }
+
+ function secured(n): bool {
+ return n && n.security !== undefined
+ && n.security !== WifiSecurityType.Open
+ && n.security !== WifiSecurityType.Unknown;
+ }
+
+ function toggleWifi() {
+ Networking.wifiEnabled = !Networking.wifiEnabled;
+ }
+
+ function setScanning(on: bool) {
+ if (wifi) wifi.scannerEnabled = on;
+ }
+}
diff --git a/quickshell/services/Notifs.qml b/quickshell/services/Notifs.qml
new file mode 100644
index 0000000..f5c6a02
--- /dev/null
+++ b/quickshell/services/Notifs.qml
@@ -0,0 +1,111 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.Notifications
+import "root:/config"
+
+// Notification server. Replaces dunst: the shell owns the org.freedesktop
+// .Notifications name and renders popups and the history list itself.
+Singleton {
+ id: root
+
+ property bool dnd: false
+
+ // Newest first. Popups read the head of this list.
+ property list history: []
+ property int maxHistory: 60
+
+ readonly property int count: history.length
+ readonly property bool hasUnread: unread > 0
+ property int unread: 0
+
+ NotificationServer {
+ id: server
+
+ keepOnReload: true
+ bodySupported: true
+ bodyMarkupSupported: true
+ bodyImagesSupported: true
+ actionsSupported: true
+ actionIconsSupported: true
+ imageSupported: true
+ inlineReplySupported: true
+ persistenceSupported: true
+
+ onNotification: notif => {
+ // Hold the notification open until we drop it ourselves.
+ notif.tracked = true;
+
+ root.history = [notif, ...root.history].slice(0, root.maxHistory);
+ root.unread++;
+
+ if (!root.dnd) root.popupRequested(notif);
+ }
+ }
+
+ signal popupRequested(Notification notif)
+
+ function dismiss(notif: Notification) {
+ if (!notif) return;
+ const idx = root.history.indexOf(notif);
+ if (idx !== -1) {
+ const next = root.history.slice();
+ next.splice(idx, 1);
+ root.history = next;
+ }
+ notif.dismiss();
+ }
+
+ function clearAll() {
+ const all = root.history.slice();
+ root.history = [];
+ root.unread = 0;
+ for (const n of all) n.dismiss();
+ }
+
+ function markRead() {
+ root.unread = 0;
+ }
+
+ function toggleDnd() {
+ root.dnd = !root.dnd;
+ }
+
+ readonly property string icon: {
+ if (root.dnd) return "";
+ if (root.hasUnread) return "";
+ return "";
+ }
+
+ // Urgency-driven accents for the popup chrome.
+ function urgencyColor(u: int): color {
+ switch (u) {
+ case NotificationUrgency.Critical: return Theme.accent;
+ case NotificationUrgency.Low: return Theme.muted;
+ default: return Theme.primary;
+ }
+ }
+
+ function appIconFor(notif: Notification): string {
+ if (!notif) return "";
+ const app = (notif.appName || "").toLowerCase();
+ if (app.includes("spotify")) return "";
+ if (app.includes("discord")) return "";
+ if (app.includes("slack")) return "";
+ if (app.includes("thunderbird") || app.includes("mail")) return "";
+ if (app.includes("firefox")) return "";
+ if (app.includes("volume") || app.includes("audio")) return "";
+ if (app.includes("screenshot") || app.includes("grim")) return "";
+ return "";
+ }
+
+ // How long a popup stays up, in ms.
+ function timeoutFor(notif: Notification): int {
+ if (!notif) return 5000;
+ if (notif.urgency === NotificationUrgency.Critical) return 0; // sticky
+ if (notif.expireTimeout > 0) return notif.expireTimeout;
+ if (notif.urgency === NotificationUrgency.Low) return 3500;
+ return 6000;
+ }
+}
diff --git a/quickshell/services/Sys.qml b/quickshell/services/Sys.qml
new file mode 100644
index 0000000..2e58188
--- /dev/null
+++ b/quickshell/services/Sys.qml
@@ -0,0 +1,223 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+// Hardware telemetry, sourced from the user's `fluxo` daemon. fluxo's hardware
+// modules are configured to emit raw pipe-delimited values (see
+// ~/.config/fluxo/config.toml); everything here is parsing and formatting.
+Singleton {
+ id: root
+
+ // Poll interval. The bar reads these continuously, so keep it modest.
+ property int interval: 2000
+
+ // ------------------------------------------------------------------ cpu
+ property real cpuUsage: 0
+ property real cpuTemp: 0
+ property string cpuModel: ""
+
+ // ------------------------------------------------------------------ mem
+ property real memUsed: 0 // GB
+ property real memTotal: 0 // GB
+ readonly property real memPercent: memTotal > 0 ? memUsed / memTotal * 100 : 0
+
+ // ------------------------------------------------------------------ gpu
+ property bool gpuAvailable: false
+ property real gpuUsage: 0
+ property real gpuVramUsed: 0
+ property real gpuVramTotal: 0
+ property real gpuTemp: 0
+ property string gpuModel: ""
+
+ // ----------------------------------------------------------------- disk
+ property real diskUsed: 0 // GB
+ property real diskTotal: 0 // GB
+ property real diskPercent: 0
+ property string diskFree: ""
+
+ // ------------------------------------------------------------------ sys
+ property string uptime: ""
+ property real load1: 0
+ property real load5: 0
+ property real load15: 0
+ property int procs: 0
+
+ // ------------------------------------------------------------------ net
+ property string netInterface: ""
+ property string netIp: ""
+ property real netRx: 0 // MB/s
+ property real netTx: 0 // MB/s
+ readonly property bool netUp: netInterface !== "" && netInterface !== "none"
+ readonly property bool netWireless: netInterface.startsWith("wl")
+
+ // -------------------------------------------------------------- battery
+ // UPower drives the battery UI; fluxo supplies the TLP/AC detail line.
+ property string powerDetail: ""
+
+ // fluxo wraps values in zero-width spaces for Waybar's benefit.
+ function _clean(s: string): string {
+ return (s || "").replace(//g, "").trim();
+ }
+
+ function _fields(json: string): var {
+ try {
+ const o = JSON.parse(json);
+ return { parts: root._clean(o.text).split("|"), obj: o };
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function _num(v): real {
+ const n = parseFloat(v);
+ return isNaN(n) ? 0 : n;
+ }
+
+ Timer {
+ interval: root.interval
+ running: true
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: {
+ cpuReader.running = true;
+ memReader.running = true;
+ gpuReader.running = true;
+ netReader.running = true;
+ sysReader.running = true;
+ }
+ }
+
+ // Disk and battery move slowly; poll them a tenth as often.
+ Timer {
+ interval: root.interval * 10
+ running: true
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: {
+ diskReader.running = true;
+ powerReader.running = true;
+ }
+ }
+
+ Process {
+ id: cpuReader
+ command: ["fluxo", "cpu"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const f = root._fields(text);
+ if (!f) return;
+ root.cpuUsage = root._num(f.parts[0]);
+ root.cpuTemp = root._num(f.parts[1]);
+ root.cpuModel = f.obj.tooltip || "";
+ }
+ }
+ }
+
+ Process {
+ id: memReader
+ command: ["fluxo", "mem"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const f = root._fields(text);
+ if (!f) return;
+ root.memUsed = root._num(f.parts[0]);
+ root.memTotal = root._num(f.parts[1]);
+ }
+ }
+ }
+
+ Process {
+ id: gpuReader
+ command: ["fluxo", "gpu"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const f = root._fields(text);
+ if (!f || f.parts.length < 4) {
+ root.gpuAvailable = false;
+ return;
+ }
+ root.gpuAvailable = true;
+ root.gpuUsage = root._num(f.parts[0]);
+ root.gpuVramUsed = root._num(f.parts[1]);
+ root.gpuVramTotal = root._num(f.parts[2]);
+ root.gpuTemp = root._num(f.parts[3]);
+ const m = /Model:\s*(.+)/.exec(f.obj.tooltip || "");
+ if (m) root.gpuModel = m[1];
+ }
+ }
+ }
+
+ Process {
+ id: diskReader
+ command: ["fluxo", "disk", "/"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const f = root._fields(text);
+ if (!f) return;
+ root.diskUsed = root._num(f.parts[1]);
+ root.diskTotal = root._num(f.parts[2]);
+ root.diskPercent = f.obj.percentage || 0;
+ const m = /Free:\s*(\S+)/.exec(f.obj.tooltip || "");
+ root.diskFree = m ? m[1] : "";
+ }
+ }
+ }
+
+ Process {
+ id: netReader
+ command: ["fluxo", "net"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const f = root._fields(text);
+ if (!f) return;
+ root.netInterface = f.parts[0] || "";
+ root.netIp = f.parts[1] || "";
+ root.netRx = root._num(f.parts[2]);
+ root.netTx = root._num(f.parts[3]);
+ }
+ }
+ }
+
+ Process {
+ id: sysReader
+ command: ["fluxo", "sys"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const f = root._fields(text);
+ if (!f) return;
+ root.uptime = f.parts[0] || "";
+ root.load1 = root._num(f.parts[1]);
+ root.load5 = root._num(f.parts[2]);
+ root.load15 = root._num(f.parts[3]);
+ const m = /Processes:\s*(\d+)/.exec(f.obj.tooltip || "");
+ if (m) root.procs = parseInt(m[1]);
+ }
+ }
+ }
+
+ Process {
+ id: powerReader
+ command: ["fluxo", "power"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ root.powerDetail = JSON.parse(text).tooltip || "";
+ } catch (e) {}
+ }
+ }
+ }
+
+ // ------------------------------------------------------------ formatting
+ function rate(mbps: real): string {
+ if (mbps >= 1) return mbps.toFixed(1) + " MB/s";
+ const kb = mbps * 1024;
+ if (kb >= 1) return Math.round(kb) + " KB/s";
+ return "idle";
+ }
+
+ function gb(v: real): string {
+ return v >= 100 ? Math.round(v) + "G" : v.toFixed(1) + "G";
+ }
+}
diff --git a/quickshell/shell.qml b/quickshell/shell.qml
new file mode 100644
index 0000000..a8ecadd
--- /dev/null
+++ b/quickshell/shell.qml
@@ -0,0 +1,58 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+import "root:/services"
+import "root:/bar"
+import "root:/overlays"
+import "root:/lock"
+
+// Takemi — a Persona 5 flavoured Quickshell desktop.
+//
+// bar/ the top bar and its modules
+// popouts/ hover panels hanging off bar modules
+// overlays/ power menu, notification toasts, OSD
+// lock/ session lock
+// services/ data sources (fluxo, pipewire, bluez, upower, mpris, …)
+// config/ palette, type, geometry, motion
+ShellRoot {
+ // Services are lazily constructed on first use. The shell wants several of
+ // them warm from the start — polling, the notification server, the audio
+ // OSD — so touch them here rather than waiting for a hover.
+ Component.onCompleted: {
+ void Sys.cpuUsage;
+ void Audio.ready;
+ void Battery.available;
+ void Brightness.percent;
+ void Notifs.count;
+ void Media.hasPlayer;
+ void Net.wifiEnabled;
+ void Bt.available;
+ void Actions.powerMenuOpen;
+ }
+
+ Variants {
+ model: Quickshell.screens
+
+ Bar {}
+ }
+
+ Variants {
+ model: Quickshell.screens
+
+ PowerMenu {}
+ }
+
+ Variants {
+ model: Quickshell.screens
+
+ Toasts {}
+ }
+
+ Variants {
+ model: Quickshell.screens
+
+ Osd {}
+ }
+
+ Lock {}
+}
diff --git a/systemd/user/dunst.service b/systemd/user/dunst.service
new file mode 120000
index 0000000..dc1dc0c
--- /dev/null
+++ b/systemd/user/dunst.service
@@ -0,0 +1 @@
+/dev/null
\ No newline at end of file
diff --git a/systemd/user/graphical-session.target.wants/waybar.service b/systemd/user/graphical-session.target.wants/waybar.service
deleted file mode 120000
index c2a0b64..0000000
--- a/systemd/user/graphical-session.target.wants/waybar.service
+++ /dev/null
@@ -1 +0,0 @@
-/usr/lib/systemd/user/waybar.service
\ No newline at end of file