From 47ff5233bde34b44a014e54156e854ce3f2edb3b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 10 Aug 2026 23:57:26 +0200 Subject: [PATCH] added quickshell --- .gitignore | 1 + fluxo/config.toml | 29 +- fluxo/config.toml.waybar-bak | 85 ++++ hypr/modules/autostart.conf | 5 + hypr/modules/keybinds.conf | 9 +- hypr/modules/takemi.conf | 25 ++ hypr/modules/theme.conf | 141 +++--- hypr/scripts/lid_handler.sh | 5 +- quickshell/README.md | 118 +++++ quickshell/assets/halftone.png | Bin 0 -> 338 bytes quickshell/assets/noise.png | Bin 0 -> 310 bytes quickshell/assets/scanline.png | Bin 0 -> 304 bytes quickshell/assets/stripes.png | Bin 0 -> 448 bytes quickshell/bar/ActiveWindow.qml | 75 ++++ quickshell/bar/Bar.qml | 159 +++++++ quickshell/bar/BarPill.qml | 107 +++++ quickshell/bar/Clock.qml | 101 +++++ quickshell/bar/MediaPill.qml | 185 ++++++++ quickshell/bar/NotifButton.qml | 75 ++++ quickshell/bar/PowerButton.qml | 42 ++ quickshell/bar/Resources.qml | 114 +++++ quickshell/bar/Sigil.qml | 45 ++ quickshell/bar/Status.qml | 115 +++++ quickshell/bar/StatusIcon.qml | 108 +++++ quickshell/bar/Tray.qml | 93 ++++ quickshell/bar/Workspaces.qml | 138 ++++++ quickshell/components/Gauge.qml | 120 +++++ quickshell/components/Geom.qml | 100 +++++ quickshell/components/Icon.qml | 45 ++ quickshell/components/Marquee.qml | 62 +++ quickshell/components/MaskedArt.qml | 14 + quickshell/components/MeterBar.qml | 59 +++ quickshell/components/NotifCard.qml | 191 ++++++++ quickshell/components/P5Button.qml | 106 +++++ quickshell/components/P5Panel.qml | 165 +++++++ quickshell/components/P5Slider.qml | 99 +++++ quickshell/components/Popout.qml | 135 ++++++ quickshell/components/SplitText.qml | 96 ++++ quickshell/components/TintEffect.qml | 11 + quickshell/config/Theme.qml | 101 +++++ quickshell/lock/Lock.qml | 25 ++ quickshell/lock/LockFace.qml | 419 ++++++++++++++++++ quickshell/lock/RailRow.qml | 64 +++ quickshell/lockpreview.qml | 23 + quickshell/overlays/Osd.qml | 182 ++++++++ quickshell/overlays/PowerMenu.qml | 285 ++++++++++++ quickshell/overlays/Toasts.qml | 148 +++++++ quickshell/popouts/AudioPopout.qml | 275 ++++++++++++ quickshell/popouts/BatteryPopout.qml | 119 +++++ quickshell/popouts/BtPopout.qml | 192 ++++++++ quickshell/popouts/CalendarPopout.qml | 260 +++++++++++ quickshell/popouts/MediaPopout.qml | 337 ++++++++++++++ quickshell/popouts/NetPopout.qml | 252 +++++++++++ quickshell/popouts/NotifPopout.qml | 113 +++++ quickshell/popouts/PopoutHeader.qml | 73 +++ quickshell/popouts/PopoutSurface.qml | 24 + quickshell/popouts/SysPopout.qml | 142 ++++++ quickshell/services/Actions.qml | 69 +++ quickshell/services/Audio.qml | 99 +++++ quickshell/services/Battery.qml | 101 +++++ quickshell/services/Brightness.qml | 75 ++++ quickshell/services/Bt.qml | 80 ++++ quickshell/services/Media.qml | 99 +++++ quickshell/services/Net.qml | 96 ++++ quickshell/services/Notifs.qml | 111 +++++ quickshell/services/Sys.qml | 223 ++++++++++ quickshell/shell.qml | 58 +++ systemd/user/dunst.service | 1 + .../waybar.service | 1 - 69 files changed, 6945 insertions(+), 80 deletions(-) create mode 100644 fluxo/config.toml.waybar-bak create mode 100644 hypr/modules/takemi.conf create mode 100644 quickshell/README.md create mode 100644 quickshell/assets/halftone.png create mode 100644 quickshell/assets/noise.png create mode 100644 quickshell/assets/scanline.png create mode 100644 quickshell/assets/stripes.png create mode 100644 quickshell/bar/ActiveWindow.qml create mode 100644 quickshell/bar/Bar.qml create mode 100644 quickshell/bar/BarPill.qml create mode 100644 quickshell/bar/Clock.qml create mode 100644 quickshell/bar/MediaPill.qml create mode 100644 quickshell/bar/NotifButton.qml create mode 100644 quickshell/bar/PowerButton.qml create mode 100644 quickshell/bar/Resources.qml create mode 100644 quickshell/bar/Sigil.qml create mode 100644 quickshell/bar/Status.qml create mode 100644 quickshell/bar/StatusIcon.qml create mode 100644 quickshell/bar/Tray.qml create mode 100644 quickshell/bar/Workspaces.qml create mode 100644 quickshell/components/Gauge.qml create mode 100644 quickshell/components/Geom.qml create mode 100644 quickshell/components/Icon.qml create mode 100644 quickshell/components/Marquee.qml create mode 100644 quickshell/components/MaskedArt.qml create mode 100644 quickshell/components/MeterBar.qml create mode 100644 quickshell/components/NotifCard.qml create mode 100644 quickshell/components/P5Button.qml create mode 100644 quickshell/components/P5Panel.qml create mode 100644 quickshell/components/P5Slider.qml create mode 100644 quickshell/components/Popout.qml create mode 100644 quickshell/components/SplitText.qml create mode 100644 quickshell/components/TintEffect.qml create mode 100644 quickshell/config/Theme.qml create mode 100644 quickshell/lock/Lock.qml create mode 100644 quickshell/lock/LockFace.qml create mode 100644 quickshell/lock/RailRow.qml create mode 100644 quickshell/lockpreview.qml create mode 100644 quickshell/overlays/Osd.qml create mode 100644 quickshell/overlays/PowerMenu.qml create mode 100644 quickshell/overlays/Toasts.qml create mode 100644 quickshell/popouts/AudioPopout.qml create mode 100644 quickshell/popouts/BatteryPopout.qml create mode 100644 quickshell/popouts/BtPopout.qml create mode 100644 quickshell/popouts/CalendarPopout.qml create mode 100644 quickshell/popouts/MediaPopout.qml create mode 100644 quickshell/popouts/NetPopout.qml create mode 100644 quickshell/popouts/NotifPopout.qml create mode 100644 quickshell/popouts/PopoutHeader.qml create mode 100644 quickshell/popouts/PopoutSurface.qml create mode 100644 quickshell/popouts/SysPopout.qml create mode 100644 quickshell/services/Actions.qml create mode 100644 quickshell/services/Audio.qml create mode 100644 quickshell/services/Battery.qml create mode 100644 quickshell/services/Brightness.qml create mode 100644 quickshell/services/Bt.qml create mode 100644 quickshell/services/Media.qml create mode 100644 quickshell/services/Net.qml create mode 100644 quickshell/services/Notifs.qml create mode 100644 quickshell/services/Sys.qml create mode 100644 quickshell/shell.qml create mode 120000 systemd/user/dunst.service delete mode 120000 systemd/user/graphical-session.target.wants/waybar.service 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 0000000000000000000000000000000000000000..78d7dd6f67487659f1719e284039fd3779e0c2ca GIT binary patch literal 338 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFq3dtTpz6=aiY77hwEes65fI|H(?D8gRi>Fdh=ii1l;TUKlS z;=e#4)e_f;l9a@fRIB8oR3OD*WME{bYha;kXb@s#U}a)#Wn`&sU|?lnFgJXO8H$G7 z{FKbJO57UWaLIH5HE6(XD9OxCEiOsSEx@g3x4dmAP>+$Pi(`ny<>Z6~T0j2(-+$@< z|No1Dczuhvkfes80T3vz+~{;U*>wu%4UwY>Q;)DRJY}=sP)`YO1nOq+boFyt=akR{ E03(}WasU7T literal 0 HcmV?d00001 diff --git a/quickshell/assets/noise.png b/quickshell/assets/noise.png new file mode 100644 index 0000000000000000000000000000000000000000..c2a1e39aa6df8cba8a8de7ec8204e3f404844240 GIT binary patch literal 310 zcmeAS@N?(olHy`uVBq!ia0vp^Od!k%Bp9O38gv3Fg=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(GY3GxeOU?`h>)&j&!@^*J&V7%KUyadQ&FY)ws zWq-xNC88}7>?|D#6jCj5jVMV;EJ?LWE=mPb3`Pb9nO2EgL*=AMkPRAe8%i>BQ;SOya|>|m(QB`01M1=Nba4#fxSpJm bP{6>zc!c4gr>mAHP@KWj)z4*}Q$iB}!c0qg literal 0 HcmV?d00001 diff --git a/quickshell/assets/scanline.png b/quickshell/assets/scanline.png new file mode 100644 index 0000000000000000000000000000000000000000..3bbcefd92a3e93fd1293168d62dbf1838f07276d GIT binary patch literal 304 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=6p}rHd>I(3)EF2VS{N990fib~ zFff!FFfhDIU|_JC!N4G1FlSew4N!tD$=lt9;Xep2*t>i(P=vk2)7O>#6$h7ywyf6t z#eac9swJ)wB`Jv|saDBFsX&Us$iT=<*T6#8&>+Ofz{&buq|Nmdlc8Y<4 Ybt3b>CI5F#0m?CWy85}Sb4q9e04efP!~g&Q literal 0 HcmV?d00001 diff --git a/quickshell/assets/stripes.png b/quickshell/assets/stripes.png new file mode 100644 index 0000000000000000000000000000000000000000..bdd28e29c591fc7c437e42ad7d0b30e44ef06aa3 GIT binary patch literal 448 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj3dtTpz6=aiY77hwEes65fI|H(?D8gRi>Fdh=ii1l;TUKlS z;=e#4)e_f;l9a@fRIB8oR3OD*WME{bYha;kXb@s#U}a)#Wn`&sU|?lnFgJXO8H$G7 z{FKbJO57UWaLIH5HE6(XD9OxCEiOsSEx@g3x4dmAP|pHS7sn8f<6Ey7@*P&-aeMe( zeP=BDyZY%0<(g|-3q)0B^FDc4-8^xo 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