diff --git a/.gitignore b/.gitignore index 47c527d..90c9912 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ /config/wg/** # Ignore Neovim lazy-lock file /config/dot/nvim/lazy-lock.json +# Local visual-companion sessions and generated design mocks. +/.superpowers/ diff --git a/README.md b/README.md index bd6d868..041aa0b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ # Panama -## Formerly Sunhat, this is a personal config for Fedora, with the intention of helping a user set up their Fedora system with one command. + +Formerly Sunhat. A personal config for Fedora, with the intention of helping a +user set up their Fedora system with one command. + +```sh +git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama +~/.local/share/Panama/install +``` + +`install` runs everything in `setup/scripts/` in order: + +| Script | Does | +|---|---| +| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the four package lists in `setup/packages/` | +| `link-dotfiles` | Symlinks `config/dot/` → `~/.config/` | +| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | + +Existing configs are moved to `config/old/` rather than overwritten. + +## Desktops + +Panama configures **two** desktops that coexist. Both sessions stay available in +GDM, so you can switch back and forth while you settle in. + +### GNOME +The original setup: Forge for tiling, Dash-to-Dock, Openbar, Vitals, +AppIndicator support. Configured through `config/dot/forge/` and gsettings. + +### Hyprland +A from-scratch replacement built to reproduce the GNOME setup closely enough +that muscle memory transfers — same keybinds, same panel contents, same dock, +same Tokyo Night Moon palette. + +| Piece | What it is | +|---|---| +| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README | +| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI | +| `config/dot/vicinae/` | Raycast-style launcher, themed | +| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) | +| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start | +| `config/dot/xdg-desktop-portal/` | Portal backend routing | + +**Start here: [`config/dot/hypr/README.md`](config/dot/hypr/README.md)** — it +covers the Lua migration, the uwsm environment gotcha, the HDR decision, the +full keymap, and troubleshooting. + +Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland". + +## Layout + +``` +bin/ Small user-facing commands on PATH +config/ + bash/ .bashrc, aliases, env (env is gitignored) + copy/ Files copied verbatim over / (needs sudo) + dot/ Symlinked into ~/.config + old/ Backups of whatever was replaced (gitignored) +setup/ + packages/ One package per line + scripts/ Run in order by ./install +``` diff --git a/config/bash/.bashrc b/config/bash/.bashrc index eaae856..322b53e 100644 --- a/config/bash/.bashrc +++ b/config/bash/.bashrc @@ -24,3 +24,8 @@ else fi export PATH fi +. "$HOME/.cargo/env" + +# >>> Codex installer >>> +export PATH="/home/gib/.local/bin:$PATH" +# <<< Codex installer <<< diff --git a/config/copy/etc/udev/rules.d/99-panama-gpu.rules b/config/copy/etc/udev/rules.d/99-panama-gpu.rules new file mode 100644 index 0000000..dc46a75 --- /dev/null +++ b/config/copy/etc/udev/rules.d/99-panama-gpu.rules @@ -0,0 +1,18 @@ +# Stable, colon-free symlinks for the DRM devices. +# +# Why this exists: Hyprland's AQ_DRM_DEVICES selects which GPU renders, but +# - /dev/dri/cardN numbering is assigned at boot and is not stable, and +# - /dev/dri/by-path/ names contain colons, which AQ_DRM_DEVICES itself uses +# as its separator, so they cannot be used. +# +# These rules are keyed to specific PCI addresses and simply do not match on +# other machines; hypr/env.lua checks that the symlink exists before setting +# AQ_DRM_DEVICES, so a non-match is harmless. +# +# Apply without rebooting: sudo udevadm control --reload && sudo udevadm trigger + +# desktop-gib: Radeon RX 7800 XT (discrete, drives DP-2) +KERNEL=="card*", KERNELS=="0000:03:00.0", SUBSYSTEM=="drm", SUBSYSTEMS=="pci", SYMLINK+="dri/amd-dgpu" + +# desktop-gib: Granite Ridge integrated graphics +KERNEL=="card*", KERNELS=="0000:12:00.0", SUBSYSTEM=="drm", SUBSYSTEMS=="pci", SYMLINK+="dri/amd-igpu" diff --git a/config/dot/forge/config/windows.json b/config/dot/forge/config/windows.json index 258d825..fc3c7db 100644 --- a/config/dot/forge/config/windows.json +++ b/config/dot/forge/config/windows.json @@ -67,6 +67,10 @@ { "wmClass": "Spotify", "mode": "float" + }, + { + "wmClass": "bitwarden", + "mode": "float" } ] } \ No newline at end of file diff --git a/config/dot/forge/stylesheet/forge/stylesheet.css b/config/dot/forge/stylesheet/forge/stylesheet.css index c80c7ac..aff744d 100644 --- a/config/dot/forge/stylesheet/forge/stylesheet.css +++ b/config/dot/forge/stylesheet/forge/stylesheet.css @@ -107,7 +107,7 @@ } .window-floated-border { - border-width: 1px; + border-width: 3px; border-color: rgb(98,160,234); border-style: solid; border-radius: 14px; diff --git a/config/dot/forge/stylesheet/forge/stylesheet.css.bak b/config/dot/forge/stylesheet/forge/stylesheet.css.bak index 12fee7f..c80c7ac 100644 --- a/config/dot/forge/stylesheet/forge/stylesheet.css.bak +++ b/config/dot/forge/stylesheet/forge/stylesheet.css.bak @@ -29,41 +29,47 @@ } .window-tiled-border { - border-width: 4px; - border-color: rgb(130,170,255); + border-width: 3px; + border-color: rgb(98,160,234); border-style: solid; border-radius: 14px; } .window-split-border { - border-width: 4px; - border-color: rgb(130,170,255); + border-width: 3px; + border-color: rgb(98,160,234); border-style: solid; border-radius: 14px; } +/* forge-36le: St does not paint an asymmetric (single-side) border when a + * border-radius is set, so the directional hint never rendered. Drop the radius + * on the one-edge variants; St paints a straight single-side border reliably. */ + .window-split-horizontal { border-left-width: 0; border-top-width: 0; border-bottom-width: 0; + border-radius: 0; } .window-split-vertical { border-left-width: 0; border-top-width: 0; border-right-width: 0; + border-radius: 0; } .window-stacked-border { - border-width: 4px; - border-color: rgb(130,170,255); + border-width: 3px; + border-color: rgb(98,160,234); border-style: solid; border-radius: 14px; } .window-tabbed-border { - border-width: 4px; - border-color: rgb(130,170,255); + border-width: 3px; + border-color: rgb(98,160,234); border-style: solid; border-radius: 14px; } @@ -74,7 +80,7 @@ .window-tabbed-tab { background-color: rgba(54, 47, 45, 1); - border-color: rgba(130,170,255,0.6); + border-color: rgba(98,160,234,0.6); border-width: 1px; border-radius: 8px; color: white; @@ -83,7 +89,7 @@ } .window-tabbed-tab-active { - background-color: rgb(130,170,255); + background-color: rgb(98,160,234); color: black; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); } @@ -101,32 +107,84 @@ } .window-floated-border { - border-width: 4px; - border-color: rgb(130,170,255); + border-width: 1px; + border-color: rgb(98,160,234); border-style: solid; border-radius: 14px; } .window-tilepreview-tiled { border-width: 1px; - border-color: rgba(130,170,255,0.3); + border-color: rgba(98,160,234,0.3); border-style: solid; border-radius: 14px; - background-color: rgba(130,170,255,0.2); + background-color: rgba(98,160,234,0.2); } .window-tilepreview-stacked { border-width: 1px; - border-color: rgba(130,170,255,0.3); + border-color: rgba(98,160,234,0.3); border-style: solid; border-radius: 14px; - background-color: rgba(130,170,255,0.2); + background-color: rgba(98,160,234,0.2); +} + +.window-tilepreview-swap { + border-width: 1px; + border-color: rgba(162, 247, 43, 0.4); + border-style: solid; + border-radius: 14px; + background-color: rgba(162, 247, 43, 0.4); } .window-tilepreview-tabbed { border-width: 1px; - border-color: rgba(130,170,255,0.3); + border-color: rgba(98,160,234,0.3); border-style: solid; border-radius: 14px; - background-color: rgba(130,170,255,0.2); + background-color: rgba(98,160,234,0.2); +} + +/* Keybindings Cheatsheet Overlay */ + +.forge-cheatsheet { + background-color: #2d2d2d; + border-radius: 16px; + padding: 24px 32px; + border: 1px solid #4d4d4d; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.forge-cheatsheet-title { + font-size: 16px; + font-weight: bold; + color: #ffffff; + margin-bottom: 20px; + text-align: center; +} + +.forge-cheatsheet-category { + font-size: 13px; + font-weight: bold; + color: #78aeed; + margin-top: 16px; + margin-bottom: 8px; +} + +.forge-cheatsheet-row { + font-size: 12px; + color: #eeeeec; + margin: 4px 0; + padding: 2px 0; +} + +.forge-cheatsheet-key { + font-family: monospace; + font-weight: bold; + color: #f5c211; + min-width: 180px; +} + +.forge-cheatsheet-desc { + color: #d0cfcc; } \ No newline at end of file diff --git a/config/dot/gtk-3.0/bookmarks b/config/dot/gtk-3.0/bookmarks index 05c23a6..f0644ed 100644 --- a/config/dot/gtk-3.0/bookmarks +++ b/config/dot/gtk-3.0/bookmarks @@ -1,8 +1,8 @@ -file:///home/gib/Documents -file:///home/gib/Downloads -file:///home/gib/Music -file:///home/gib/Pictures -file:///home/gib/Videos -file:///home/gib/Documents/Code +file:///home/gib/Documents Documents +file:///home/gib/Documents/Code Code +file:///home/gib/Downloads Downloads +file:///home/gib/Music Music +file:///home/gib/Pictures Pictures +file:///home/gib/Videos Videos file:///home/gib/.local/share/Panama Panama -smb://server.gib/ Gib's Server +smb://server-gib.local/homes/ Gib's Server diff --git a/config/dot/gtk-3.0/settings.ini b/config/dot/gtk-3.0/settings.ini index b8f5d10..02eb639 100644 --- a/config/dot/gtk-3.0/settings.ini +++ b/config/dot/gtk-3.0/settings.ini @@ -1,9 +1,18 @@ [Settings] -gtk-theme-name=Tahoe-Dark +# These values must match `gsettings get org.gnome.desktop.interface ...`. +# +# Under GNOME, gnome-settings-daemon publishes settings over XSETTINGS and this +# file is effectively ignored. Under Hyprland there is no settings daemon, so +# this file becomes authoritative for GTK3 — which is why it previously named +# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything +# appearing broken. +gtk-theme-name=adw-gtk3-dark gtk-icon-theme-name=Adwaita -gtk-font-name=Cantarell 11 -gtk-cursor-theme-name=WhiteSur-cursors +gtk-font-name=Adwaita Sans 11 +gtk-cursor-theme-name=oreo_blue_cursors gtk-cursor-theme-size=24 +gtk-application-prefer-dark-theme=1 + gtk-toolbar-style=GTK_TOOLBAR_ICONS gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR gtk-button-images=0 @@ -14,4 +23,3 @@ gtk-xft-antialias=1 gtk-xft-hinting=1 gtk-xft-hintstyle=hintslight gtk-xft-rgba=rgb -gtk-application-prefer-dark-theme=0 diff --git a/config/dot/gtk-4.0/settings.ini b/config/dot/gtk-4.0/settings.ini index 7c6461a..bb748bb 100644 --- a/config/dot/gtk-4.0/settings.ini +++ b/config/dot/gtk-4.0/settings.ini @@ -1,2 +1,11 @@ [Settings] -gtk-application-prefer-dark-theme=0 +# libadwaita apps normally take their dark preference from the +# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which +# reads gsettings). This file is the fallback for plain GTK4 apps and for the +# window before the portal answers. +gtk-application-prefer-dark-theme=1 +gtk-theme-name=adw-gtk3-dark +gtk-icon-theme-name=Adwaita +gtk-font-name=Adwaita Sans 11 +gtk-cursor-theme-name=oreo_blue_cursors +gtk-cursor-theme-size=24 diff --git a/config/dot/hypr/DESKTOP-PARITY.md b/config/dot/hypr/DESKTOP-PARITY.md new file mode 100644 index 0000000..5d7c5b3 --- /dev/null +++ b/config/dot/hypr/DESKTOP-PARITY.md @@ -0,0 +1,101 @@ +# Panama desktop parity + +This is the living inventory for the GNOME-to-Hyprland transition. It records +real behavior, not a one-for-one count of extensions: an enabled extension with +no configured action is not treated as missing functionality. + +Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0. + +## Daily desktop surfaces + +| Capability | Panama implementation | Status | +|---|---|---| +| App launcher and run dialog | Vicinae; Wofi emergency fallback | Live | +| App grid | Vicinae applications provider | Live | +| Hidden applications | GNOME App Hider list in Vicinae provider config | Live | +| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live | +| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live | +| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live | +| Dock | Quickshell dock with GNOME favourites and intellihide | Live | +| Panel | Workspaces, CPU/RAM/GPU, clock, weather, media, clipboard, tray, status | Live | +| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live | +| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live | +| Date menu | Notifications, month calendar, weather, conditional media card | Live | +| Quick settings | Wi-Fi, Bluetooth, audio input/output, caffeine, night light, focus | Live | +| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live | +| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor | +| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live | +| Screenshot and recording | Quickshell picker, grim/slurp/satty/wf-recorder | Live | +| Screen text and code recognition | Local Tesseract/ZBar Read mode, selectable Prism result sheet | Live after package install | +| Lock, idle, and power | hyprlock, hypridle, login1, Quickshell power menu | Live | +| Wallpaper | hyprpaper | Live | +| Keyring and authorization | gnome-keyring and hyprpolkitagent | Live | +| Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live | +| Removable media | udiskie plus udisks notifications | Live | +| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live | +| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live | + +## GNOME extension migration + +| GNOME extension | Result | +|---|---| +| Forge | Replaced by Hyprland tiling and the original keymap | +| Dash to Dock | Replaced by the Quickshell dock | +| AppIndicator | Replaced by the Quickshell system tray | +| Clipboard Indicator | Replaced by the bar clipboard panel and Vicinae history | +| Emoji Copy | Replaced by Vicinae emoji search on `SUPER+.` | +| Vitals | Replaced by the CPU, memory, and GPU bar readouts | +| Weather O'Clock | Replaced by bar weather and the date-menu weather card | +| Caffeine | Replaced by a real logind inhibitor in quick settings | +| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur | +| Bluetooth Quick Connect | Replaced by the full Bluetooth picker | +| Wi-Fi QR | Package and UI support still to be completed | +| GSConnect | No paired phone profile was present; KDE Connect support is staged | +| Home Assistant | Useful integration, but needs a purpose-built panel and secure credential migration | +| Custom Hot Corners Extended | No action was configured, so there is no behavior to port | +| App Hider | Hidden app list is now applied in Vicinae | +| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test | + +## System UI still worth adding + +These are not blockers for normal use, but they are the remaining places where +a polished general-purpose desktop can go beyond the current shell. + +1. Calendar events, not just the month grid. The right integration point is + Evolution Data Server so existing GNOME/Google calendars remain the source + of truth. +2. Home Assistant favourites in a dedicated, secure quick panel. The token must + stay in the keyring or a private state file, never in this repository. +4. Phone integration through KDE Connect, including battery/share status in the + tray. The package is staged but needs installation and a new pairing. +5. Connected-network QR sharing after `qrencode` is installed. Password access + must be on demand and must never enter shell logs. +6. A printer administration GUI. CUPS is already active; the staged + `system-config-printer` package supplies the missing UI. +7. A real external RustDesk test while hyprlock is active. Local process state + cannot prove what a remote client sees or whether input is accepted. + +Packages staged in `setup/packages/hyprland-packages` but not installed in the +current session because privilege escalation requires an interactive password: + +```sh +sudo dnf install kde-connect qrencode system-config-printer tesseract zbar +``` + +## Global menu decision + +A macOS-style global application menu is not being shipped. Quickshell can +render a `DBusMenu` that an application already exports, but it does not provide +an application-menu registrar or a reliable focused-window-to-menu association. +Hyprland has no cross-toolkit global-menu protocol, and the daily applications +audited here do not consistently export one: GTK 4/Nautilus and Electron apps +are the important failures. + +That would produce a premium-looking empty strip for most windows and a working +menu for a minority, which is worse than omitting it. Relevant references: + +- +- + +A future focused-window menu could expose compositor actions such as float, +move, fullscreen, and close. It should not be labelled as an application menu. diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md new file mode 100644 index 0000000..259b44a --- /dev/null +++ b/config/dot/hypr/README.md @@ -0,0 +1,250 @@ +# Panama :: Hyprland + +A Hyprland desktop built to reproduce the GNOME + Forge setup it replaces, so +that muscle memory transfers unchanged. + +## The one thing to know first + +**Hyprland 0.55 replaced hyprlang with Lua.** `hyprland.conf` still loads on +0.56 as a legacy fallback and is removed in 0.57. Everything here is Lua. + +The authoritative option reference for the installed build is on disk: + +| File | What it is | +|---|---| +| `/usr/share/hypr/stubs/hl.meta.lua` | Generated type stub — the exact option list for this build | +| `/usr/share/hypr/hyprland.lua` | Upstream example config | + +Wire the stub into your editor with `.luarc.json` in this directory for +autocomplete. + +**The Hypr ecosystem tools did NOT move to Lua.** `hyprlock.conf`, +`hypridle.conf`, `hyprpaper.conf` and `hyprtoolkit.conf` are still hyprlang. +Don't "fix" them. + +## Layout + +| File | Contents | +|---|---| +| `hyprland.lua` | Entry point. Each `require()` is its own error scope | +| `env.lua` | Environment. Note the uwsm caveat below | +| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision | +| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout | +| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME | +| `rules.lua` | Window rules, gaming rules, layer rules for the shell | +| `keybinds.lua` | The full keymap | +| `autostart.lua` | Session startup | +| `overrides.lua` | Per-machine escape hatch, loaded last | +| `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) | + +Validate any change without leaving your session: + +```sh +Hyprland --verify-config +``` + +## The look + +Tokyo Night Moon, with two accents that come from the tmux theme: + +| Token | Value | Role | +|---|---|---| +| `accent` | `#82aaff` | Carries every state meaning — focused, active, on | +| `accentSecondary` | `#b172b0` | Never used alone; only the far end of a gradient | + +The signature is **the prism**: the two accents meeting. It appears in exactly +four places, and nowhere else — + +1. A one-pixel hairline along the top edge of every glass surface (the bar, + dock, popovers), running blue on the left to orchid on the right and fading + out before the corners. See `quickshell/widgets/PrismEdge.qml`. +2. The focused window's border — `general.col.active_border`, blue→orchid at + 115°. Unfocused windows get no colour at all, because the gradient only + means something if one window on screen is wearing it. +3. The active workspace pill in the bar. +4. Slider fills. + +The restraint is the point. The pink stops being special the moment it's used +as a flat fill, so it never is. + +### Typography + +**Adwaita Sans for everything the user reads as text — no monospace in the UI.** +A monospaced clock or percentage reads as terminal output pasted into a panel, +which is the opposite of the intent. + +The Nerd Font is still used, but only to draw **icon glyphs** — it is the +pragmatic alternative to freedesktop symbolic icons, which ship with a +hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolour. Where a +themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml` +paints a palette colour through the icon's alpha. + +Anything whose digits change in place — the clock, the vitals percentages, the +recording timer, the selection readout — sets `font.features: +Theme.tabularFigures`. Tabular figures share one advance width, so the text +stops twitching as numbers tick without reaching for a monospaced face. + +Window rounding is 18, matching the shell's popover radius, so a window and a +panel next to each other read as the same family of object. + +**On animated gradient borders:** Hyprland can rotate the border gradient +continuously with `borderangle` + `style = "loop"`. Don't. The wiki is explicit +that it forces a full-refresh-rate repaint forever, even when no border is +visible — at 4500×3000 that is pure idle GPU burn. This config uses +`style = "once"`, so the gradient sweeps into place when a window takes focus +and then costs nothing. + +## Session: use the uwsm one + +Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland". + +`xdg-desktop-portal-hyprland`'s systemd unit requires `graphical-session.target`, +and Fedora ships no `hyprland-session.target`. Without uwsm that target never +activates and **screen sharing in OBS, Sunshine and Zoom silently fails.** + +### The uwsm environment gotcha + +`hl.env()` in `env.lua` exports into the *compositor's* environment, **not** the +systemd user manager. Anything started as a user unit — `vicinae.service`, +`hyprpaper.service`, `hyprpolkitagent.service`, `hypridle.service` — and every +app those launch will not see it. + +That is why `~/.config/uwsm/env` and `~/.config/uwsm/env-hyprland` exist and +duplicate the important variables. Keep them in sync with `env.lua`; `env.lua` +remains the fallback for the plain session. + +## HDR — read this before turning it on + +GNOME ran this panel in bt2100/HDR. This config deliberately does not. + +On Hyprland 0.56.x, `cm = "hdr"` currently breaks **screencopy**: `grim` +screenshots come back empty, and OBS/Sunshine capture and hyprlock's blurred +background go with them. Root cause is still open upstream. + +So the desktop runs SDR at 10-bit (`cm = "auto"`) and HDR is handed to +fullscreen games only, via `render.cm_auto_hdr = 1` in `looks.lua`. Games get +HDR; screenshots keep working. + +To try full-time HDR anyway, `overrides.lua` has the block and the ordered list +of workarounds to reach for when things break. + +## Keymap + +The mental model is unchanged from Forge: + +- **SUPER** acts on windows +- **ALT** acts on workspaces +- **SUPER + CTRL** changes layout structure + +### Windows +| Key | Action | +|---|---| +| `SUPER + H/J/K/L` (or arrows) | Focus | +| `SUPER + SHIFT + H/J/K/L` | Move window | +| `SUPER + CTRL + H/J/K/L` | Swap window | +| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower | +| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter | +| `SUPER + [` / `]` / `=` | Shrink / expand / reset split | +| `SUPER + Q` | Close | +| `SUPER + U` | Fullscreen | +| `SUPER + CTRL + C` | Toggle float | +| `SUPER + CTRL + SHIFT + C` | Pin (nearest thing to Forge's "always float") | +| `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down | +| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows | +| `SUPER + SHIFT + grave` | Last window | +| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimise to scratchpad | + +### Workspaces (dynamic, like GNOME) +| Key | Action | +|---|---| +| `ALT + H` / `ALT + L` | Previous / next workspace | +| `ALT + SHIFT + H/L` | Move window to previous / next | +| `ALT + 1..9`, `ALT + 0` | Jump to workspace | +| `ALT + SHIFT + 1..9` | Send window to workspace | +| `SUPER + scroll` | Change workspace | + +### Launcher and shell +| Key | Action | +|---|---| +| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite | +| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken | +| `SUPER + V` | Clipboard history | +| `SUPER + .` | Emoji picker | +| `SUPER + S` | Quick settings | +| `SUPER + I` | Panama Settings | +| `SUPER + SHIFT + F` | Start or reveal focus session | +| `SUPER + B` | Notification centre | +| `SUPER + grave` | Workspace overview | +| `Print` | Screenshot / record picker | +| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately | +| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection | +| `SUPER + SHIFT + P` | Colour picker | +| `CTRL + ALT + L` | Lock (SUPER+L is "focus right") | +| `CTRL + ALT + Delete` | Power menu | + +### Apps +`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator · +`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME +Settings remains searchable in Vicinae for hardware and account panels. + +## Screen Intelligence + +`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read +mode. Read is also available beside Screenshot and Record under `Print`, and +works on a whole display, a window, or a region. Tesseract recognizes English +text locally; ZBar recognizes QR codes and barcodes. The result sheet can copy +text, search it, translate it, or open a detected web address. No pixels leave +the workstation unless one of those explicit network actions is selected. + +## Deliberate deviations from GNOME + +These are the places a 1:1 port was impossible, and what was done instead: + +- **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an + axis and lets the layout pick the edge. The eight Forge keys collapse to four + behaviours, keeping the horizontal/vertical and grow/shrink pairing. +- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a + modifier misfires when you're quick with SUPER combos. +- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap. +- **`SUPER + grave` was Forge's "cycle windows of same app"**, which Hyprland + has no equivalent for. "Last window" moved to `SUPER + SHIFT + grave`. +- **`gnome-control-center` is launched with `XDG_CURRENT_DESKTOP=GNOME`**, + because it hard-refuses to start otherwise. Panels backed by system services + work; panels backed by GNOME Shell (Displays, Keyboard Shortcuts, + Multitasking, Appearance) are inert — Hyprland owns those now. + +## Gaming + +`content = "game"` on the window rules in `rules.lua` is the keystone — it is +what `misc.vrr = 3`, `render.direct_scanout = 2` and `cursor.no_break_fs_vrr = 2` +key off. Games additionally get blur, animation, shadow and dim disabled, plus +`immediate` for tearing. + +Tearing only takes effect when the game is fullscreen and the only thing on +screen — no bar, no notifications. + +## Do not install a notification daemon + +`mako`, `dunst` and `SwayNotificationCenter` all register +`Name=org.freedesktop.Notifications` for D-Bus activation. Installing any of +them creates a startup race against Quickshell's notification server, and +whoever wins keeps the name. If one gets installed as a dependency, +`systemctl --user mask .service`. + +## Troubleshooting + +**Nothing autostarts** — check `systemctl --user status hyprland-session.target` +and `systemctl --user show-environment | grep WAYLAND_DISPLAY`. An empty +environment means uwsm didn't export it and units with +`ConditionEnvironment=WAYLAND_DISPLAY` silently skip. + +**Screen sharing missing** — `busctl --user list | grep impl.portal` should show +`xdg-desktop-portal-hyprland`. + +**Qt apps look wrong** — `QT_QPA_PLATFORMTHEME` must be `gtk3`. It is a single +value, not a list: Qt splits on `:` and uses only the first token. + +**Wrong GPU / won't start** — `AQ_DRM_DEVICES` points at +`/dev/dri/amd-dgpu`, created by `config/copy/etc/udev/rules.d/99-panama-gpu.rules`. +Both `env.lua` and `uwsm/env-hyprland` guard on the symlink existing, so a +missing rule degrades to "let aquamarine choose" rather than failing to start. diff --git a/config/dot/hypr/autostart.lua b/config/dot/hypr/autostart.lua new file mode 100644 index 0000000..27c99c9 --- /dev/null +++ b/config/dot/hypr/autostart.lua @@ -0,0 +1,60 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Autostart +-- +-- `exec-once` no longer exists in 0.56; startup is an event handler. +-- +-- Session: Fedora installs two sessions, "Hyprland" and "Hyprland +-- (uwsm-managed)". Panama targets the uwsm one, because +-- xdg-desktop-portal-hyprland's systemd unit requires graphical-session.target +-- and Fedora ships no hyprland-session.target -- without uwsm, screen sharing +-- in OBS/Sunshine/Zoom silently fails. The target is started explicitly below +-- so the plain session works too. +-- +-- Anything that ships a systemd user unit is started as a unit rather than as +-- a compositor child. That is not cosmetic: under uwsm, units get the correct +-- activation environment (see uwsm/env), they restart on failure, and they +-- shut down in order. Panama's `change-settings` script enables them once; +-- the explicit starts here make a fresh clone work before that has run. +-- ───────────────────────────────────────────────────────────────────────────── + +hl.on("hyprland.start", function() + -- Publish the session environment to systemd and D-Bus so user units and + -- the portals can see WAYLAND_DISPLAY. Cheap, and essential without uwsm. + hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland") + hl.exec_cmd("systemctl --user start hyprland-session.target") + + -- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. + hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service") + + -- The shell: bar, dock, overview, quick settings, notifications, capture. + -- No systemd unit ships with quickshell, so it runs as a compositor child. + hl.exec_cmd("quickshell --daemonize") + + -- Removable-media automounting. GNOME did this invisibly via gvfs+udisks; + -- outside GNOME something has to ask udisks to mount. No tray icon: the + -- Quickshell bar already has a tray, and udiskie's own icon would be + -- redundant. -f opens Nautilus when you click the mount notification. + hl.exec_cmd("udiskie --automount --notify --no-tray --file-manager nautilus") + + -- Keyring unlock, for Nextcloud and Bitwarden credential storage. + hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11") + + -- Tray applications carried over from ~/.config/autostart. These need the + -- Quickshell tray (an SNI host) to be up, hence starting after it. + -- + -- Commands are copied verbatim from the GNOME .desktop files rather than + -- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and + -- its autostart entry launches a specific script with --autostart. + -- + -- RustDesk is deliberately absent: it ships an enabled *system* service + -- (`rustdesk --service`) that spawns --server and --tray for the session on + -- its own. Starting it here as well would give you two trays. + hl.exec_cmd("nextcloud --background") + hl.exec_cmd("flatpak run --command=bitwarden.sh com.bitwarden.desktop --autostart") +end) + +hl.on("hyprland.shutdown", function() + hl.exec_cmd("systemctl --user stop hyprland-session.target") +end) + +return true diff --git a/config/dot/hypr/env.lua b/config/dot/hypr/env.lua new file mode 100644 index 0000000..07f6937 --- /dev/null +++ b/config/dot/hypr/env.lua @@ -0,0 +1,84 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Environment +-- +-- hl.env() sets variables before the display server initialises, which is what +-- makes it valid for AQ_* and cursor variables. +-- +-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm +-- prefers these live in ~/.config/uwsm/env and ~/.config/uwsm/env-hyprland +-- instead. They still work here; see the session notes in autostart.lua. +-- ───────────────────────────────────────────────────────────────────────────── + +-- ── GPU selection ─────────────────────────────────────────────────────────── +-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU +-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render. +-- +-- /dev/dri/cardN numbering is not stable across boots, and the by-path names +-- contain colons -- which are AQ_DRM_DEVICES' own separator, so they cannot be +-- used directly. Panama installs a udev rule creating colon-free stable +-- symlinks (/dev/dri/amd-dgpu, /dev/dri/amd-igpu); see +-- config/copy/etc/udev/rules.d/99-panama-gpu.rules +-- +-- First entry is the primary renderer. The iGPU stays in the list so anything +-- plugged into its outputs still lights up. +-- +-- Guarded on the symlink actually existing: this config is shared across +-- machines, and pointing AQ_DRM_DEVICES at a device that isn't there stops +-- Hyprland from starting at all. If the udev rule hasn't been installed (or +-- this is a different box), we say nothing and let aquamarine choose. +local function exists(path) + local f = io.open(path, "r") + if f then f:close() return true end + return false +end + +if exists("/dev/dri/amd-dgpu") then + local devices = "/dev/dri/amd-dgpu" + if exists("/dev/dri/amd-igpu") then + devices = devices .. ":/dev/dri/amd-igpu" + end + hl.env("AQ_DRM_DEVICES", devices) +end + +-- ── Cursor ────────────────────────────────────────────────────────────────── +-- Theme carried over from GNOME. +-- +-- Deliberately no HYPRCURSOR_THEME: oreo_blue_cursors is a plain XCursor theme +-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl), +-- so setting it would point hyprcursor at nothing. Hyprland falls back to the +-- XCursor path, which is what we want. +hl.env("XCURSOR_THEME", "oreo_blue_cursors") +hl.env("XCURSOR_SIZE", "24") + +-- ── Toolkits ──────────────────────────────────────────────────────────────── +-- Wayland first, X11 only as a fallback for apps that genuinely need it. +hl.env("QT_QPA_PLATFORM", "wayland;xcb") + +-- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3 +-- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than +-- approximating it -- and it gives them GTK file/font/colour dialogs. It also +-- covers Qt5 and Qt6 with one value. +-- +-- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses +-- only the first token, so the commonly-copied "qt5ct:qt6ct" silently means +-- just "qt5ct". +hl.env("QT_QPA_PLATFORMTHEME", "gtk3") +hl.env("QT_WAYLAND_DISABLE_WINDOWDECORATION", "1") +hl.env("QT_AUTO_SCREEN_SCALE_FACTOR", "1") +hl.env("GDK_BACKEND", "wayland,x11") +hl.env("SDL_VIDEODRIVER", "wayland") +hl.env("CLUTTER_BACKEND", "wayland") +hl.env("MOZ_ENABLE_WAYLAND", "1") + +-- Electron apps (Slack, Obsidian, Claude, Codex, BlueBubbles, Podman Desktop) +-- pick Wayland from this rather than needing per-app flags. +hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") + +-- ── Desktop identity ──────────────────────────────────────────────────────── +-- Portals and some GNOME apps key off these. uwsm sets them itself, so these +-- are only load-bearing for the plain (non-uwsm) session. +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") + +return true diff --git a/config/dot/hypr/hypridle.conf b/config/dot/hypr/hypridle.conf new file mode 100644 index 0000000..c191099 --- /dev/null +++ b/config/dot/hypr/hypridle.conf @@ -0,0 +1,39 @@ +# ───────────────────────────────────────────────────────────────────────────── +# hypridle — idle behaviour +# +# Mirrors the GNOME power profile this replaces, exactly: +# org.gnome.desktop.session idle-delay = 300 (screen off at 5min) +# org.gnome.desktop.screensaver lock-delay = 300 (lock 5min after that) +# org.gnome.settings-daemon.plugins.power +# sleep-inactive-ac-type = nothing (never auto-suspend) +# power-button-action = nothing +# +# So: screen off at 5 minutes, lock at 10, never suspend. This is a desktop. +# +# hyprlang syntax, not Lua — hypridle is a separate project from Hyprland. +# ───────────────────────────────────────────────────────────────────────────── + +general { + # `pidof` guard prevents stacking lockers if this fires twice. + lock_cmd = pidof hyprlock || hyprlock + before_sleep_cmd = loginctl lock-session + after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })' + + # 2 = auto. Holds off sleep until the locker is actually up. + inhibit_sleep = 2 +} + +# 5 minutes -> screen off. +listener { + timeout = 300 + on-timeout = hyprctl dispatch 'hl.dsp.dpms({ action = "off" })' + on-resume = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })' +} + +# 10 minutes -> lock. Matches GNOME's lock-delay of 300s after blanking. +listener { + timeout = 600 + on-timeout = loginctl lock-session +} + +# Deliberately no suspend listener: sleep-inactive-ac-type was 'nothing'. diff --git a/config/dot/hypr/hyprland.lua b/config/dot/hypr/hyprland.lua new file mode 100644 index 0000000..4fc243b --- /dev/null +++ b/config/dot/hypr/hyprland.lua @@ -0,0 +1,26 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Panama :: Hyprland +-- +-- Hyprland 0.55 replaced hyprlang (hyprland.conf) with Lua. The .conf format +-- still loads as a legacy fallback in 0.56 but is removed in 0.57, so this +-- config is Lua-native. Reference for every option below: +-- /usr/share/hypr/stubs/hl.meta.lua <- authoritative for THIS build +-- /usr/share/hypr/hyprland.lua <- upstream example +-- +-- Entry point only. Each require() is its own error scope, so a mistake in one +-- module doesn't take the whole config down with it. +-- +-- Reload: hyprctl reload +-- Check: Hyprland --verify-config +-- ───────────────────────────────────────────────────────────────────────────── + +require("env") +require("monitors") +require("looks") +require("input") +require("rules") +require("keybinds") +require("autostart") + +-- Per-machine overrides, loaded last so it wins. Ships effectively empty. +require("overrides") diff --git a/config/dot/hypr/hyprlock.conf b/config/dot/hypr/hyprlock.conf new file mode 100644 index 0000000..f662b64 --- /dev/null +++ b/config/dot/hypr/hyprlock.conf @@ -0,0 +1,114 @@ +# ───────────────────────────────────────────────────────────────────────────── +# hyprlock — lock screen +# +# Tokyo Night Moon, matching quickshell/config/Theme.qml: +# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f +# +# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland. +# ───────────────────────────────────────────────────────────────────────────── + +general { + hide_cursor = true + + # 2 = auto. Matters at the 1.5x fractional scale this display runs. + fractional_scaling = 2 + + # 0 = GPU-accelerated screencopy. 1 is a CPU fallback and is very slow at + # 4500x3000. + screencopy_mode = 0 + + fail_timeout = 2000 +} + +auth { + pam:enabled = true + pam:module = hyprlock +} + +# ── Background ────────────────────────────────────────────────────────────── +# `path = screenshot` grabs the desktop at lock time and blurs it in place, +# which is what GNOME does. +# +# Caveat: this uses screencopy. If the desktop is ever switched to full-time +# HDR (see hypr/overrides.lua), this background may come back black — that is +# the same upstream screencopy bug, not a hyprlock misconfiguration. +background { + monitor = + path = screenshot + + blur_passes = 3 + blur_size = 8 + noise = 0.0117 + contrast = 0.9 + brightness = 0.8 + vibrancy = 0.17 + vibrancy_darkness = 0.05 + + # Shown if the screenshot is unavailable. + color = rgba(34, 36, 54, 1.0) + + zindex = -1 +} + +# ── Clock ─────────────────────────────────────────────────────────────────── +label { + monitor = + text = cmd[update:1000] date +"%-I:%M" + color = rgba(200, 211, 245, 1.0) + font_size = 120 + font_family = Adwaita Sans Light + position = 0, 260 + halign = center + valign = center +} + +label { + monitor = + text = cmd[update:60000] date +"%A, %B %-d" + color = rgba(130, 139, 184, 1.0) + font_size = 24 + font_family = Adwaita Sans + position = 0, 160 + halign = center + valign = center +} + +# ── Password field ────────────────────────────────────────────────────────── +input-field { + monitor = + size = 340, 52 + position = 0, -40 + halign = center + valign = center + + outline_thickness = 2 + rounding = 26 + + outer_color = rgba(130, 170, 255, 0.9) + inner_color = rgba(46, 47, 61, 0.85) + font_color = rgba(200, 211, 245, 1.0) + check_color = rgba(130, 170, 255, 1.0) + fail_color = rgba(255, 117, 127, 1.0) + + dots_size = 0.25 + dots_spacing = 0.3 + dots_center = true + + placeholder_text = Password + fail_text = $FAIL ($ATTEMPTS) + + fade_on_empty = false + hide_input = false +} + +# ── User ──────────────────────────────────────────────────────────────────── +label { + monitor = + text = $USER + color = rgba(200, 211, 245, 0.9) + font_size = 16 + font_family = Adwaita Sans + position = 0, -110 + halign = center + valign = center +} diff --git a/config/dot/hypr/hyprpaper.conf b/config/dot/hypr/hyprpaper.conf new file mode 100644 index 0000000..cef63bf --- /dev/null +++ b/config/dot/hypr/hyprpaper.conf @@ -0,0 +1,36 @@ +# ───────────────────────────────────────────────────────────────────────────── +# hyprpaper — static wallpaper +# +# NOTE: hyprpaper, hypridle, hyprlock and hyprtoolkit are separate projects and +# still use hyprlang. Only Hyprland itself moved to Lua. Don't "fix" these. +# +# SYNTAX NOTE: hyprpaper 0.8 was rewritten on hyprtoolkit/hyprwire and replaced +# the old flat syntax with blocks. The pre-0.8 form +# +# preload = /path/to.jpg +# wallpaper = DP-2,/path/to.jpg +# +# is silently ignored — it parses, then logs "Monitor DP-2 has no target: no wp +# will be created" and shows nothing. Almost every guide online still uses it. +# +# Static only, by choice: animated wallpapers repaint continuously and cost +# frame time for nothing. +# ───────────────────────────────────────────────────────────────────────────── + +wallpaper { + monitor = DP-2 + path = ~/Pictures/Wallpapers/faroe_islands.jpg + # cover = fill the output, cropping overflow. Matches the "zoom" picture + # option this setup used under GNOME. + fit_mode = cover +} + +# Fallback for any output without its own block above. +wallpaper { + monitor = + path = ~/Pictures/Wallpapers/faroe_islands.jpg + fit_mode = cover +} + +ipc = on +splash = false diff --git a/config/dot/hypr/hyprtoolkit.conf b/config/dot/hypr/hyprtoolkit.conf new file mode 100644 index 0000000..64e4b95 --- /dev/null +++ b/config/dot/hypr/hyprtoolkit.conf @@ -0,0 +1,34 @@ +# ───────────────────────────────────────────────────────────────────────────── +# hyprtoolkit — shared theme for the first-party Hypr GUI apps +# +# One file themes all of these, which is why they're preferred over the usual +# grab-bag of GTK/Qt utilities: +# hyprshutdown session exit dialog (bound to the power menu) +# hyprpwcenter PipeWire / audio routing GUI (replaces a pavucontrol/TUI) +# hyprsysteminfo system info panel +# hyprland-guiutils hyprland-dialog / hyprland-run +# hyprpolkitagent authentication prompts (via hyprland-qt-support) +# +# Colours are Tokyo Night Moon, matching quickshell/config/Theme.qml and +# hypr/looks.lua. Format is hyprlang; path is fixed at +# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit). +# ───────────────────────────────────────────────────────────────────────────── + +background = 0xFF1E2030 # bg_dark — window background +base = 0xFF222436 # bg — raised surfaces +alternate_base = 0xFF2F334D # bg_highlight — rows, hover +text = 0xFFC8D3F5 # fg +bright_text = 0xFFFFFFFF +link_text = 0xFF82AAFF # blue + +accent = 0xFF82AAFF # blue — same accent as everything else +accent_secondary = 0xFFC099FF # magenta + +font_family = Adwaita Sans +font_family_monospace = VictorMono Nerd Font + +icon_theme = Adwaita + +# Match the rounding used by the Quickshell popovers and the Hyprland windows. +rounding_large = 18 +rounding_small = 8 diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua new file mode 100644 index 0000000..96391e9 --- /dev/null +++ b/config/dot/hypr/input.lua @@ -0,0 +1,40 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Input +-- +-- Deliberately GNOME-like: click-to-focus, no focus-follows-mouse, no mouse +-- acceleration changes. The goal is that muscle memory transfers untouched. +-- ───────────────────────────────────────────────────────────────────────────── + +hl.config({ + input = { + kb_layout = "us", + kb_variant = "", + kb_model = "", + kb_options = "", + kb_rules = "", + + numlock_by_default = true, + + -- GNOME's defaults are 500ms delay / 33Hz repeat. + repeat_delay = 500, + repeat_rate = 33, + + -- 1 = click to focus. GNOME's behaviour; NOT sloppy focus. + follow_mouse = 1, + + -- Don't refocus on mouse move alone -- only on click. + mouse_refocus = false, + + -- Flat pointer response, no acceleration. Matters for gaming. + sensitivity = 0, + accel_profile = "flat", + + -- Clicking a floating window raises and focuses it. + float_switch_override_focus = 2, + }, +}) + +-- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever +-- runs this config, add touchpad settings in overrides.lua. + +return true diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua new file mode 100644 index 0000000..b08c8c5 --- /dev/null +++ b/config/dot/hypr/keybinds.lua @@ -0,0 +1,243 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Keybindings +-- +-- Ported 1:1 from the GNOME + Forge setup this replaces. Where Hyprland has no +-- equivalent, the deviation is called out in a comment rather than silently +-- dropped. +-- +-- The mental model, unchanged from Forge: +-- SUPER -> act on windows (focus / move / swap / resize) +-- ALT -> act on workspaces +-- SUPER + CTRL -> change layout structure (split, float, swap) +-- ───────────────────────────────────────────────────────────────────────────── + +local mod = "SUPER" + +-- Programs, matched to the GNOME custom keybindings and media-key settings. +local terminal = "kitty" +local editor = "kitty nvim ." +local browser = "helium-browser-bin" +local files = "nautilus --new-window" +local calculator = "gnome-calculator" +local mail = "flatpak run org.mozilla.thunderbird_esr" +-- Panama owns the Hyprland and shell controls. GNOME Settings remains installed +-- and searchable in Vicinae for its hardware/account panels. +local settings = "qs ipc call settings toggle" +local sysmonitor = "flatpak run io.missioncenter.MissionCenter" + +-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the +-- window against the already-running server (started in autostart.lua). +local launcher = "vicinae toggle" + +-- Quickshell IPC targets. See quickshell/shell.qml for the handlers. +local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end + +-- ── Applications ──────────────────────────────────────────────────────────── +hl.bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" }) +hl.bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" }) +hl.bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" }) +hl.bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" }) +hl.bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) +hl.bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" }) +hl.bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" }) +hl.bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) + +-- ── Launcher ──────────────────────────────────────────────────────────────── +-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were +-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a +-- third option to settle on. Vicinae covers apps, calculator, files, clipboard, +-- emoji and window switching, so a separate run dialog and app grid are gone. +hl.bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) +hl.bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) +hl.bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) + +-- Emergency fallback launcher. Vicinae runs as a systemd user service and the +-- bar is Quickshell; if either fails to come up, this is how you start an +-- application without dropping to a TTY. Depends on nothing but wofi itself. +hl.bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" }) + +-- Clipboard history and emoji, straight into the relevant launcher view. +-- Deeplink form is the one from vicinae's own Hyprland quickstart. +hl.bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"), + { description = "Clipboard history" }) +hl.bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"), + { description = "Emoji picker" }) + +-- ── Shell surfaces (Quickshell) ───────────────────────────────────────────── +-- SUPER+S was GNOME's quick settings; kept. +hl.bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" }) + +-- Start a 45-minute focus session on the current workspace, or reveal its +-- Signal Glass controls if one is already running. +hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" }) + +-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a +-- modifier misfires when you're fast with SUPER+key combos, so it lives on a +-- real chord instead. SUPER+grave was Forge's "cycle windows of same app", +-- which Hyprland has no equivalent for. +hl.bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" }) + +-- Notification centre. +hl.bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" }) + +-- Screenshot / screen record. One key, then pick screen / window / region and +-- whether to capture or record -- reproducing GNOME's Print-screen UI. +hl.bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" }) +-- The GNOME direct-capture variants, kept as shortcuts past the picker. +hl.bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" }) +hl.bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" }) + +-- Local OCR and QR/barcode recognition through the same region picker. This +-- opens directly in Selection + Read mode; Print still exposes every mode. +hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")), + { description = "Screen Intelligence" }) + +-- Colour picker: copies the hex under the cursor to the clipboard. +hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" }) + +-- ── Window management ─────────────────────────────────────────────────────── +hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) +hl.bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" }) + +-- Forge: window-toggle-float / window-toggle-always-float. +-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin +-- is the nearest useful thing -- the window floats above every workspace. +hl.bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" }) +hl.bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" }) + +-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical. +hl.bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" }) +hl.bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" }) +hl.bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" }) + +-- Forge: window-shrink / window-expand / window-reset-sizes. +hl.bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" }) +hl.bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" }) +hl.bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" }) + +-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge. +hl.bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" }) +hl.bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" }) +hl.bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" }) +hl.bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" }) +hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" })) +hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" })) +hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" })) +hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" })) + +-- Move (Forge: window-move-*). +hl.bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" }) +hl.bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" }) +hl.bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" }) +hl.bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" }) + +-- Swap (Forge: window-swap-*). +hl.bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" }) +hl.bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" }) +hl.bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" }) +hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" }) + +-- Resize (Forge: window-resize--). +-- +-- Forge resized one named edge at a time. Hyprland resizes the active window +-- along an axis and lets the layout decide which edge actually moves, so the +-- eight Forge keys collapse onto four behaviours. The pairing is kept +-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical, +-- and "increase" always grows while "decrease" always shrinks. +local step = 60 +hl.bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) +hl.bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) +hl.bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) +hl.bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) +hl.bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) +hl.bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) +hl.bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) +hl.bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) + +-- Window cycling (GNOME: cycle-windows on SUPER+Tab). +hl.bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" }) +hl.bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" }) +-- Jump back to the previously focused window. +hl.bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" }) + +-- Mouse: drag to move, right-drag to resize. +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) + +-- ── Workspaces ────────────────────────────────────────────────────────────── +-- ALT is the workspace modifier, matching the GNOME setup. +-- +-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces: +-- moving right past the last workspace creates a new one, and moving left from +-- the first clamps instead of wrapping. +hl.bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) +hl.bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) +hl.bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" }) +hl.bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" }) + +-- GNOME also had these on CTRL+ALT+Up/Down. +hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" })) +hl.bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" })) + +-- Direct jump. ALT+0 is workspace 10. +for i = 1, 10 do + local key = i % 10 + hl.bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i }) + hl.bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i }) +end + +-- Scroll the mouse wheel over the desktop with SUPER held to change workspace. +-- (Scrolling the workspace indicator in the bar does the same; that's handled +-- in quickshell/modules/bar/Workspaces.qml.) +hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" })) +hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" })) + +-- Minimise, as far as Hyprland has one. +-- +-- Hyprland has no minimise: it receives the request (the binary has +-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes +-- no dispatcher, no config option and not even an event to hook, so titlebar +-- minimise buttons are inert and cannot be made to work. A tiling WM has no +-- iconified state and no taskbar to restore from. +-- +-- The scratchpad is the honest equivalent: the window goes away, and the same +-- key brings it back. Bound to X to match the muscle memory it replaces. +hl.bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" }) +hl.bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" }) + +-- ── Session ───────────────────────────────────────────────────────────────── +-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to +-- CTRL+ALT+L -- the other binding most people already have in muscle memory. +hl.bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" }) +hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" }) + +-- ── Media and volume ──────────────────────────────────────────────────────── +-- locked = true keeps these working on the lock screen, as they do in GNOME. +-- 6% steps match the GNOME volume-step setting. +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true }) +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true }) + +-- Fine-grained steps, matching GNOME's shift/alt volume modifiers. +hl.bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true }) +hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true }) + +hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) +hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) +hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) +hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) +hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true }) + +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true }) + +-- Hardware keys GNOME mapped that have obvious equivalents. +hl.bind("XF86Tools", hl.dsp.exec_cmd(settings)) +hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator)) +hl.bind("XF86Explorer", hl.dsp.exec_cmd(files)) +hl.bind("XF86WWW", hl.dsp.exec_cmd(browser)) +hl.bind("XF86Mail", hl.dsp.exec_cmd(mail)) +hl.bind("XF86Search", hl.dsp.exec_cmd(launcher)) + +return true diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua new file mode 100644 index 0000000..cd1dd74 --- /dev/null +++ b/config/dot/hypr/looks.lua @@ -0,0 +1,218 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Look and feel -- Tokyo Night Moon +-- +-- Colours here must stay in sync with quickshell/config/Theme.qml. +-- accent #82aaff borders / focus +-- bg #222436 base +-- +-- Performance note: every animation below is event-driven. Nothing uses the +-- "loop" style on borderangle/shadowangle/glowangle -- that style forces +-- Hyprland to render at full refresh rate forever, even when nothing is +-- visible, which is exactly the kind of idle GPU burn to avoid on a 4500x3000 +-- display. +-- ───────────────────────────────────────────────────────────────────────────── + +hl.config({ + general = { + gaps_in = 5, + gaps_out = 10, + + border_size = 2, + + col = { + -- The prism: blue leads, orchid follows, on a diagonal so the pair + -- is visible on both a tall and a wide window. Same two colours as + -- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the + -- tmux theme this palette came from. + active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, + -- Unfocused windows get no colour at all. The gradient only means + -- something if exactly one window on screen is wearing it. + inactive_border = "rgba(3b426199)", + }, + + resize_on_border = true, + + -- Enables the per-window "immediate" rule used for games in rules.lua. + -- Harmless on its own; tearing only happens where a rule opts in. + allow_tearing = true, + + layout = "dwindle", + + snap = { enabled = true }, + }, + + decoration = { + -- 18 to match the shell's popover radius, so a window and a panel sitting + -- next to each other read as the same object family. + rounding = 18, + rounding_power = 2, + + active_opacity = 1.0, + inactive_opacity = 1.0, + + blur = { + enabled = true, + size = 8, + passes = 3, + + -- Required for blur to be affordable. Never turn this off. + new_optimizations = true, + -- Floating windows skip blurring what's behind tiled ones. Big win. + xray = true, + + ignore_opacity = true, + noise = 0.0117, + contrast = 0.9, + brightness = 0.85, + vibrancy = 0.17, + vibrancy_darkness = 0.05, + + popups = true, + popups_ignorealpha = 0.2, + + -- Blurring the special workspace is expensive and rarely seen. + special = false, + }, + + shadow = { + enabled = true, + range = 20, + render_power = 3, + sharp = false, + color = "rgba(15161eee)", + offset = { 0, 4 }, + scale = 1.0, + }, + + -- New in 0.56. Kept deliberately faint: in this direction the gradient + -- border is the signature, and a strong halo would compete with it. + -- This is just enough to lift the focused window off the wallpaper. + glow = { + enabled = true, + range = 8, + render_power = 2, + color = "rgba(82aaff33)", + color_inactive = "rgba(00000000)", + }, + + -- Off: costs real frame time and reads as smeary on a 60Hz panel. + motion_blur = { enabled = false }, + }, + + animations = { enabled = true }, + + dwindle = { + -- Keep the split orientation a window was created with. Closest match + -- to how the Forge extension behaved on GNOME. + preserve_split = true, + smart_resizing = true, + }, + + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + disable_splash_rendering = true, + + font_family = "Adwaita Sans", + + -- Variable refresh rate. 3 = enable only for fullscreen windows whose + -- content type is "video" or "game" -- the tag rules.lua applies to + -- games. Keeps VRR off the desktop, where it causes visible flicker. + vrr = 3, + + -- Blur behind the lock screen. + session_lock_blur = true, + + -- Let a crashed lock screen be recovered rather than stranding you. + allow_session_lock_restore = true, + + -- Don't let apps steal focus by shouting; matches GNOME's behaviour. + focus_on_activate = false, + }, + + render = { + -- Colour management is stable and on by default in 0.56. + cm_enabled = true, + + -- 1 = automatically flip the monitor into HDR for fullscreen content + -- that asks for it, and back out afterwards. This is how games get HDR + -- without the desktop paying the screencopy cost. See monitors.lua. + cm_auto_hdr = 1, + + -- 2 = direct scanout only for windows tagged content = "game" + -- (set by the rules in rules.lua). Bypasses compositing for real + -- fullscreen games. + direct_scanout = 2, + }, + + cursor = { + -- 2 = auto. Disables the hardware cursor when tearing, which is what + -- keeps the cursor from ghosting in games. + no_hardware_cursors = 2, + + -- Don't let the cursor break VRR on content tagged as a game. + no_break_fs_vrr = 2, + min_refresh_rate = 48, + + -- Pull the cursor theme from gsettings so GTK apps and Hyprland agree. + sync_gsettings_theme = true, + + -- Fade the cursor out after 4s of no movement, like GNOME does. + inactive_timeout = 4, + }, + + ecosystem = { + no_update_news = true, + no_donation_nag = true, + }, + + xwayland = { + -- Render X11 apps at 1:1 device pixels so they're sharp rather than + -- bilinearly upscaled. See the note in README.md about the unavoidable + -- sizing tradeoff at 1.5x. + force_zero_scaling = true, + }, + + quirks = { + -- 2 = report HDR as preferred to gamescope only. Stops gamescope from + -- white-screening when it expects HDR to already be live. + prefer_hdr = 2, + }, +}) + +-- ── Motion ────────────────────────────────────────────────────────────────── +-- speed is in deciseconds: speed = 4 means 400ms. Higher = slower. + +hl.curve("easeOutQuint", { type = "bezier", points = { { 0.23, 1 }, { 0.32, 1 } } }) +hl.curve("almostLinear", { type = "bezier", points = { { 0.5, 0.5 }, { 0.75, 1 } } }) +hl.curve("quick", { type = "bezier", points = { { 0.15, 0 }, { 0.1, 1 } } }) +hl.curve("easy", { type = "spring", mass = 1, stiffness = 238.1191, dampening = 24.21279 }) +-- A touch bouncier than "easy"; used for windows appearing. +hl.curve("snappy", { type = "spring", mass = 1, stiffness = 320, dampening = 22 }) + +hl.animation({ leaf = "global", enabled = true, speed = 10, bezier = "default" }) +hl.animation({ leaf = "border", enabled = true, speed = 5.39, bezier = "easeOutQuint" }) + +-- The focus sweep: when a window takes focus, its gradient border rotates into +-- place once. Style "once" is load-bearing -- the alternative, "loop", makes +-- Hyprland render at full refresh rate forever, even when no border is visible. +-- That is exactly the idle GPU cost this config exists to avoid, so: never loop. +hl.animation({ leaf = "borderangle", enabled = true, speed = 6, bezier = "easeOutQuint", style = "once" }) + +hl.animation({ leaf = "windows", enabled = true, speed = 4.79, spring = "easy" }) +hl.animation({ leaf = "windowsIn", enabled = true, speed = 4.1, spring = "snappy", style = "popin 90%" }) +hl.animation({ leaf = "windowsOut", enabled = true, speed = 1.49, bezier = "almostLinear", style = "popin 90%" }) + +hl.animation({ leaf = "fade", enabled = true, speed = 3.03, bezier = "quick" }) + +-- Layer surfaces = the Quickshell bar, dock, popovers and overlays. +hl.animation({ leaf = "layers", enabled = true, speed = 3.81, bezier = "easeOutQuint" }) +hl.animation({ leaf = "layersIn", enabled = true, speed = 3, bezier = "easeOutQuint", style = "fade" }) +hl.animation({ leaf = "layersOut", enabled = true, speed = 2, bezier = "almostLinear", style = "fade" }) + +-- Workspace switching: horizontal slide, matching Alt+H / Alt+L direction. +hl.animation({ leaf = "workspaces", enabled = true, speed = 2.5, bezier = "easeOutQuint", style = "slidefade 15%" }) + +hl.animation({ leaf = "zoomFactor", enabled = true, speed = 7, bezier = "quick" }) + +return true diff --git a/config/dot/hypr/monitors.lua b/config/dot/hypr/monitors.lua new file mode 100644 index 0000000..cf9b809 --- /dev/null +++ b/config/dot/hypr/monitors.lua @@ -0,0 +1,43 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Monitors +-- +-- Kuycon P20 on DP-2: 4500x3000 @ 60Hz, 1.5x fractional scale. +-- 4500/1.5 = 3000 and 3000/1.5 = 2000, both integers, so this is a "clean" +-- fractional scale and Hyprland will not complain. +-- +-- On HDR -------------------------------------------------------------------- +-- GNOME runs this panel in bt2100/HDR. Hyprland CAN do that (cm = "hdr"), but +-- on 0.56.x it currently breaks screencopy: grim screenshots come back empty, +-- and OBS/Sunshine capture and hyprlock's blurred background go with them. +-- Root cause is still open upstream. Since screen capture matters more here +-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to +-- fullscreen games only, via render.cm_auto_hdr in looks.lua. +-- +-- To try full-time HDR anyway, set cm = "hdr" below (or override it in +-- overrides.lua) and read the notes in that file first. +-- ───────────────────────────────────────────────────────────────────────────── + +hl.monitor({ + output = "DP-2", + mode = "4500x3000@60", + position = "0x0", + scale = 1.5, + + -- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of + -- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or + -- falls back to a lower mode, drop this line first. + bitdepth = 10, + + -- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header. + cm = "auto", +}) + +-- Any monitor not named above: sane defaults rather than nothing. +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = "auto", +}) + +return true diff --git a/config/dot/hypr/overrides.lua b/config/dot/hypr/overrides.lua new file mode 100644 index 0000000..54c6930 --- /dev/null +++ b/config/dot/hypr/overrides.lua @@ -0,0 +1,33 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Per-machine overrides +-- +-- Loaded last, so anything here beats the modules above. Keep machine-specific +-- settings in this file rather than editing the others -- it keeps the rest of +-- the config portable between boxes. +-- +-- Full-time HDR --------------------------------------------------------------- +-- The desktop ships SDR at 10-bit with HDR handed to fullscreen games (see +-- monitors.lua for why). To run the whole desktop in HDR, uncomment this: +-- +-- hl.monitor({ +-- output = "DP-2", +-- mode = "4500x3000@60", +-- position = "0x0", +-- scale = 1.5, +-- bitdepth = 10, +-- cm = "hdr", +-- sdrbrightness = 1.2, -- how bright SDR content appears, 1.0 - 2.0 +-- sdrsaturation = 0.98, +-- }) +-- +-- Then expect to also need some of these, in this order: +-- hl.config({ render = { keep_unmodified_copy = 1 } }) -- if captures go transparent +-- hl.config({ render = { use_shader_blur_blend = true } }) -- if that kills blur +-- hl.config({ render = { direct_scanout = 0 } }) -- if screenshots come back empty +-- hl.config({ cursor = { no_hardware_cursors = 1 } }) -- if the cursor looks oversaturated +-- +-- If screenshots, OBS or Sunshine break and stay broken, that is the known +-- upstream issue -- go back to cm = "auto". +-- ───────────────────────────────────────────────────────────────────────────── + +return true diff --git a/config/dot/hypr/rules.lua b/config/dot/hypr/rules.lua new file mode 100644 index 0000000..75e2cfe --- /dev/null +++ b/config/dot/hypr/rules.lua @@ -0,0 +1,207 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Window, workspace and layer rules +-- +-- Rules evaluate top to bottom, last match wins -- but every NAMED rule is +-- evaluated before every anonymous one, so a named rule can never override an +-- anonymous rule further down. Names are used sparingly here for that reason. +-- +-- Matching is Google RE2 (no backtracking). Prefix a pattern with "negative:" +-- to invert it. +-- ───────────────────────────────────────────────────────────────────────────── + +-- ── Upstream sanity rules ─────────────────────────────────────────────────── +hl.window_rule({ + name = "suppress-maximize-events", + match = { class = ".*" }, + suppress_event = "maximize", +}) + +hl.window_rule({ + name = "fix-xwayland-drags", + match = { + class = "^$", + title = "^$", + xwayland = true, + float = true, + fullscreen = false, + pin = false, + }, + no_focus = true, +}) + +-- ── Floating apps ─────────────────────────────────────────────────────────── +-- Carried over from the Forge windows.json override list, minus entries for +-- apps that are no longer installed. +hl.window_rule({ + match = { + class = "^(jetbrains-toolbox|zoom|Cider|Hidamari|com\\.mattjakeman\\.ExtensionManager)$", + }, + float = true, +}) + +hl.window_rule({ + match = { class = "^([Bb]itwarden|com\\.bitwarden\\.desktop)$" }, + float = true, +}) + +hl.window_rule({ + match = { class = "^(com\\.nextcloud\\.desktopclient\\.nextcloud|Nextcloud)$" }, + float = true, +}) + +hl.window_rule({ + match = { class = "^(org\\.gnome\\.Calculator|gnome-calculator)$" }, + float = true, + size = { 400, 600 }, + center = true, +}) + +hl.window_rule({ + match = { class = "^(mpv|io\\.mpv\\.Mpv)$" }, + float = true, + -- Video should never be blurred or dimmed behind another window. + no_blur = true, + no_dim = true, +}) + +hl.window_rule({ + match = { class = "^(com\\.spotify\\.Client|Spotify)$" }, + float = true, +}) + +-- Settings-style utility windows and pickers: float and centre, like GNOME did. +hl.window_rule({ + match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" }, + float = true, + size = { "monitor_w * 0.45", "monitor_h * 0.6" }, + center = true, +}) + +-- Portal dialogs (file chooser, screen share picker) should always float. +hl.window_rule({ + match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" }, + float = true, + center = true, +}) + +hl.window_rule({ + match = { title = "^(Open File|Save File|Save As|Open Folder|Select a File|Choose Files)$" }, + float = true, + center = true, +}) + +-- Polkit prompt. +hl.window_rule({ + match = { class = "^(hyprpolkitagent|polkit-gnome-authentication-agent-1)$" }, + float = true, + center = true, +}) + +-- ── Gaming ────────────────────────────────────────────────────────────────── +-- content = "game" is the keystone: misc.vrr = 3, render.direct_scanout = 2 and +-- cursor.no_break_fs_vrr = 2 all key off it. Blur, animation, shadow and dim are +-- all disabled so the compositor gets out of the way entirely. +hl.window_rule({ + match = { class = "^(steam_app_\\d+|gamescope|lutris|net\\.lutris\\.Lutris|com\\.heroicgameslauncher\\.hgl|Minecraft.*|moonlight|com\\.moonlight_stream\\.Moonlight)$" }, + content = "game", + immediate = true, + no_blur = true, + no_anim = true, + no_shadow = true, + no_dim = true, +}) + +-- Steam itself is a normal window, but its transient popups are a mess. +hl.window_rule({ + match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" }, + float = true, +}) + +-- Fullscreen video in a browser also benefits from no blur / no dim. +hl.window_rule({ + match = { fullscreen = true }, + no_blur = true, + no_dim = true, +}) + +-- ── Workspace rules ───────────────────────────────────────────────────────── +-- Deliberately NO "smart gaps". +-- +-- The usual trick is to strip gaps, border and rounding when a workspace holds +-- a single tiled window (`workspace = "w[tv1]"` + matching window rules). It +-- was here and it was wrong for this setup: the common case is one window on a +-- workspace, so the desktop spent most of its time square-cornered and +-- edge-to-edge -- the opposite of the intended look. Gaps and rounding are +-- constant now, however many windows are open. + +-- ── Layer rules (Quickshell surfaces) ─────────────────────────────────────── +-- Namespaces are set in QML via WlrLayershell.namespace; keep these regexes and +-- those strings in sync. `hyprctl layers` lists what is actually live. +-- +-- The bar intentionally has no rule: it is a transparent edge-to-edge input +-- surface, not glass. Its individual controls paint their own hover feedback. +-- Keeping a full-width blur rule here would spend compositor work on pixels +-- the bar does not draw. + +hl.layer_rule({ + name = "qs-dock", + match = { namespace = "^qs-dock$" }, + blur = true, + ignore_alpha = 0.3, +}) + +-- Popovers: calendar, quick settings, notification centre, tray menus. +hl.layer_rule({ + name = "qs-popover", + match = { namespace = "^qs-popover" }, + blur = true, + blur_popups = true, + ignore_alpha = 0.2, +}) + +-- Overview, capture and local screen-reading UI dim the desktop behind them. +hl.layer_rule({ + name = "qs-overlay", + match = { namespace = "^qs-(overview|capture|screen-intelligence)$" }, + blur = true, + ignore_alpha = 0.4, + dim_around = true, + no_screen_share = true, +}) + +-- Notification toasts. Blurred like every other shell surface -- without this +-- the cards are a near-transparent fill sitting directly on the wallpaper and +-- read as washed out rather than as glass. +-- +-- no_anim because the toasts animate themselves in QML, and no_screen_share so +-- they don't leak into OBS or Sunshine streams. +hl.layer_rule({ + name = "qs-notifications", + match = { namespace = "^qs-notifications$" }, + blur = true, + ignore_alpha = 0.15, + no_anim = true, + no_screen_share = true, +}) + +-- Signal Glass provides its own event-driven motion, so the compositor only +-- supplies the material blur and privacy behavior. +hl.layer_rule({ + name = "qs-signal-glass", + match = { namespace = "^qs-signal-glass$" }, + blur = true, + ignore_alpha = 0.15, + no_anim = true, + no_screen_share = true, +}) + +-- The launcher blurs and dims whatever is behind it. +hl.layer_rule({ + name = "launcher", + match = { namespace = "^(vicinae|wofi)$" }, + blur = true, + ignore_alpha = 0.5, + dim_around = true, +}) + +return true diff --git a/config/dot/quickshell/config/DesktopPreferences.qml b/config/dot/quickshell/config/DesktopPreferences.qml new file mode 100644 index 0000000..fdd6e50 --- /dev/null +++ b/config/dot/quickshell/config/DesktopPreferences.qml @@ -0,0 +1,120 @@ +pragma Singleton + +// User choices that Panama Settings may change at runtime. This is deliberately +// separate from Settings.qml: the latter remains the stable public surface used +// by the shell, while this object owns durable mutation and shipped defaults. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property alias use24Hour: values.use24Hour + property alias showSeconds: values.showSeconds + property alias showWeekday: values.showWeekday + property alias showCpu: values.showCpu + property alias showMemory: values.showMemory + property alias showGpu: values.showGpu + property alias dockAutohide: values.dockAutohide + property alias dockRevealDelayMs: values.dockRevealDelayMs + property alias dockHideDelayMs: values.dockHideDelayMs + property alias focusDurationMinutes: values.focusDurationMinutes + property alias autoHdr: values.autoHdr + property alias vrrPolicy: values.vrrPolicy + property alias directScanoutPolicy: values.directScanoutPolicy + property alias nightLightEnabled: values.nightLightEnabled + property alias nightLightAutomatic: values.nightLightAutomatic + property alias nightLightTemperature: values.nightLightTemperature + property alias lastPage: values.lastPage + + FileView { + id: preferencesFile + + path: Quickshell.stateDir + "/panama-settings.json" + blockLoading: true + printErrors: false + atomicWrites: true + + JsonAdapter { + id: values + + property bool use24Hour: false + property bool showSeconds: true + property bool showWeekday: true + property bool showCpu: true + property bool showMemory: true + property bool showGpu: true + property bool dockAutohide: true + property int dockRevealDelayMs: 0 + property int dockHideDelayMs: 250 + property int focusDurationMinutes: 45 + property bool autoHdr: true + property int vrrPolicy: 3 + property int directScanoutPolicy: 2 + property bool nightLightEnabled: false + property bool nightLightAutomatic: false + property int nightLightTemperature: 3500 + property string lastPage: "home" + } + } + + // Listen after the adapter has been constructed instead of writing from + // FileView.onAdapterUpdated. The latter also fires for default-property + // initialization, which can overwrite a valid file before it is loaded. + Connections { + target: values + function onUse24HourChanged(): void { persistTimer.restart(); } + function onShowSecondsChanged(): void { persistTimer.restart(); } + function onShowWeekdayChanged(): void { persistTimer.restart(); } + function onShowCpuChanged(): void { persistTimer.restart(); } + function onShowMemoryChanged(): void { persistTimer.restart(); } + function onShowGpuChanged(): void { persistTimer.restart(); } + function onDockAutohideChanged(): void { persistTimer.restart(); } + function onDockRevealDelayMsChanged(): void { persistTimer.restart(); } + function onDockHideDelayMsChanged(): void { persistTimer.restart(); } + function onFocusDurationMinutesChanged(): void { persistTimer.restart(); } + function onAutoHdrChanged(): void { persistTimer.restart(); } + function onVrrPolicyChanged(): void { persistTimer.restart(); } + function onDirectScanoutPolicyChanged(): void { persistTimer.restart(); } + function onNightLightEnabledChanged(): void { persistTimer.restart(); } + function onNightLightAutomaticChanged(): void { persistTimer.restart(); } + function onNightLightTemperatureChanged(): void { persistTimer.restart(); } + function onLastPageChanged(): void { persistTimer.restart(); } + } + + // Singleton construction can happen after FileView's preload phase when a + // test or a lazy page first references it. An explicit reload makes the + // durable state authoritative in that case as well as in the main shell. + Component.onCompleted: preferencesFile.reload() + + // Coalesce a group of UI changes into one atomic write. Writing from the + // adapter's change signal itself can race a second property assignment and + // reload the older value before the batch has finished. + Timer { + id: persistTimer + interval: 0 + onTriggered: preferencesFile.writeAdapter() + } + + function resetDesktopDefaults(): void { + values.use24Hour = false; + values.showSeconds = true; + values.showWeekday = true; + values.showCpu = true; + values.showMemory = true; + values.showGpu = true; + values.dockAutohide = true; + values.dockRevealDelayMs = 0; + values.dockHideDelayMs = 250; + values.focusDurationMinutes = 45; + values.autoHdr = true; + values.vrrPolicy = 3; + values.directScanoutPolicy = 2; + values.nightLightEnabled = false; + values.nightLightAutomatic = false; + values.nightLightTemperature = 3500; + values.lastPage = "home"; + } +} diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml new file mode 100644 index 0000000..94d5be5 --- /dev/null +++ b/config/dot/quickshell/config/Settings.qml @@ -0,0 +1,104 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// User-facing knobs. Everything here is something you might reasonably want to +// change without understanding the rest of the shell. +// +// Theme.qml holds *how it looks*; this holds *what it does*. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import QtQuick + +Singleton { + id: root + + // ── Clock ─────────────────────────────────────────────────────────────── + // Carried over from GNOME: 12-hour, weekday + date shown, seconds on. + readonly property bool use24Hour: DesktopPreferences.use24Hour + readonly property bool showSeconds: DesktopPreferences.showSeconds + readonly property bool showWeekday: DesktopPreferences.showWeekday + + // ── Weather ───────────────────────────────────────────────────────────── + // Coordinates taken from the GNOME night-light setting, which had already + // resolved the location. Uses Open-Meteo, which needs no API key. + readonly property real latitude: 27.7375 + readonly property real longitude: -82.6861 + // Open-Meteo returns coordinates but no friendly place name. Keep the + // label deliberately general rather than exposing precise coordinates in + // the UI or guessing at a city from them. + readonly property string weatherLocation: "Local weather" + readonly property string temperatureUnit: "fahrenheit" + readonly property int weatherRefreshMinutes: 20 + + // ── Vitals ────────────────────────────────────────────────────────────── + // The GNOME Vitals extension showed processor usage, memory usage and GPU + // usage, in that order. Same here. + readonly property int vitalsIntervalMs: 2000 + readonly property bool showCpu: DesktopPreferences.showCpu + readonly property bool showMemory: DesktopPreferences.showMemory + readonly property bool showGpu: DesktopPreferences.showGpu + + // amdgpu exposes utilisation here. Verified present on this machine; the + // widget hides itself if the path is missing rather than showing zeros. + readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent" + + // ── Night light ───────────────────────────────────────────────────────── + // Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00. + readonly property int nightLightTemperature: DesktopPreferences.nightLightTemperature + readonly property real nightLightFrom: 17.0 + readonly property real nightLightTo: 10.0 + readonly property bool nightLightEnabledByDefault: DesktopPreferences.nightLightEnabled + + // ── Notifications ─────────────────────────────────────────────────────── + readonly property int notificationTimeoutMs: 5000 + readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire + readonly property int notificationHistoryLimit: 100 + readonly property int maxVisibleToasts: 4 + + // ── Focus ────────────────────────────────────────────────────────────── + // One deliberate default rather than a preset picker: quick settings and + // the keyboard shortcut should start a useful session in one action. + readonly property int focusDurationMinutes: DesktopPreferences.focusDurationMinutes + + // ── Dock ──────────────────────────────────────────────────────────────── + // Pinned apps, in order, taken from the GNOME dash favourites. + readonly property list dockPinned: [ + "org.gnome.Settings", + "kitty", + "org.gnome.Nautilus", + "com.bitwarden.desktop", + "org.gnome.Software", + "helium", + "org.mozilla.thunderbird_esr", + "com.slack.Slack", + "app.bluebubbles.BlueBubbles", + "rustdesk", + "io.podman_desktop.PodmanDesktop", + "claude-desktop", + "codex-desktop", + "md.obsidian.Obsidian", + "com.obsproject.Studio", + "steam" + ] + + // Dash-to-Dock was set to intellihide against all windows: the dock hides + // when any window would overlap it, and comes back on hover. + readonly property bool dockAutohide: DesktopPreferences.dockAutohide + + // 0: reveal the instant the pointer reaches the bottom edge. A reveal delay + // is indistinguishable from lag, because the user has already committed to + // the gesture by the time the strip is hit. + readonly property int dockRevealDelayMs: DesktopPreferences.dockRevealDelayMs + + // Hiding keeps a delay, so brushing past the bottom edge or crossing the + // gap between two icons doesn't make the dock flicker. + readonly property int dockHideDelayMs: DesktopPreferences.dockHideDelayMs + + // ── Capture ───────────────────────────────────────────────────────────── + readonly property string screenshotDir: "Pictures/Screenshots" + readonly property string recordingDir: "Videos/Recordings" + // Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not + // cost CPU while gaming. + readonly property string recorderArgs: "-c h264_vaapi -d /dev/dri/renderD128" +} diff --git a/config/dot/quickshell/config/Theme.qml b/config/dot/quickshell/config/Theme.qml new file mode 100644 index 0000000..8307c2c --- /dev/null +++ b/config/dot/quickshell/config/Theme.qml @@ -0,0 +1,154 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Tokyo Night Moon — the single source of truth for every colour, radius, +// duration and font in the shell. Nothing below this file should hardcode a +// colour; if you find yourself writing "#" outside this file, add a token here. +// +// The bar keeps GNOME's 36px information density but sits directly on the +// desktop, edge to edge. The dock retains the former glass vocabulary: bottom, +// 48px icons and dot indicators. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +// QtQuick is required even though nothing visual is declared here: `color` is a +// QtQuick value type, and Qt.rgba() lives in its JS namespace. +import QtQuick + +Singleton { + id: root + + // ── Palette ───────────────────────────────────────────────────────────── + // Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly. + readonly property color bg: "#222436" + readonly property color bgDark: "#1e2030" + readonly property color bgHighlight: "#2f334d" + readonly property color bgPanel: "#2e2f3d" // dock glass surface + readonly property color bgPopover: "#21212f" // Openbar submenu background + + readonly property color fg: "#c8d3f5" + readonly property color fgDim: "#828bb8" + readonly property color fgMuted: "#636da6" + readonly property color gutter: "#3b4261" + + // The pair. `accent` is the primary and carries every state meaning + // (focused, active, on). `accentSecondary` is the orchid from the tmux + // theme — it never appears alone, only as the far end of a gradient. That + // restraint is the whole point: the two colours meeting is the signature, + // so the pink stops being special the moment it's used as a flat fill. + readonly property color accent: "#82aaff" // blue + readonly property color accentSecondary: "#b172b0" // orchid, from tmux + readonly property color accentAlt: "#65bcff" // blue1, a lighter blue + readonly property color cyan: "#86e1fc" + readonly property color teal: "#4fd6be" + readonly property color green: "#c3e88d" + readonly property color yellow: "#ffc777" + readonly property color orange: "#ff966c" + readonly property color red: "#ff757f" + readonly property color redDeep: "#c53b53" + readonly property color magenta: "#c099ff" + readonly property color pink: "#fca7ea" + + // Semantic aliases — prefer these in widgets so intent survives a repaint. + readonly property color ok: green + readonly property color warn: yellow + readonly property color danger: red + readonly property color urgent: orange + + // ── Surface alphas ────────────────────────────────────────────────────── + // The dock sits on compositor blur, so its fill is deliberately very + // translucent. Popovers are near-opaque because they carry text that must + // stay legible. The bar itself has no surface or alpha token. + readonly property real dockAlpha: 0.34 + readonly property real popoverAlpha: 0.92 + readonly property real overlayAlpha: 0.55 + readonly property real hoverAlpha: 0.14 + readonly property real activeAlpha: 0.24 + + // ── Geometry ──────────────────────────────────────────────────────────── + readonly property int barHeight: 36 + readonly property int barGap: 6 // breathing room below the bar for popovers + readonly property int barSideMargin: 10 // inset for floating popovers + + readonly property int dockIconSize: 48 + readonly property int dockPadding: 8 + readonly property int dockGap: 8 + readonly property int dockRadius: 20 + + readonly property int popoverRadius: 18 + readonly property int popoverPadding: 14 + readonly property int popoverWidth: 380 + + readonly property int cardRadius: 12 + readonly property int pillRadius: 999 + readonly property int itemSpacing: 8 + readonly property int sectionSpacing: 14 + + // ── Type ──────────────────────────────────────────────────────────────── + // Adwaita Sans for everything the user reads as text. No exceptions: a + // monospaced clock or percentage reads as a terminal readout pasted into a + // UI, which is the opposite of the intent here. + readonly property string fontFamily: "Adwaita Sans" + + // Nerd Font, used ONLY to draw icon glyphs — never for text. It is the + // pragmatic alternative to freedesktop symbolic icons, which ship with a + // hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml). + readonly property string fontMono: "VictorMono Nerd Font" + + // Apply to any text whose digits change in place — clocks, percentages, + // elapsed timers, dimension readouts. Tabular figures all share one + // advance width, so the text stops twitching as the numbers tick without + // needing a monospaced face. + // + // font.family: Theme.fontFamily + // font.features: Theme.tabularFigures + readonly property var tabularFigures: ({ + "tnum": 1 + }) + readonly property int fontSize: 13 + readonly property int fontSizeSmall: 11 + readonly property int fontSizeLarge: 16 + readonly property int fontSizeTitle: 20 + + // ── Motion ────────────────────────────────────────────────────────────── + // Event-driven only. Nothing in this shell animates while idle — no pulse, + // no shimmer, no spinners. These durations are used for open/close/hover. + readonly property int durFast: 120 + readonly property int durNormal: 200 + readonly property int durSlow: 320 + + // The dock revealing is the one animation that answers a live pointer + // movement, so it gets its own (much shorter) duration. Anything slower + // reads as the desktop lagging behind the cursor rather than as motion. + readonly property int durDockReveal: 90 + + // Matches the "snappy" spring curve defined in hypr/looks.lua. + readonly property list easeStandard: [0.05, 0.9, 0.1, 1.0] + readonly property list easeOvershoot: [0.34, 1.56, 0.64, 1.0] + + // ── Helpers ───────────────────────────────────────────────────────────── + // Qt.alpha() does not exist; this is the idiomatic way to re-alpha a token. + function alpha(c: color, a: real): color { + return Qt.rgba(c.r, c.g, c.b, a); + } + + // Blend two tokens — used for hover/pressed states so we never invent a + // colour that isn't derived from the palette. + function mix(a: color, b: color, ratio: real): color { + return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio); + } + + // ── The prism ─────────────────────────────────────────────────────────── + // Where the two accents meet. Used for the hairline along the top edge of + // every glass surface (widgets/PrismEdge.qml), the focused window border, + // the active workspace pill and slider fills. + // + // Where the gradient runs left-to-right the blue leads, because the eye + // reads left first and blue is the colour that carries meaning. + readonly property real prismStart: 0.22 + readonly property real prismEnd: 0.78 + + // Opacity of the hairline itself. Deliberately below 1: at full strength it + // reads as a drawn line rather than as light catching an edge. + readonly property real prismEdgeAlpha: 0.85 +} diff --git a/config/dot/quickshell/config/qmldir b/config/dot/quickshell/config/qmldir new file mode 100644 index 0000000..584b488 --- /dev/null +++ b/config/dot/quickshell/config/qmldir @@ -0,0 +1,4 @@ +module qs.config +singleton DesktopPreferences 1.0 DesktopPreferences.qml +singleton Settings 1.0 Settings.qml +singleton Theme 1.0 Theme.qml diff --git a/config/dot/quickshell/modules/bar/ActivityIndicator.qml b/config/dot/quickshell/modules/bar/ActivityIndicator.qml new file mode 100644 index 0000000..107ad3e --- /dev/null +++ b/config/dot/quickshell/modules/bar/ActivityIndicator.qml @@ -0,0 +1,42 @@ +// Persistent, bar-native privacy state. Signal Glass handles transitions; this +// remains until the underlying capture activity actually ends. + +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Pill { + id: root + + visible: PrivacyState.anyActive + horizontalPadding: 8 + onActivated: ShellState.toggle("activity") + + Text { + anchors.verticalCenter: parent.verticalCenter + text: { + if (PrivacyState.recordingActive) + return "\u{F044A}"; + if (PrivacyState.screenSharingActive) + return "\u{F0379}"; + if (PrivacyState.cameraActive) + return "\u{F0100}"; + return "\u{F036C}"; + } + color: PrivacyState.recordingActive ? Theme.danger : Theme.warn + font.family: Theme.fontMono + font.pixelSize: 14 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: PrivacyState.activeKinds.length > 1 + text: String(PrivacyState.activeKinds.length) + color: Theme.fg + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } +} diff --git a/config/dot/quickshell/modules/bar/ActivityPanel.qml b/config/dot/quickshell/modules/bar/ActivityPanel.qml new file mode 100644 index 0000000..7f877af --- /dev/null +++ b/config/dot/quickshell/modules/bar/ActivityPanel.qml @@ -0,0 +1,218 @@ +// Compact explanation and control surface for persistent privacy indicators. + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services +import qs.widgets + +PanelWindow { + id: root + + visible: ShellState.activityOpen && PrivacyState.anyActive + color: "transparent" + anchors.top: true + anchors.right: true + margins.top: Theme.barHeight + Theme.barGap * 2 + margins.right: Theme.barSideMargin + exclusiveZone: 0 + implicitWidth: 350 + implicitHeight: surface.implicitHeight + + WlrLayershell.namespace: "qs-popover-activity" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + readonly property var activities: { + const result = []; + if (PrivacyState.recordingActive) + result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true }); + if (PrivacyState.screenSharingActive) + result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false }); + if (PrivacyState.cameraActive) + result.push({ kind: "camera", glyph: "\u{F0100}", label: "Camera", detail: PrivacyState.cameraApp || "Managed by the application", tone: "warn", stoppable: false }); + if (PrivacyState.microphoneActive) + result.push({ kind: "microphone", glyph: "\u{F036C}", label: "Microphone", detail: PrivacyState.microphoneApp || "Managed by the application", tone: "warn", stoppable: false }); + return result; + } + + function elapsed(): string { + const total = Capture.recordingSeconds; + const seconds = String(total % 60).padStart(2, "0"); + const minutes = Math.floor(total / 60) % 60; + const hours = Math.floor(total / 3600); + return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`; + } + + onVisibleChanged: { + if (root.visible) + openAnimation.restart(); + } + + Connections { + target: PrivacyState + function onAnyActiveChanged(): void { + if (!PrivacyState.anyActive && ShellState.activityOpen) + ShellState.close(); + } + } + + HyprlandFocusGrab { + windows: [root] + active: root.visible + onCleared: ShellState.close() + } + + Rectangle { + id: surface + implicitHeight: content.implicitHeight + Theme.popoverPadding * 2 + anchors.left: parent.left + anchors.right: parent.right + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + transform: Scale { + id: openScale + origin.x: surface.width + origin.y: 0 + xScale: 1 + yScale: 1 + } + + Column { + id: content + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.popoverPadding + spacing: 8 + + Text { + width: parent.width + text: "Privacy & activity" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: "Active access stays visible until it ends." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.alpha(Theme.fg, 0.08) + } + + Repeater { + model: root.activities + + Rectangle { + id: activityRow + required property var modelData + width: content.width + height: 50 + radius: Theme.cardRadius + border.width: 0 + color: Theme.alpha(Theme.fg, 0.045) + + Text { + id: activityIcon + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: activityRow.modelData.glyph + color: activityRow.modelData.tone === "danger" ? Theme.danger : Theme.warn + font.family: Theme.fontMono + font.pixelSize: 17 + } + + Column { + anchors.left: activityIcon.right + anchors.leftMargin: 11 + anchors.right: stopButton.visible ? stopButton.left : parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: activityRow.modelData.label + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: activityRow.modelData.detail + color: Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Rectangle { + id: stopButton + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + visible: activityRow.modelData.stoppable + width: 54 + height: 28 + radius: Theme.pillRadius + border.width: 0 + color: stopMouse.containsMouse ? Theme.alpha(Theme.danger, 0.26) : Theme.alpha(Theme.danger, 0.15) + + Text { + anchors.centerIn: parent + text: "Stop" + color: Theme.danger + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + MouseArea { + id: stopMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Capture.stopRecording() + } + } + } + } + } + } + + NumberAnimation { + id: openAnimation + target: openScale + property: "yScale" + from: 0.96 + to: 1 + duration: Theme.durNormal + easing.type: Easing.OutCubic + } +} diff --git a/config/dot/quickshell/modules/bar/Bar.qml b/config/dot/quickshell/modules/bar/Bar.qml new file mode 100644 index 0000000..be9d040 --- /dev/null +++ b/config/dot/quickshell/modules/bar/Bar.qml @@ -0,0 +1,134 @@ +// The top bar: edge-to-edge system chrome with no surface of its own. Content +// floats directly over the desktop, while individual controls still provide +// hover and press feedback. The entire bar is a hit target so the top-left +// corner remains a dependable workspace-scroll gesture. +// +// Instantiate one per screen: +// +// Variants { +// model: Quickshell.screens +// Bar {} +// } +// +// Layout mirrors GNOME's: workspaces + vitals left, clock + weather centre, +// media + tray + status cluster right. + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.modules.clipboard +import qs.modules.focus + +PanelWindow { + id: root + + // Set by Variants. Left as a plain property (not `required`) so the bar can + // also be instantiated standalone on the default screen while testing. + property var modelData: null + + // Raised when the status cluster is clicked; the shell wires this to the + // quick settings panel, which this module does not own. + signal requestQuickSettings + + // Bound by the shell when an idle inhibitor is held, so the cluster can + // show the caffeine glyph. + property alias idleInhibited: statusCluster.idleInhibited + + screen: root.modelData + + anchors { + top: true + left: true + right: true + } + + // PanelWindow defaults to an opaque white background. + color: "transparent" + + implicitHeight: Theme.barHeight + + exclusiveZone: Theme.barHeight + + // Stable namespace for compositor inspection and shell diagnostics. + WlrLayershell.namespace: "qs-bar" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + // The full-width region is intentional: unlike a floating island, a menu + // bar owns the screen edge. In particular, Workspaces starts at x=0 and + // spans the full height so scrolling at the literal corner always works. + mask: Region { + item: barContent + } + + Item { + id: barContent + + anchors.fill: parent + + // ── Left ──────────────────────────────────────────────────────────── + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.itemSpacing + + Workspaces { + anchors.verticalCenter: parent.verticalCenter + screen: root.screen + } + + VitalsWidget { + anchors.verticalCenter: parent.verticalCenter + } + } + + // ── Centre ────────────────────────────────────────────────────────── + Row { + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Clock { + anchors.verticalCenter: parent.verticalCenter + } + + WeatherWidget { + anchors.verticalCenter: parent.verticalCenter + } + } + + // ── Right ─────────────────────────────────────────────────────────── + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.itemSpacing + + ActivityIndicator { + anchors.verticalCenter: parent.verticalCenter + } + + FocusIndicator { + anchors.verticalCenter: parent.verticalCenter + } + + MediaWidget { + anchors.verticalCenter: parent.verticalCenter + } + + ClipboardWidget { + anchors.verticalCenter: parent.verticalCenter + } + + Tray { + anchors.verticalCenter: parent.verticalCenter + } + + StatusCluster { + id: statusCluster + anchors.verticalCenter: parent.verticalCenter + onRequestQuickSettings: root.requestQuickSettings() + } + } + } +} diff --git a/config/dot/quickshell/modules/bar/CalendarArrow.qml b/config/dot/quickshell/modules/bar/CalendarArrow.qml new file mode 100644 index 0000000..743b6de --- /dev/null +++ b/config/dot/quickshell/modules/bar/CalendarArrow.qml @@ -0,0 +1,41 @@ +// Month-stepping arrow in CalendarPopup's header. Round hover target, sized +// like GNOME's calendar arrows rather than a bare glyph. + +import QtQuick +import qs.config + +Rectangle { + id: root + + signal activated + + required property string glyph + + width: 24 + height: 24 + radius: width / 2 + border.width: 0 + color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + Text { + anchors.centerIn: parent + text: root.glyph + font.family: Theme.fontMono + font.pixelSize: Theme.fontSize + color: Theme.fgDim + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + } +} diff --git a/config/dot/quickshell/modules/bar/CalendarPopup.qml b/config/dot/quickshell/modules/bar/CalendarPopup.qml new file mode 100644 index 0000000..c041a55 --- /dev/null +++ b/config/dot/quickshell/modules/bar/CalendarPopup.qml @@ -0,0 +1,222 @@ +// The calendar that drops out of the clock — GNOME's date menu, minus the +// notification list (that lives in its own panel). Month grid, today marked +// with the accent, arrows to page through months, current weather at the foot. + +import Quickshell +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Popover { + id: root + + // Popover's container is a plain Item, which does not derive an implicit + // size from its children, so the window has to be sized from the body. + implicitWidth: body.implicitWidth + contentPadding * 2 + implicitHeight: body.implicitHeight + contentPadding * 2 + + readonly property int cellSize: 34 + readonly property int cellHeight: 30 + + // Hours precision is enough: the only thing that has to change on its own + // is which cell counts as "today", and that only moves at midnight. + SystemClock { + id: clock + precision: SystemClock.Hours + } + + readonly property int todayYear: clock.date.getFullYear() + readonly property int todayMonth: clock.date.getMonth() + readonly property int todayDay: clock.date.getDate() + + // The month currently on screen. Reset to today every time the popover + // opens, so it never comes back showing wherever you paged off to. + property int viewYear: root.todayYear + property int viewMonth: root.todayMonth + + onVisibleChanged: if (visible) + root.showToday() + + function showToday(): void { + root.viewYear = root.todayYear; + root.viewMonth = root.todayMonth; + } + + function stepMonth(delta: int): void { + const d = new Date(root.viewYear, root.viewMonth + delta, 1); + root.viewYear = d.getFullYear(); + root.viewMonth = d.getMonth(); + } + + // Six weeks of cells, so the grid height never changes as you page through + // months. Days from the neighbouring months fill the edges, dimmed. + readonly property var cells: { + const first = new Date(root.viewYear, root.viewMonth, 1); + const offset = first.getDay(); // 0 = Sunday, matching the header row + const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate(); + const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate(); + + const out = []; + for (let i = 0; i < 42; i++) { + const n = i - offset + 1; + if (n < 1) + out.push({ + day: inPrevMonth + n, + current: false + }); + else if (n > inThisMonth) + out.push({ + day: n - inThisMonth, + current: false + }); + else + out.push({ + day: n, + current: true + }); + } + return out; + } + + Column { + id: body + spacing: Theme.itemSpacing + + // ── Month header ──────────────────────────────────────────────────── + Item { + width: root.cellSize * 7 + height: 28 + + CalendarArrow { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + glyph: "\u{F0141}" // md-chevron_left + onActivated: root.stepMonth(-1) + } + + Text { + anchors.centerIn: parent + text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy") + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + color: Theme.fg + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.showToday() + } + } + + CalendarArrow { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + glyph: "\u{F0142}" // md-chevron_right + onActivated: root.stepMonth(1) + } + } + + // ── Weekday header ────────────────────────────────────────────────── + Row { + Repeater { + model: ["S", "M", "T", "W", "T", "F", "S"] + + delegate: Text { + required property string modelData + + width: root.cellSize + height: 20 + text: modelData + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + color: Theme.fgMuted + } + } + } + + // ── Day grid ──────────────────────────────────────────────────────── + Grid { + columns: 7 + + Repeater { + model: root.cells + + delegate: Item { + id: cell + + required property var modelData + + readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear + + width: root.cellSize + height: root.cellHeight + + Rectangle { + anchors.centerIn: parent + width: root.cellHeight - 2 + height: root.cellHeight - 2 + radius: width / 2 + border.width: 0 + visible: cell.isToday + color: Theme.accent + } + + Text { + anchors.centerIn: parent + text: cell.modelData.day + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: cell.isToday ? Font.DemiBold : Font.Normal + + color: { + if (cell.isToday) + return Theme.bgDark; + return cell.modelData.current ? Theme.fg : Theme.fgMuted; + } + } + } + } + } + + // ── Weather ───────────────────────────────────────────────────────── + Rectangle { + width: root.cellSize * 7 + height: 1 + color: Theme.alpha(Theme.fg, 0.1) + visible: Weather.available + } + + Row { + spacing: Theme.itemSpacing + visible: Weather.available + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Weather.icon + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeTitle + color: Theme.accentAlt + } + + Column { + Text { + text: Math.round(Weather.temperature) + Weather.unitSuffix + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + color: Theme.fg + } + + Text { + text: Weather.description + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + color: Theme.fgDim + } + } + } + } +} diff --git a/config/dot/quickshell/modules/bar/Clock.qml b/config/dot/quickshell/modules/bar/Clock.qml new file mode 100644 index 0000000..488deeb --- /dev/null +++ b/config/dot/quickshell/modules/bar/Clock.qml @@ -0,0 +1,40 @@ +// The centre clock, kept in the same place and the same format GNOME had it: +// weekday, date, 12-hour time with seconds. Clicking it opens the calendar, +// exactly like GNOME's date menu. + +import Quickshell +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Pill { + id: root + + // Seconds are only ticked when they are actually shown — a per-second wake + // for a clock that displays minutes would be pure waste. + SystemClock { + id: clock + precision: Settings.showSeconds ? SystemClock.Seconds : SystemClock.Minutes + } + + readonly property string format: { + const date = Settings.showWeekday ? "ddd MMM d " : "MMM d "; + const time = Settings.use24Hour ? (Settings.showSeconds ? "HH:mm:ss" : "HH:mm") : (Settings.showSeconds ? "h:mm:ss AP" : "h:mm AP"); + return date + time; + } + + onActivated: ShellState.toggle("notifications") + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDateTime(clock.date, root.format) + font.family: Theme.fontFamily + // Seconds tick once a second in the centre of the bar; without tabular + // figures the whole clock shifts sideways on every digit change. + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + color: Theme.fg + } + +} diff --git a/config/dot/quickshell/modules/bar/MediaWidget.qml b/config/dot/quickshell/modules/bar/MediaWidget.qml new file mode 100644 index 0000000..e67e4aa --- /dev/null +++ b/config/dot/quickshell/modules/bar/MediaWidget.qml @@ -0,0 +1,69 @@ +// Now-playing readout. Click toggles play/pause, scroll skips tracks. +// +// "Nothing playing" means no MPRIS player is exposing a track at all — a +// paused player still shows, because otherwise there would be nothing left on +// the bar to press play on. + +import QtQuick +import Quickshell.Services.Mpris +import qs.config +import qs.widgets + +Pill { + id: root + + // Prefer whatever is actually playing; fall back to the first player that + // has a track loaded so a paused session stays reachable. The model's + // insertion behavior is covered by the live-player verification. + readonly property MprisPlayer player: { + const players = Mpris.players ? Mpris.players.values : []; + return players.find(p => p.isPlaying) ?? players.find(p => p.trackTitle) ?? null; + } + + readonly property string label: { + if (!root.player) + return ""; + const title = root.player.trackTitle ?? ""; + const artist = root.player.trackArtist ?? ""; + return artist ? artist + " — " + title : title; + } + + visible: root.player !== null + horizontalPadding: 8 + + onActivated: if (root.player?.canTogglePlaying) + root.player.togglePlaying() + + // Scroll up = previous, down = next — the same direction as the workspace + // switcher, so the whole bar scrolls consistently. + onScrolled: delta => { + if (!root.player) + return; + if (delta > 0 && root.player.canGoPrevious) + root.player.previous(); + else if (delta < 0 && root.player.canGoNext) + root.player.next(); + } + + Text { + anchors.verticalCenter: parent.verticalCenter + // Shows the action the click will perform, not the current state. + text: root.player?.isPlaying ? "\u{F04C}" : "\u{F04B}" // fa-pause / fa-play + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSmall + color: Theme.accent + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.label + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + color: Theme.fg + + // Titles are unbounded; the bar is not. Elide rather than let one + // podcast episode push the tray off the edge. + elide: Text.ElideRight + width: Math.min(implicitWidth, 200) + } +} diff --git a/config/dot/quickshell/modules/bar/StatusCluster.qml b/config/dot/quickshell/modules/bar/StatusCluster.qml new file mode 100644 index 0000000..4befa21 --- /dev/null +++ b/config/dot/quickshell/modules/bar/StatusCluster.qml @@ -0,0 +1,109 @@ +// GNOME's system menu button: a small run of status glyphs — network, volume, +// bluetooth, caffeine — that opens the quick settings panel when clicked. +// +// This widget only *reports* state. The quick settings panel itself lives +// elsewhere; clicking here just raises `requestQuickSettings`. + +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Networking +import Quickshell.Services.Pipewire +import QtQuick +import qs.config +import qs.widgets + +Pill { + id: root + + signal requestQuickSettings + + // Set by the shell when an idle inhibitor is held. Lives outside this + // widget because the inhibitor itself is owned by the quick settings side. + property bool idleInhibited: false + + onActivated: root.requestQuickSettings() + + // ── Audio ─────────────────────────────────────────────────────────────── + // Without a tracker, volume and muted silently read as zero/false. + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + // defaultAudioSink is transiently null while pipewire settles. + readonly property real volume: Pipewire.defaultAudioSink?.audio?.volume ?? 0 + readonly property bool muted: Pipewire.defaultAudioSink?.audio?.muted ?? false + + // ── Network ───────────────────────────────────────────────────────────── + readonly property var devices: Networking.devices ? Networking.devices.values : [] + + readonly property var wiredDevice: root.devices.find(d => d.type === DeviceType.Wired && d.connected) ?? null + readonly property var wifiDevice: root.devices.find(d => d.type === DeviceType.Wifi) ?? null + + readonly property var wifiNetwork: { + const networks = root.wifiDevice?.networks ? root.wifiDevice.networks.values : []; + return networks.find(n => n.connected) ?? null; + } + + // ── Bluetooth ─────────────────────────────────────────────────────────── + readonly property bool bluetoothOn: Bluetooth.defaultAdapter?.enabled ?? false + readonly property bool bluetoothConnected: { + const devices = Bluetooth.devices ? Bluetooth.devices.values : []; + return devices.some(d => d.connected); + } + + StatusGlyph { + glyph: { + if (root.wiredDevice) + return "\u{F0200}"; // md-ethernet + if (!root.wifiNetwork) + return "\u{F092D}"; // md-wifi_strength_off + + // NetworkManager reports strength 0-100; some backends normalise to + // 0-1, so accept either rather than showing one bar forever. + const raw = root.wifiNetwork.signalStrength ?? 0; + const strength = raw <= 1 ? raw * 100 : raw; + if (strength >= 75) + return "\u{F0928}"; // md-wifi_strength_4 + if (strength >= 50) + return "\u{F0925}"; // md-wifi_strength_3 + if (strength >= 25) + return "\u{F0922}"; // md-wifi_strength_2 + return "\u{F091F}"; // md-wifi_strength_1 + } + + color: { + if (root.wiredDevice) + return Theme.fg; + if (!root.wifiNetwork) + return Theme.fgMuted; + return Networking.connectivity === NetworkConnectivity.Full ? Theme.fg : Theme.warn; + } + } + + StatusGlyph { + glyph: { + if (root.muted || root.volume <= 0) + return "\u{F075F}"; // md-volume_mute + if (root.volume < 0.34) + return "\u{F057F}"; // md-volume_low + if (root.volume < 0.67) + return "\u{F0580}"; // md-volume_medium + return "\u{F057E}"; // md-volume_high + } + color: root.muted ? Theme.fgMuted : Theme.fg + } + + StatusGlyph { + // Bluetooth off is the resting state on this machine, so the glyph is + // simply absent rather than shown crossed out. + visible: root.bluetoothOn + glyph: root.bluetoothConnected ? "\u{F00B1}" : "\u{F00AF}" // md-bluetooth_connect / md-bluetooth + color: root.bluetoothConnected ? Theme.accent : Theme.fg + } + + StatusGlyph { + visible: root.idleInhibited + glyph: "\u{F0176}" // md-coffee + color: Theme.warn + } +} diff --git a/config/dot/quickshell/modules/bar/StatusGlyph.qml b/config/dot/quickshell/modules/bar/StatusGlyph.qml new file mode 100644 index 0000000..a9d9961 --- /dev/null +++ b/config/dot/quickshell/modules/bar/StatusGlyph.qml @@ -0,0 +1,26 @@ +// A single status icon in StatusCluster. Fixed width so the cluster doesn't +// shuffle sideways every time a glyph swaps for a wider one. + +import QtQuick +import qs.config + +Text { + id: root + + required property string glyph + + anchors.verticalCenter: parent?.verticalCenter ?? undefined + + text: root.glyph + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeLarge + horizontalAlignment: Text.AlignHCenter + width: 18 + color: Theme.fg + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } +} diff --git a/config/dot/quickshell/modules/bar/Tray.qml b/config/dot/quickshell/modules/bar/Tray.qml new file mode 100644 index 0000000..6fee7e1 --- /dev/null +++ b/config/dot/quickshell/modules/bar/Tray.qml @@ -0,0 +1,84 @@ +// System tray (StatusNotifierItem / AppIndicator). +// +// GNOME removed this and never replaced it; Nextcloud, RustDesk and Slack all +// still expect it, so it is a first-class part of this bar. Left click +// activates, right click opens the item's own menu — rendered in QML by +// TrayMenu so it matches the rest of the shell instead of dropping a GTK menu +// on top of it. + +import Quickshell +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import QtQuick +import qs.config + +Row { + id: root + + spacing: 2 + + Repeater { + model: SystemTray.items + + delegate: Item { + id: entry + + required property SystemTrayItem modelData + + implicitWidth: 26 + implicitHeight: Theme.barHeight - 8 + + Rectangle { + anchors.fill: parent + radius: Theme.pillRadius + border.width: 0 + color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + } + + IconImage { + anchors.centerIn: parent + implicitSize: 16 + asynchronous: true + mipmap: true + // `icon` is already a resolved URL — running it through + // Quickshell.iconPath() would break it. + source: entry.modelData.icon + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + + onClicked: event => { + // onlyMenu items have no activate action at all, so a left + // click has to fall through to the menu or nothing happens. + if (event.button === Qt.RightButton || entry.modelData.onlyMenu) { + if (entry.modelData.hasMenu) + menu.visible = !menu.visible; + } else if (event.button === Qt.MiddleButton) { + entry.modelData.secondaryActivate(); + } else { + entry.modelData.activate(); + } + } + + onWheel: event => entry.modelData.scroll(event.angleDelta.y, false) + } + + TrayMenu { + id: menu + anchorItem: entry + trayItem: entry.modelData + } + } + } +} diff --git a/config/dot/quickshell/modules/bar/TrayMenu.qml b/config/dot/quickshell/modules/bar/TrayMenu.qml new file mode 100644 index 0000000..3b1c7d7 --- /dev/null +++ b/config/dot/quickshell/modules/bar/TrayMenu.qml @@ -0,0 +1,101 @@ +// A tray item's DBus menu, drawn natively so it inherits the shell's theme. +// +// Submenus drill down in place rather than flying out sideways — a popover with +// a back row, the way GNOME's menus behave, and far less fiddly than chasing a +// cascade of popup windows with the mouse. + +import Quickshell +import Quickshell.Services.SystemTray +import QtQuick +import qs.config +import qs.widgets + +Popover { + id: root + + required property SystemTrayItem trayItem + + // Popover's container is a plain Item and does not size itself from its + // children, so the window dimensions come from the column's implicit size. + // Rows take their *actual* width from the window in the other direction — + // the two chains are independent, so there is no binding loop. + implicitWidth: Math.max(body.implicitWidth + contentPadding * 2, 200) + implicitHeight: body.implicitHeight + contentPadding * 2 + + // Stack of submenu handles; empty means we are at the top level. + property var menuStack: [] + + onVisibleChanged: if (!visible) + root.menuStack = [] + + QsMenuOpener { + id: opener + menu: root.menuStack.length > 0 ? root.menuStack[root.menuStack.length - 1] : root.trayItem.menu + } + + Column { + id: body + width: parent.width + spacing: 2 + + // Back row, only while inside a submenu. + TrayMenuRow { + width: parent.width + visible: root.menuStack.length > 0 + label: "\u{F0141} Back" // md-chevron_left + onActivated: root.menuStack = root.menuStack.slice(0, -1) + } + + Repeater { + model: opener.children + + delegate: Loader { + id: entryLoader + + required property var modelData + + width: body.width + sourceComponent: entryLoader.modelData.isSeparator ? separator : row + + Component { + id: separator + + Item { + implicitHeight: 7 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 1 + color: Theme.alpha(Theme.fg, 0.1) + } + } + } + + Component { + id: row + + TrayMenuRow { + // QsMenuEntry calls it `text`, not `label`. + label: entryLoader.modelData.text + icon: entryLoader.modelData.icon + rowEnabled: entryLoader.modelData.enabled + buttonType: entryLoader.modelData.buttonType + checkState: entryLoader.modelData.checkState + submenu: entryLoader.modelData.hasChildren + + onActivated: { + if (entryLoader.modelData.hasChildren) { + root.menuStack = root.menuStack.concat([entryLoader.modelData]); + return; + } + + entryLoader.modelData.triggered(); + root.visible = false; + } + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/bar/TrayMenuRow.qml b/config/dot/quickshell/modules/bar/TrayMenuRow.qml new file mode 100644 index 0000000..af3cd49 --- /dev/null +++ b/config/dot/quickshell/modules/bar/TrayMenuRow.qml @@ -0,0 +1,85 @@ +// One row of a TrayMenu: optional icon, label, and either a check/radio mark +// or a submenu chevron on the right. + +import Quickshell.Widgets +import QtQuick +import qs.config + +Rectangle { + id: root + + signal activated + + property string label: "" + property string icon: "" + // Named `rowEnabled` rather than `enabled`: overriding Item.enabled would + // also disable the MouseArea, and we still want the row to swallow clicks + // rather than let them fall through to whatever is behind the popover. + property bool rowEnabled: true + property int buttonType: 0 // QsMenuButtonType: 0 None, 1 CheckBox, 2 RadioButton + property int checkState: Qt.Unchecked + property bool submenu: false + + implicitWidth: rowContent.implicitWidth + 20 + implicitHeight: 30 + + radius: Theme.cardRadius + border.width: 0 + color: mouse.containsMouse && root.rowEnabled ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + Row { + id: rowContent + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + IconImage { + anchors.verticalCenter: parent.verticalCenter + implicitSize: 16 + asynchronous: true + visible: root.icon !== "" + source: root.icon + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.label + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + color: root.rowEnabled ? Theme.fg : Theme.fgMuted + } + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + font.family: Theme.fontMono + font.pixelSize: Theme.fontSize + color: Theme.accent + + text: { + if (root.submenu) + return "\u{F0142}"; // md-chevron_right + if (root.buttonType !== 0 && root.checkState === Qt.Checked) + return "\u{F012C}"; // md-check + return ""; + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: root.rowEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (root.rowEnabled) + root.activated() + } +} diff --git a/config/dot/quickshell/modules/bar/VitalsField.qml b/config/dot/quickshell/modules/bar/VitalsField.qml new file mode 100644 index 0000000..5deb542 --- /dev/null +++ b/config/dot/quickshell/modules/bar/VitalsField.qml @@ -0,0 +1,53 @@ +// One glyph + percentage pair inside VitalsWidget. Split out so the three +// fields cannot drift apart in spacing, width or colour thresholds. + +import QtQuick +import qs.config + +Row { + id: root + + required property string glyph + required property real value + + property int warnAt: 70 + property int dangerAt: 90 + + spacing: 4 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.glyph + font.family: Theme.fontMono + font.pixelSize: Theme.fontSize + color: Theme.fgDim + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Math.round(root.value) + "%" + + // Tabular figures and a fixed width so the row doesn't jitter as digits + // change — a moving bar is far more distracting than a wide one. Sans, + // not mono: this is UI text, not terminal output. + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + horizontalAlignment: Text.AlignRight + width: 30 + + color: { + if (root.value >= root.dangerAt) + return Theme.danger; + if (root.value >= root.warnAt) + return Theme.warn; + return Theme.fg; + } + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + } +} diff --git a/config/dot/quickshell/modules/bar/VitalsWidget.qml b/config/dot/quickshell/modules/bar/VitalsWidget.qml new file mode 100644 index 0000000..05ccfcb --- /dev/null +++ b/config/dot/quickshell/modules/bar/VitalsWidget.qml @@ -0,0 +1,43 @@ +// CPU / RAM / GPU readout, in that order — the exact set and order the GNOME +// Vitals extension was configured with (_processor_usage_, _memory_usage_, +// _gpu#1_usage_). +// +// Values are colour-coded rather than graphed: at bar size a number you can +// glance at beats a sparkline you have to squint at. + +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Pill { + id: root + + interactive: false + + Row { + spacing: Theme.itemSpacing + + VitalsField { + glyph: "\u{F4BC}" // oct-cpu + value: Vitals.cpu + visible: Settings.showCpu + } + + VitalsField { + glyph: "\u{F035B}" // md-memory + value: Vitals.memory + visible: Settings.showMemory + // Memory sitting high is normal (page cache, browsers); only start + // complaining much later than for CPU. + warnAt: 80 + dangerAt: 92 + } + + VitalsField { + glyph: "\u{F08AE}" // md-expansion_card + value: Vitals.gpu + visible: Settings.showGpu && Vitals.gpuAvailable + } + } +} diff --git a/config/dot/quickshell/modules/bar/WeatherWidget.qml b/config/dot/quickshell/modules/bar/WeatherWidget.qml new file mode 100644 index 0000000..1c0a874 --- /dev/null +++ b/config/dot/quickshell/modules/bar/WeatherWidget.qml @@ -0,0 +1,31 @@ +// Temperature and condition glyph, sitting next to the clock the way GNOME's +// weather sat inside the date menu button. Hidden entirely until a fetch has +// succeeded — an empty slot reads better than a placeholder. + +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Pill { + id: root + + interactive: false + visible: Weather.available + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Weather.icon + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeLarge + color: Theme.accentAlt + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Math.round(Weather.temperature) + Weather.unitSuffix + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + color: Theme.fg + } +} diff --git a/config/dot/quickshell/modules/bar/Workspaces.qml b/config/dot/quickshell/modules/bar/Workspaces.qml new file mode 100644 index 0000000..1105b2c --- /dev/null +++ b/config/dot/quickshell/modules/bar/Workspaces.qml @@ -0,0 +1,161 @@ +// Workspace indicator, in the shape GNOME's workspace switcher had: a row of +// dots, the current one drawn as a wide accent pill. Hyprland workspaces are +// dynamic here, so the row grows and shrinks as workspaces come and go. +// +// Scrolling anywhere over the row switches workspace — a habit carried over +// from the GNOME top panel and explicitly worth keeping. + +import Quickshell +import Quickshell.Hyprland +import QtQuick +import qs.config + +Item { + id: root + + // The QsScreen this bar instance lives on. When set, only that monitor's + // workspaces are shown — otherwise a second monitor would duplicate them. + property var screen: null + + readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null + + // Special workspaces (negative ids) are scratchpads, not places you switch + // to by index — GNOME had no equivalent, so they stay out of the row. + readonly property var workspaces: { + const all = Hyprland.workspaces ? Hyprland.workspaces.values : []; + return all.filter(ws => ws && ws.id > 0 && (!root.monitor || !ws.monitor || ws.monitor === root.monitor)).sort((a, b) => a.id - b.id); + } + + // The delegate currently focused. The accent pill tracks its geometry, which + // is what makes the pill appear to slide from one workspace to the next. + property Item focusedItem: null + + readonly property int dotSize: 8 + readonly property int slotWidth: 16 + readonly property int focusedSlotWidth: 28 + + implicitWidth: row.implicitWidth + 12 + // Full bar height makes (0, 0) part of the wheel target. The dots stay + // optically centred by the row and active-pill geometry below. + implicitHeight: Theme.barHeight + + // Scroll over the padding either side of the row, not just over a dot. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: event => root.cycle(event.angleDelta.y) + } + + Rectangle { + id: activePill + + // Behind the dots: the focused delegate hides its own dot, so nothing + // is ever drawn on top of the pill. + z: -1 + + visible: root.focusedItem !== null + height: root.dotSize + radius: Theme.pillRadius + border.width: 0 // QTBUG-137166: rounded rects can lose their corners + + // The prism, as a fill rather than a hairline. This is the one element + // in the bar that is always coloured, so it is where the pair belongs. + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + position: 0.0 + color: Theme.accent + } + GradientStop { + position: 1.0 + color: Theme.accentSecondary + } + } + + y: (root.height - height) / 2 + x: row.x + (root.focusedItem ? root.focusedItem.x : 0) + width: root.focusedItem ? root.focusedItem.width : 0 + + Behavior on x { + NumberAnimation { + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + } + Behavior on width { + NumberAnimation { + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + } + } + + Row { + id: row + anchors.centerIn: parent + spacing: 4 + + Repeater { + model: root.workspaces + + delegate: Item { + id: slot + + required property var modelData + + readonly property bool isFocused: slot.modelData.focused + readonly property bool isOccupied: slot.modelData.toplevels ? slot.modelData.toplevels.values.length > 0 : false + + implicitWidth: slot.isFocused ? root.focusedSlotWidth : root.slotWidth + implicitHeight: root.height + + Behavior on implicitWidth { + NumberAnimation { + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + } + + onIsFocusedChanged: if (slot.isFocused) + root.focusedItem = slot + Component.onCompleted: if (slot.isFocused) + root.focusedItem = slot + + Rectangle { + anchors.centerIn: parent + width: root.dotSize + height: root.dotSize + radius: width / 2 + border.width: 0 + visible: !slot.isFocused + + color: { + if (slot.modelData.urgent) + return Theme.urgent; + return slot.isOccupied ? Theme.fg : Theme.alpha(Theme.fg, 0.3); + } + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: slot.modelData.activate() + onWheel: event => root.cycle(event.angleDelta.y) + } + } + } + } + + // Scroll up goes to the previous workspace, down to the next — the same + // direction GNOME used. Relative focus rather than by index so it keeps + // working with dynamic workspaces. + function cycle(angleDeltaY: int): void { + Hyprland.dispatch(angleDeltaY > 0 ? 'hl.dsp.focus({ workspace = "-1" })' : 'hl.dsp.focus({ workspace = "+1" })'); + } +} diff --git a/config/dot/quickshell/modules/capture/CaptureBar.qml b/config/dot/quickshell/modules/capture/CaptureBar.qml new file mode 100644 index 0000000..221c7f8 --- /dev/null +++ b/config/dot/quickshell/modules/capture/CaptureBar.qml @@ -0,0 +1,225 @@ +// The floating control panel at the bottom of the capture overlay. +// +// Layout is a deliberate copy of GNOME 42's screenshot UI: the area segmented +// control on top, and below it the shutter dead-centre with "Show Pointer" on +// the left and the screenshot/screencast switch on the right. + +import QtQuick +import qs.config +import qs.services + +Rectangle { + id: root + + signal captureRequested + + // Set by the overlay so the shutter can grey out when Window mode has + // nothing pickable (no Hyprland, or an empty workspace). + property bool captureEnabled: true + + implicitWidth: 760 + implicitHeight: col.implicitHeight + 2 * 20 + radius: 28 + border.width: 0 + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + + Column { + id: col + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: 20 + } + spacing: 18 + + // ── Area mode ─────────────────────────────────────────────────────── + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + implicitWidth: modes.implicitWidth + 8 + implicitHeight: modes.implicitHeight + 8 + radius: Theme.pillRadius + border.width: 0 + color: Theme.alpha(Theme.fg, 0.07) + + Row { + id: modes + anchors.centerIn: parent + spacing: 2 + + ModeButton { + glyph: "󰍹" + label: "Screen" + active: Capture.mode === "screen" + onClicked: Capture.mode = "screen" + } + ModeButton { + glyph: "󰖯" + label: "Window" + active: Capture.mode === "window" + onClicked: Capture.mode = "window" + } + ModeButton { + glyph: "󰩭" + label: "Selection" + active: Capture.mode === "selection" + onClicked: Capture.mode = "selection" + } + } + } + + // ── Pointer toggle · shutter · capture type ───────────────────────── + Item { + width: col.width + height: 68 + + // "Show Pointer". wf-recorder has no cursor switch in this build, + // so it is disabled while the screencast type is selected instead + // of silently lying about what it does. + Rectangle { + id: pointerToggle + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + } + implicitWidth: pointerRow.implicitWidth + 26 + implicitHeight: 40 + radius: Theme.pillRadius + border.width: 0 + opacity: Capture.recordMode || Capture.intelligenceMode ? 0.4 : 1.0 + color: pointerMouse.containsMouse && !Capture.recordMode && !Capture.intelligenceMode ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.07) + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + Row { + id: pointerRow + anchors.centerIn: parent + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Capture.showPointer ? "󰄬" : "󰄱" + color: Capture.showPointer ? Theme.accent : Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeLarge + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Show Pointer" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + MouseArea { + id: pointerMouse + anchors.fill: parent + hoverEnabled: true + enabled: !Capture.recordMode && !Capture.intelligenceMode + cursorShape: Qt.PointingHandCursor + onClicked: Capture.showPointer = !Capture.showPointer + } + } + + // ── Shutter ───────────────────────────────────────────────────── + Rectangle { + id: shutter + anchors.centerIn: parent + implicitWidth: 64 + implicitHeight: 64 + radius: width / 2 + border.width: 0 + opacity: root.captureEnabled ? 1.0 : 0.45 + color: shutterMouse.containsMouse && root.captureEnabled ? Theme.alpha(Theme.fg, 0.3) : Theme.alpha(Theme.fg, 0.18) + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + Rectangle { + anchors.centerIn: parent + width: shutterMouse.pressed ? 40 : 46 + height: width + radius: width / 2 + border.width: 0 + color: Capture.recordMode ? Theme.danger : (Capture.intelligenceMode ? Theme.accent : Theme.fg) + + Behavior on width { + NumberAnimation { + duration: Theme.durFast + } + } + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + Text { + anchors.centerIn: parent + visible: Capture.intelligenceMode + text: "󰗊" + color: Theme.bgDark + font.family: Theme.fontMono + font.pixelSize: 21 + } + } + + MouseArea { + id: shutterMouse + anchors.fill: parent + hoverEnabled: true + enabled: root.captureEnabled + cursorShape: Qt.PointingHandCursor + onClicked: root.captureRequested() + } + } + + // ── Screenshot / screencast ───────────────────────────────────── + Rectangle { + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + implicitWidth: types.implicitWidth + 8 + implicitHeight: types.implicitHeight + 8 + radius: Theme.pillRadius + border.width: 0 + color: Theme.alpha(Theme.fg, 0.07) + + Row { + id: types + anchors.centerIn: parent + spacing: 2 + + ModeButton { + glyph: "󰄀" + label: "Shot" + active: !Capture.recordMode && !Capture.intelligenceMode + onClicked: Capture.selectScreenshot() + } + ModeButton { + glyph: "󰕧" + label: "Record" + active: Capture.recordMode + onClicked: Capture.selectRecording() + } + ModeButton { + glyph: "󰗊" + label: "Read" + active: Capture.intelligenceMode + onClicked: Capture.selectIntelligence() + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/capture/CaptureOverlay.qml b/config/dot/quickshell/modules/capture/CaptureOverlay.qml new file mode 100644 index 0000000..6ced8cb --- /dev/null +++ b/config/dot/quickshell/modules/capture/CaptureOverlay.qml @@ -0,0 +1,178 @@ +// The GNOME 42 screenshot UI. +// +// Print freezes the screen, this overlay paints that frozen frame full-bleed +// and puts the picker on top of it. Because the frame is a still, nothing under +// the overlay animates while the user aims, and the overlay can never end up in +// its own screenshot. +// +// The grab happens in services/Capture.qml *before* ShellState flips to +// "capture", so by the time this window maps the backdrop is already on disk. + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services + +PanelWindow { + id: win + + visible: ShellState.captureOpen + color: "transparent" + + anchors { + top: true + bottom: true + left: true + right: true + } + + // Fullscreen overlays must not reserve space, or every window on the + // workspace gets resized as the picker opens and closes. + exclusiveZone: 0 + + WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + onVisibleChanged: { + if (!win.visible) + return; + // Hyprland reports window geometry in layout-global coordinates; the + // picker works in window-local ones. On a single monitor this is 0,0. + Capture.originX = win.screen ? win.screen.x : 0; + Capture.originY = win.screen ? win.screen.y : 0; + keys.forceActiveFocus(); + } + + // Fire whatever the current mode has selected. + function capture(): void { + if (Capture.mode === "screen") { + Capture.commit(null); + return; + } + if (Capture.mode === "window") { + const r = picker.selectedRect; + if (r) + Capture.commit(r); + return; + } + if (!selection.hasSelection) + return; + const s = selection.selection; + Capture.commit(Qt.rect(s.x + Capture.originX, s.y + Capture.originY, s.width, s.height)); + } + + function cycleMode(step: int): void { + const order = ["screen", "window", "selection"]; + const i = order.indexOf(Capture.mode); + Capture.mode = order[(i + step + order.length) % order.length]; + } + + FocusScope { + id: keys + anchors.fill: parent + focus: true + + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Escape: + Capture.close(); + break; + case Qt.Key_Return: + case Qt.Key_Enter: + case Qt.Key_Space: + win.capture(); + break; + case Qt.Key_Left: + win.cycleMode(-1); + break; + case Qt.Key_Right: + win.cycleMode(1); + break; + case Qt.Key_1: + Capture.mode = "screen"; + break; + case Qt.Key_2: + Capture.mode = "window"; + break; + case Qt.Key_3: + Capture.mode = "selection"; + break; + case Qt.Key_P: + Capture.showPointer = !Capture.showPointer; + break; + default: + return; + } + event.accepted = true; + } + + // Painted under the frozen frame so a failed grab (no wlr-screencopy) + // still gives the picker something opaque to sit on. + Rectangle { + anchors.fill: parent + color: Theme.bgDark + border.width: 0 + } + + // The frozen screen. grim -o pins the grab to this output, so filling + // the window is an exact 1:1 mapping; the source is 4500x3000 device + // pixels drawn into 3000x2000 logical ones, i.e. full sharpness. + Image { + anchors.fill: parent + source: Capture.freezeUrl + fillMode: Image.Stretch + // Unique filename per grab plus cache:false — otherwise QML would + // happily repaint a previous session's frame. + cache: false + // Synchronous so the overlay never flashes black on the way in; a + // raw PPM decode is a parse, not a decompress. + asynchronous: false + visible: status === Image.Ready + } + + // ── Screen mode ───────────────────────────────────────────────────── + // Nothing to pick, so just outline the output the way GNOME does. + Frame { + anchors.fill: parent + visible: Capture.mode === "screen" + strokeWidth: 3 + strokeColor: Theme.accent + } + + // ── Window mode ───────────────────────────────────────────────────── + WindowPicker { + id: picker + anchors.fill: parent + visible: Capture.mode === "window" + originX: Capture.originX + originY: Capture.originY + onPicked: win.capture() + } + + // ── Selection mode ────────────────────────────────────────────────── + SelectionLayer { + id: selection + anchors.fill: parent + visible: Capture.mode === "selection" + onCommitted: win.capture() + } + + CaptureBar { + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 56 + } + captureEnabled: { + if (Capture.mode === "window") + return picker.selectedRect !== null; + if (Capture.mode === "selection") + return selection.hasSelection; + return true; + } + onCaptureRequested: win.capture() + } + } +} diff --git a/config/dot/quickshell/modules/capture/Frame.qml b/config/dot/quickshell/modules/capture/Frame.qml new file mode 100644 index 0000000..20868af --- /dev/null +++ b/config/dot/quickshell/modules/capture/Frame.qml @@ -0,0 +1,49 @@ +// An outline drawn as four opaque strips rather than a Rectangle border. +// +// A rounded Rectangle with a translucent fill and a border renders holes at the +// corners on this Qt build (QTBUG-137166), and every outline in the capture +// overlay sits on top of a photo where that would be very visible. Four plain +// strips have no corners to get wrong. + +import QtQuick +import qs.config + +Item { + id: root + + property color strokeColor: Theme.accent + property int strokeWidth: 2 + // Optional wash over the enclosed area, drawn under the strips. + property color fillColor: "transparent" + + Rectangle { + anchors.fill: parent + color: root.fillColor + border.width: 0 + } + + Rectangle { + anchors { left: parent.left; right: parent.right; top: parent.top } + height: root.strokeWidth + color: root.strokeColor + border.width: 0 + } + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: root.strokeWidth + color: root.strokeColor + border.width: 0 + } + Rectangle { + anchors { top: parent.top; bottom: parent.bottom; left: parent.left } + width: root.strokeWidth + color: root.strokeColor + border.width: 0 + } + Rectangle { + anchors { top: parent.top; bottom: parent.bottom; right: parent.right } + width: root.strokeWidth + color: root.strokeColor + border.width: 0 + } +} diff --git a/config/dot/quickshell/modules/capture/IntelligenceResult.qml b/config/dot/quickshell/modules/capture/IntelligenceResult.qml new file mode 100644 index 0000000..c25c380 --- /dev/null +++ b/config/dot/quickshell/modules/capture/IntelligenceResult.qml @@ -0,0 +1,408 @@ +// Focused result sheet for local OCR and code recognition. It is intentionally +// a transient overlay: the user selects pixels, acts on the result, and leaves +// without creating another application window or workspace tile. + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services +import qs.widgets + +PanelWindow { + id: win + + visible: ScreenIntelligence.visible + color: "transparent" + exclusiveZone: 0 + + anchors { + top: true + bottom: true + left: true + right: true + } + + WlrLayershell.namespace: "qs-screen-intelligence" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + onVisibleChanged: { + if (visible) + keyboardScope.forceActiveFocus(); + else + copied = false; + } + + property bool copied: false + + Timer { + id: copiedTimer + interval: 1500 + onTriggered: win.copied = false + } + + FocusScope { + id: keyboardScope + anchors.fill: parent + focus: true + + Keys.onEscapePressed: event => { + ScreenIntelligence.close(); + event.accepted = true; + } + + Rectangle { + anchors.fill: parent + color: Theme.alpha(Theme.bgDark, 0.46) + border.width: 0 + + MouseArea { + anchors.fill: parent + onClicked: ScreenIntelligence.close() + } + } + + Rectangle { + id: sheet + anchors.centerIn: parent + width: Math.min(parent.width - 80, 840) + height: Math.min(parent.height - 120, ScreenIntelligence.phase === "ready" ? 680 : 390) + radius: 24 + color: Theme.alpha(Theme.bgPopover, 0.965) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.1) + clip: true + + PrismEdge { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + inset: sheet.radius + } + + MouseArea { + anchors.fill: parent + onClicked: mouse => mouse.accepted = true + } + + Column { + id: heading + anchors.left: parent.left + anchors.right: closeButton.left + anchors.top: parent.top + anchors.leftMargin: 28 + anchors.topMargin: 25 + anchors.rightMargin: 18 + spacing: 4 + + Text { + text: "Screen Intelligence" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeTitle + font.weight: Font.DemiBold + } + + Text { + text: { + if (ScreenIntelligence.phase === "capturing") + return "Capturing the selected pixels"; + if (ScreenIntelligence.phase === "reading") + return "Reading locally on this computer"; + if (ScreenIntelligence.phase === "error") + return "Recognition needs attention"; + return "Local text and code recognition"; + } + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + Rectangle { + id: closeButton + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 20 + anchors.rightMargin: 20 + width: 34 + height: 34 + radius: 11 + color: closeMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent" + + Text { + anchors.centerIn: parent + text: "×" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: 20 + } + + MouseArea { + id: closeMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: ScreenIntelligence.close() + } + } + + Item { + id: body + anchors.left: parent.left + anchors.right: parent.right + anchors.top: heading.bottom + anchors.bottom: actions.top + anchors.leftMargin: 28 + anchors.rightMargin: 28 + anchors.topMargin: 24 + anchors.bottomMargin: 18 + + Column { + anchors.centerIn: parent + width: Math.min(parent.width, 520) + spacing: 13 + visible: ScreenIntelligence.busy + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "󰗊" + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: 40 + } + Text { + width: parent.width + text: ScreenIntelligence.phase === "capturing" ? "Capturing…" : "Reading text and codes…" + color: Theme.fg + horizontalAlignment: Text.AlignHCenter + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + Text { + width: parent.width + text: "Pixels stay on this computer. No network request is being made." + color: Theme.fgDim + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width, 560) + spacing: 14 + visible: ScreenIntelligence.phase === "error" + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "󰅚" + color: Theme.warn + font.family: Theme.fontMono + font.pixelSize: 38 + } + Text { + width: parent.width + text: ScreenIntelligence.error + color: Theme.fg + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: commandText.implicitWidth + 28 + height: 38 + radius: 10 + visible: !ScreenIntelligence.ocrReady + color: Theme.alpha(Theme.fg, 0.07) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + Text { + id: commandText + anchors.centerIn: parent + text: "sudo dnf install -y tesseract zbar" + color: Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSmall + } + } + } + + Column { + anchors.fill: parent + spacing: 12 + visible: ScreenIntelligence.phase === "ready" + + Rectangle { + width: parent.width + height: ScreenIntelligence.codeValue === "" ? 0 : 62 + visible: ScreenIntelligence.codeValue !== "" + radius: 13 + color: Theme.alpha(Theme.accent, 0.09) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.2) + + Text { + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.verticalCenter: parent.verticalCenter + text: "󰐲" + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: 22 + } + Column { + anchors.left: parent.left + anchors.leftMargin: 52 + anchors.right: codeAction.left + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { text: ScreenIntelligence.codeType || "Detected code"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSizeSmall; font.weight: Font.DemiBold } + Text { width: parent.width; text: ScreenIntelligence.codeValue; color: Theme.fgDim; elide: Text.ElideMiddle; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize } + } + ResultAction { + id: codeAction + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + label: ScreenIntelligence.primaryUrl !== "" ? "Open" : "Copy" + onClicked: { + if (ScreenIntelligence.primaryUrl !== "") + ScreenIntelligence.openDetected(); + else + ScreenIntelligence.copyCode(); + } + } + } + + Rectangle { + width: parent.width + height: parent.height - (ScreenIntelligence.codeValue === "" ? 0 : 74) + radius: 14 + color: Theme.alpha(Theme.bgDark, 0.72) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.075) + + Text { + anchors.centerIn: parent + width: parent.width - 60 + visible: ScreenIntelligence.text === "" && ScreenIntelligence.codeValue === "" + text: "No readable text or code was found. Try a tighter selection with larger, higher-contrast text." + color: Theme.fgDim + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + } + + Flickable { + anchors.fill: parent + anchors.margins: 17 + visible: ScreenIntelligence.text !== "" + clip: true + contentWidth: width + contentHeight: resultText.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + TextEdit { + id: resultText + width: parent.width + text: ScreenIntelligence.text + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + color: Theme.fg + selectionColor: Theme.accent + selectedTextColor: Theme.bgDark + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + } + } + } + } + } + + Row { + id: actions + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 28 + anchors.bottomMargin: 24 + spacing: 8 + visible: ScreenIntelligence.phase === "ready" && (ScreenIntelligence.text !== "" || ScreenIntelligence.codeValue !== "") + + ResultAction { + label: "Open link" + visible: ScreenIntelligence.primaryUrl !== "" && ScreenIntelligence.codeValue === "" + onClicked: ScreenIntelligence.openDetected() + } + ResultAction { + label: "Translate" + enabled: ScreenIntelligence.text !== "" + onClicked: ScreenIntelligence.translate() + } + ResultAction { + label: "Search" + enabled: ScreenIntelligence.text !== "" + onClicked: ScreenIntelligence.search() + } + ResultAction { + label: win.copied ? "Copied" : "Copy text" + primary: true + enabled: ScreenIntelligence.text !== "" + onClicked: { + ScreenIntelligence.copyText(); + win.copied = true; + copiedTimer.restart(); + } + } + } + } + } + + component ResultAction: Rectangle { + id: action + property string label: "" + property bool primary: false + property bool enabled: true + signal clicked + + implicitWidth: actionLabel.implicitWidth + 26 + implicitHeight: 34 + radius: 10 + opacity: enabled ? 1 : 0.4 + color: primary + ? (actionMouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.1) : Theme.accent) + : (actionMouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075)) + border.width: primary ? 0 : 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + Text { + id: actionLabel + anchors.centerIn: parent + text: action.label + color: action.primary ? Theme.bgDark : Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: actionMouse + anchors.fill: parent + enabled: action.enabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: action.clicked() + } + } +} diff --git a/config/dot/quickshell/modules/capture/ModeButton.qml b/config/dot/quickshell/modules/capture/ModeButton.qml new file mode 100644 index 0000000..49610fb --- /dev/null +++ b/config/dot/quickshell/modules/capture/ModeButton.qml @@ -0,0 +1,72 @@ +// One segment of the Screen / Window / Selection control. +// +// Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the +// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolour +// them, so they'd be invisible on this dark overlay. + +import QtQuick +import qs.config + +Rectangle { + id: root + + property string glyph: "" + property string label: "" + property bool active: false + + signal clicked + + implicitWidth: content.implicitWidth + 30 + implicitHeight: 40 + radius: Theme.pillRadius + border.width: 0 + + color: { + if (root.active) + return mouse.containsMouse ? Qt.lighter(Theme.accent, 1.1) : Theme.accent; + if (mouse.pressed) + return Theme.alpha(Theme.fg, Theme.activeAlpha); + if (mouse.containsMouse) + return Theme.alpha(Theme.fg, Theme.hoverAlpha); + return "transparent"; + } + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + readonly property color contentColor: root.active ? Theme.bgDark : Theme.fg + + Row { + id: content + anchors.centerIn: parent + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.glyph + color: root.contentColor + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeLarge + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.label + color: root.contentColor + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/config/dot/quickshell/modules/capture/RecordingIndicator.qml b/config/dot/quickshell/modules/capture/RecordingIndicator.qml new file mode 100644 index 0000000..093bb5c --- /dev/null +++ b/config/dot/quickshell/modules/capture/RecordingIndicator.qml @@ -0,0 +1,109 @@ +// The "you are being recorded" pill. +// +// GNOME puts a red dot and a timer in the top bar while a screencast runs and +// clicking it stops the recording. The bar is another module's window, so this +// is its own tiny layer surface parked just below it. + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services + +PanelWindow { + id: win + + visible: Capture.recording + color: "transparent" + + // Top only: with neither left nor right anchored, layer-shell centres the + // surface horizontally. + anchors.top: true + margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap + + implicitWidth: pill.implicitWidth + implicitHeight: pill.implicitHeight + exclusiveZone: 0 + + WlrLayershell.namespace: "qs-popover-recording" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + function elapsed(): string { + const t = Capture.recordingSeconds; + const s = ("0" + (t % 60)).slice(-2); + const m = Math.floor(t / 60) % 60; + const h = Math.floor(t / 3600); + return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s; + } + + Rectangle { + id: pill + implicitWidth: row.implicitWidth + 12 + implicitHeight: 34 + radius: Theme.pillRadius + border.width: 0 + color: Theme.redDeep + + Row { + id: row + anchors.centerIn: parent + spacing: 8 + + // Static dot, not a blinking one: nothing in this shell repaints + // while idle. + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 10 + height: 10 + radius: 5 + border.width: 0 + color: Theme.fg + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: win.elapsed() + color: Theme.fg + // Tabular figures: the timer must not shuffle as it counts. + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + + Rectangle { + id: stop + anchors.verticalCenter: parent.verticalCenter + width: 26 + height: 26 + radius: width / 2 + border.width: 0 + color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14) + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + Text { + anchors.centerIn: parent + text: "󰓛" + color: Theme.fg + font.family: Theme.fontMono + font.pixelSize: Theme.fontSize + } + + MouseArea { + id: stopMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // SIGINT, so wf-recorder finalises the container. + onClicked: Capture.stopRecording() + } + } + } + } +} diff --git a/config/dot/quickshell/modules/capture/SelectionLayer.qml b/config/dot/quickshell/modules/capture/SelectionLayer.qml new file mode 100644 index 0000000..69c034d --- /dev/null +++ b/config/dot/quickshell/modules/capture/SelectionLayer.qml @@ -0,0 +1,278 @@ +// Selection mode: a GNOME-style rubber band over the frozen screen. +// +// Behaviours copied from GNOME 42: the overlay opens with a sensible centred +// selection already in place (so the shutter is immediately useful), dragging +// inside moves it, dragging a corner resizes from the opposite corner, and +// dragging anywhere else starts a fresh band. +// +// A single MouseArea owns all of it. Nested MouseAreas for the handles would +// fight the move/create area for the press, and the hit priorities are much +// easier to reason about in one place. + +import QtQuick +import qs.config + +Item { + id: root + + // Anything smaller than this on release is treated as a stray click and the + // previous selection is restored. + readonly property real minSize: 12 + // Pointer distance from a corner that counts as grabbing its handle. + readonly property real handleGrab: 24 + readonly property real handleSize: 14 + + property real selX: 0 + property real selY: 0 + property real selW: 0 + property real selH: 0 + + readonly property bool hasSelection: root.selW >= root.minSize && root.selH >= root.minSize + readonly property rect selection: Qt.rect(root.selX, root.selY, root.selW, root.selH) + + // Double-clicking inside the band captures, like confirming a crop. + signal committed + + // "none" | "new" | "move" | "nw" | "ne" | "sw" | "se" + property string _grab: "none" + property real _anchorX: 0 + property real _anchorY: 0 + property real _pressX: 0 + property real _pressY: 0 + property real _origX: 0 + property real _origY: 0 + property rect _prev: Qt.rect(0, 0, 0, 0) + + onWidthChanged: root._resetIfEmpty() + onHeightChanged: root._resetIfEmpty() + Component.onCompleted: root._resetIfEmpty() + + function _resetIfEmpty(): void { + if (root.selW === 0 && root.selH === 0) + root.resetSelection(); + } + + // Centred, roughly the middle half of the screen. + function resetSelection(): void { + if (root.width <= 0 || root.height <= 0) + return; + root.selW = Math.round(root.width * 0.55); + root.selH = Math.round(root.height * 0.55); + root.selX = Math.round((root.width - root.selW) / 2); + root.selY = Math.round((root.height - root.selH) / 2); + } + + function _clampX(v: real): real { + return Math.max(0, Math.min(root.width, v)); + } + function _clampY(v: real): real { + return Math.max(0, Math.min(root.height, v)); + } + + function _near(a: real, b: real): bool { + return Math.abs(a - b) <= root.handleGrab; + } + + function _hitTest(px: real, py: real): string { + const x2 = root.selX + root.selW; + const y2 = root.selY + root.selH; + if (root.hasSelection) { + if (root._near(px, root.selX) && root._near(py, root.selY)) + return "nw"; + if (root._near(px, x2) && root._near(py, root.selY)) + return "ne"; + if (root._near(px, root.selX) && root._near(py, y2)) + return "sw"; + if (root._near(px, x2) && root._near(py, y2)) + return "se"; + if (px > root.selX && px < x2 && py > root.selY && py < y2) + return "move"; + } + return "new"; + } + + function _cursorFor(m: string): int { + switch (m) { + case "nw": + case "se": + return Qt.SizeFDiagCursor; + case "ne": + case "sw": + return Qt.SizeBDiagCursor; + case "move": + return Qt.SizeAllCursor; + default: + return Qt.CrossCursor; + } + } + + // Rebuild the band from a fixed anchor corner and the live pointer. + function _dragTo(px: real, py: real): void { + const cx = root._clampX(px); + const cy = root._clampY(py); + root.selX = Math.min(root._anchorX, cx); + root.selY = Math.min(root._anchorY, cy); + root.selW = Math.abs(cx - root._anchorX); + root.selH = Math.abs(cy - root._anchorY); + } + + Spotlight { + anchors.fill: parent + holeVisible: root.hasSelection + hole: root.selection + } + + Frame { + x: root.selX + y: root.selY + width: root.selW + height: root.selH + visible: root.hasSelection + strokeWidth: 2 + strokeColor: Theme.accent + } + + // ── Corner handles ────────────────────────────────────────────────────── + Repeater { + model: [ + { + gx: 0, + gy: 0 + }, + { + gx: 1, + gy: 0 + }, + { + gx: 0, + gy: 1 + }, + { + gx: 1, + gy: 1 + } + ] + + Rectangle { + required property var modelData + + visible: root.hasSelection + width: root.handleSize + height: root.handleSize + radius: width / 2 + border.width: 0 + color: Theme.accent + x: root.selX + modelData.gx * root.selW - width / 2 + y: root.selY + modelData.gy * root.selH - height / 2 + + // A dark core keeps the handle readable on top of a light photo. + Rectangle { + anchors.centerIn: parent + width: parent.width - 6 + height: width + radius: width / 2 + border.width: 0 + color: Theme.bgDark + } + } + } + + // ── Live size readout ─────────────────────────────────────────────────── + Rectangle { + id: readout + visible: root.hasSelection + implicitWidth: readoutText.implicitWidth + 24 + implicitHeight: 34 + radius: Theme.pillRadius + border.width: 0 + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + + x: Math.max(0, Math.min(root.width - width, root.selX + (root.selW - width) / 2)) + // Inside the band when there is room, otherwise tucked just above it. + y: root.selH > 110 ? root.selY + (root.selH - height) / 2 : Math.max(0, root.selY - height - 10) + + Text { + id: readoutText + anchors.centerIn: parent + text: Math.round(root.selW) + " × " + Math.round(root.selH) + color: Theme.fg + // Tabular figures so the readout doesn't twitch while dragging. + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: root._cursorFor(root._grab !== "none" ? root._grab : root._hitTest(area.mouseX, area.mouseY)) + + onPressed: event => { + root._prev = root.selection; + root._grab = root._hitTest(event.x, event.y); + root._pressX = event.x; + root._pressY = event.y; + root._origX = root.selX; + root._origY = root.selY; + + const x2 = root.selX + root.selW; + const y2 = root.selY + root.selH; + switch (root._grab) { + case "nw": + root._anchorX = x2; + root._anchorY = y2; + break; + case "ne": + root._anchorX = root.selX; + root._anchorY = y2; + break; + case "sw": + root._anchorX = x2; + root._anchorY = root.selY; + break; + case "se": + root._anchorX = root.selX; + root._anchorY = root.selY; + break; + case "new": + root._anchorX = root._clampX(event.x); + root._anchorY = root._clampY(event.y); + root.selX = root._anchorX; + root.selY = root._anchorY; + root.selW = 0; + root.selH = 0; + break; + } + } + + onPositionChanged: event => { + if (root._grab === "none") + return; + if (root._grab === "move") { + root.selX = Math.max(0, Math.min(root.width - root.selW, root._origX + (event.x - root._pressX))); + root.selY = Math.max(0, Math.min(root.height - root.selH, root._origY + (event.y - root._pressY))); + } else { + root._dragTo(event.x, event.y); + } + } + + onReleased: { + // A plain click outside the band would otherwise wipe the selection + // down to nothing; treat it as "no change" instead. + if (root._grab !== "move" && !root.hasSelection) { + root.selX = root._prev.x; + root.selY = root._prev.y; + root.selW = root._prev.width; + root.selH = root._prev.height; + } + root._grab = "none"; + } + + onDoubleClicked: event => { + if (root.hasSelection && event.x > root.selX && event.x < root.selX + root.selW && event.y > root.selY && event.y < root.selY + root.selH) + root.committed(); + } + } +} diff --git a/config/dot/quickshell/modules/capture/Spotlight.qml b/config/dot/quickshell/modules/capture/Spotlight.qml new file mode 100644 index 0000000..dca94bc --- /dev/null +++ b/config/dot/quickshell/modules/capture/Spotlight.qml @@ -0,0 +1,72 @@ +// Dims everything except one rectangle, GNOME-style. +// +// Four dimming rectangles laid around the hole, because QML has no way to +// subtract a shape from another without an OpacityMask shader — and a shader +// here would mean a full-screen 4500x3000 offscreen texture on every pointer +// move. + +import QtQuick +import qs.config + +Item { + id: root + + // The region left undimmed, in this item's coordinates. + property rect hole: Qt.rect(0, 0, 0, 0) + // When false the whole item dims uniformly (nothing selected yet). + property bool holeVisible: false + property color dimColor: Theme.alpha(Theme.bgDark, Theme.overlayAlpha) + + readonly property real _l: Math.max(0, Math.min(root.width, root.hole.x)) + readonly property real _t: Math.max(0, Math.min(root.height, root.hole.y)) + readonly property real _r: Math.max(root._l, Math.min(root.width, root.hole.x + root.hole.width)) + readonly property real _b: Math.max(root._t, Math.min(root.height, root.hole.y + root.hole.height)) + + Rectangle { + anchors.fill: parent + visible: !root.holeVisible + color: root.dimColor + border.width: 0 + } + + // Above the hole + Rectangle { + visible: root.holeVisible + x: 0 + y: 0 + width: root.width + height: root._t + color: root.dimColor + border.width: 0 + } + // Below + Rectangle { + visible: root.holeVisible + x: 0 + y: root._b + width: root.width + height: Math.max(0, root.height - root._b) + color: root.dimColor + border.width: 0 + } + // Left of the hole, between top and bottom bands + Rectangle { + visible: root.holeVisible + x: 0 + y: root._t + width: root._l + height: Math.max(0, root._b - root._t) + color: root.dimColor + border.width: 0 + } + // Right + Rectangle { + visible: root.holeVisible + x: root._r + y: root._t + width: Math.max(0, root.width - root._r) + height: Math.max(0, root._b - root._t) + color: root.dimColor + border.width: 0 + } +} diff --git a/config/dot/quickshell/modules/capture/WindowPicker.qml b/config/dot/quickshell/modules/capture/WindowPicker.qml new file mode 100644 index 0000000..1969083 --- /dev/null +++ b/config/dot/quickshell/modules/capture/WindowPicker.qml @@ -0,0 +1,140 @@ +// Window mode: outline every window on the focused workspace, highlight the one +// under the pointer and dim the rest, click to capture it. +// +// Geometry comes from `hyprctl -j clients` via services/Capture.qml, because +// Quickshell's HyprlandToplevel exposes no position or size. + +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + // Layout-global origin of the output this overlay covers. + property real originX: 0 + property real originY: 0 + + signal picked + + readonly property var windows: Capture.windows + + // Front-to-back hit test: Capture.windows is already sorted by + // focusHistoryID, so the first hit is the topmost window. + readonly property int hoveredIndex: { + if (!area.containsMouse) + return -1; + const mx = area.mouseX; + const my = area.mouseY; + for (let i = 0; i < root.windows.length; i++) { + const w = root.windows[i]; + const x = w.x - root.originX; + const y = w.y - root.originY; + if (mx >= x && mx <= x + w.w && my >= y && my <= y + w.h) + return i; + } + return -1; + } + + // Once a window has been hovered it stays highlighted after the pointer + // moves down onto the control bar, so the shutter button always has + // something to shoot. Index 0 is the most recently focused window, which is + // what GNOME preselects. + property int stickyIndex: 0 + onHoveredIndexChanged: { + if (root.hoveredIndex >= 0) + root.stickyIndex = root.hoveredIndex; + } + onWindowsChanged: root.stickyIndex = 0 + + readonly property int activeIndex: root.hoveredIndex >= 0 ? root.hoveredIndex : root.stickyIndex + readonly property var hovered: (root.activeIndex >= 0 && root.activeIndex < root.windows.length) ? root.windows[root.activeIndex] : null + + // Layout-global rect of the hovered window, or null. The overlay hands this + // straight to Capture.commit(). + readonly property var selectedRect: root.hovered ? Qt.rect(root.hovered.x, root.hovered.y, root.hovered.w, root.hovered.h) : null + + Spotlight { + anchors.fill: parent + holeVisible: root.hovered !== null + hole: root.hovered ? Qt.rect(root.hovered.x - root.originX, root.hovered.y - root.originY, root.hovered.w, root.hovered.h) : Qt.rect(0, 0, 0, 0) + } + + // Faint outline on every candidate so the user can see what is pickable + // before moving the pointer, the way GNOME's window shots do. + Repeater { + model: root.windows + + Frame { + required property var modelData + required property int index + + x: modelData.x - root.originX + y: modelData.y - root.originY + width: modelData.w + height: modelData.h + visible: index !== root.activeIndex + strokeWidth: 1 + strokeColor: Theme.alpha(Theme.fg, 0.35) + } + } + + Frame { + visible: root.hovered !== null + x: root.hovered ? root.hovered.x - root.originX : 0 + y: root.hovered ? root.hovered.y - root.originY : 0 + width: root.hovered ? root.hovered.w : 0 + height: root.hovered ? root.hovered.h : 0 + strokeWidth: 3 + strokeColor: Theme.accent + fillColor: Theme.alpha(Theme.accent, 0.08) + } + + // Title chip on the highlighted window — stacked windows are otherwise very + // hard to tell apart once the rest of the screen is dimmed. + Rectangle { + id: chip + visible: root.hovered !== null + x: (root.hovered ? root.hovered.x - root.originX : 0) + 12 + y: (root.hovered ? root.hovered.y - root.originY : 0) + 12 + implicitWidth: Math.min(chipText.implicitWidth + 24, root.hovered ? root.hovered.w - 24 : 0) + implicitHeight: 32 + radius: Theme.pillRadius + border.width: 0 + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + + Text { + id: chipText + anchors.centerIn: parent + width: chip.width - 24 + text: root.hovered ? (root.hovered.title || root.hovered.appId) : "" + color: Theme.fg + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + // Shown when hyprctl gave us nothing — a blank dimmed screen with no + // explanation would just look broken. + Text { + anchors.centerIn: parent + visible: root.windows.length === 0 + text: "No windows to capture on this workspace" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: root.hovered ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (root.hovered) + root.picked(); + } + } +} diff --git a/config/dot/quickshell/modules/clipboard/ClipboardEntry.qml b/config/dot/quickshell/modules/clipboard/ClipboardEntry.qml new file mode 100644 index 0000000..b8dffb3 --- /dev/null +++ b/config/dot/quickshell/modules/clipboard/ClipboardEntry.qml @@ -0,0 +1,171 @@ +// One row of clipboard history: what was copied, where it came from, and how +// long ago. Clicking it puts the entry back on the clipboard. +// +// Rows are cards on an already-glass surface, so they get no prism edge — the +// hairline marks a pane of glass, not every item sitting on one. + +import QtQuick +import qs.config +import qs.services + +Rectangle { + id: root + + required property var entry + + signal activated + + implicitHeight: Math.max(52, body.implicitHeight + 18) + + radius: Theme.cardRadius + // border.width: 0 works around QTBUG-137166, where transparent rounded + // rectangles can render with holes at the corners. + border.width: 0 + + color: { + if (mouse.pressed) + return Theme.alpha(Theme.accent, Theme.activeAlpha); + if (mouse.containsMouse) + return Theme.alpha(Theme.fg, Theme.hoverAlpha); + return "transparent"; + } + + // Fast in, relaxed out — the row should feel like it answers the pointer + // rather than fading after it. + Behavior on color { + ColorAnimation { + duration: mouse.containsMouse ? Theme.durFast : Theme.durNormal + easing.type: Easing.OutQuad + } + } + + // Nerd Font glyph for the kind of thing that was copied. Text is the common + // case and gets the quietest icon, so the odd link or image stands out. + readonly property string kindGlyph: { + switch (root.entry.kind) { + case Clipboard.kindLink: + return "\u{F0C1}"; // nf-fa-link + case Clipboard.kindImage: + return "\u{F03E}"; // nf-fa-picture_o + case Clipboard.kindFile: + return "\u{F016}"; // nf-fa-file_o + default: + return "\u{F15C}"; // nf-fa-file_text_o + } + } + + // Where it came from. Vicinae leaves `source` empty for clients it cannot + // attribute (anything pasted by a script, for one), in which case a link's + // host is more informative than nothing. + readonly property string origin: root.entry.source || root.entry.host || "" + + Text { + id: glyph + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.top: parent.top + anchors.topMargin: 11 + text: root.entry.encrypted ? "\u{F023}" : root.kindGlyph + // fontMono is the Nerd Font, used here only to draw an icon. + font.family: Theme.fontMono + font.pixelSize: 14 + color: root.entry.pinned ? Theme.accent : Theme.fgMuted + } + + Column { + id: body + anchors.left: glyph.right + anchors.leftMargin: 10 + anchors.right: timeLabel.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.entry.encrypted ? "Concealed by Vicinae" : root.entry.preview + color: root.entry.encrypted ? Theme.fgMuted : Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.italic: root.entry.encrypted + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + + Row { + spacing: 6 + visible: chip.visible || originLabel.visible + + // Images and file drags have no text worth reading, so the type is + // spelled out rather than left to the glyph alone. + Rectangle { + id: chip + visible: !root.entry.textual + width: chipLabel.implicitWidth + 12 + height: chipLabel.implicitHeight + 4 + radius: 4 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.10) + + Text { + id: chipLabel + anchors.centerIn: parent + text: root.entry.typeLabel + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + } + + Text { + id: originLabel + anchors.verticalCenter: chip.verticalCenter + visible: root.origin !== "" + text: root.origin + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + } + + Row { + id: timeLabel + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.top: parent.top + anchors.topMargin: 11 + spacing: 5 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: root.entry.pinned + text: "\u{F0403}" // nf-md-pin + font.family: Theme.fontMono + font.pixelSize: 11 + color: Theme.accent + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Clipboard.relativeTime(root.entry.ts) + color: Theme.fgMuted + font.family: Theme.fontFamily + // The list is full of these stacked in a column; tabular figures + // keep them from stepping sideways row to row. + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + } +} diff --git a/config/dot/quickshell/modules/clipboard/ClipboardPanel.qml b/config/dot/quickshell/modules/clipboard/ClipboardPanel.qml new file mode 100644 index 0000000..298ae7d --- /dev/null +++ b/config/dot/quickshell/modules/clipboard/ClipboardPanel.qml @@ -0,0 +1,230 @@ +// Clipboard history, backed by Vicinae's existing database. Opening the panel +// performs one read-only query; nothing polls or repaints while it is closed. + +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.config +import qs.services +import qs.widgets +import qs.modules.quicksettings + +PanelWindow { + id: root + + visible: ShellState.clipboardOpen + color: "transparent" + + anchors { + top: true + right: true + } + margins { + top: Theme.barHeight + Theme.barGap * 2 + right: Theme.barSideMargin + } + + exclusiveZone: 0 + implicitWidth: 430 + implicitHeight: 550 + + WlrLayershell.namespace: "qs-popover-clipboard" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + + property string filter: "" + + readonly property var filteredEntries: Clipboard.entries.filter(entry => { + const needle = root.filter.trim().toLowerCase(); + if (!needle) + return true; + return [entry.preview, entry.source, entry.host, entry.typeLabel] + .join(" ").toLowerCase().includes(needle); + }) + + function close(): void { + ShellState.close(); + } + + function activateFirst(): void { + if (root.filteredEntries.length === 0) + return; + Clipboard.copyEntry(root.filteredEntries[0].id); + root.close(); + } + + onVisibleChanged: { + if (root.visible) { + root.filter = ""; + search.clear(); + Clipboard.refresh(); + openAnim.restart(); + Qt.callLater(search.grab); + } + } + + HyprlandFocusGrab { + windows: [root] + active: root.visible + onCleared: root.close() + } + + Rectangle { + id: surface + anchors.fill: parent + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + PrismEdge { + anchors { + top: parent.top + left: parent.left + right: parent.right + topMargin: 1 + } + inset: parent.radius + } + + transform: Scale { + id: openScale + origin.x: surface.width + origin.y: 0 + xScale: 1 + yScale: 1 + } + + Column { + anchors.fill: parent + anchors.margins: Theme.popoverPadding + spacing: Theme.sectionSpacing + + Item { + width: parent.width + height: 30 + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 9 + + ThemedIcon { + anchors.verticalCenter: parent.verticalCenter + size: 17 + icon: "edit-paste-symbolic" + iconFallback: "edit-copy-symbolic" + tint: Theme.accent + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Clipboard" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeTitle + font.weight: Font.DemiBold + } + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: Clipboard.entries.length === 1 + ? "1 item" + : Clipboard.entries.length + " items" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + } + } + + SearchField { + id: search + width: parent.width + placeholder: "Search clipboard history" + onTextChanged: root.filter = text + onAccepted: root.activateFirst() + onDismissed: root.close() + } + + Rectangle { + width: parent.width + height: 1 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.1) + } + + Item { + width: parent.width + height: parent.height - y + + Text { + anchors.centerIn: parent + visible: Clipboard.loading + text: "Loading clipboard history…" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Text { + anchors.centerIn: parent + width: parent.width - 40 + visible: !Clipboard.loading && !Clipboard.available + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + text: "Clipboard history is unavailable. Start Vicinae to restore it." + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Text { + anchors.centerIn: parent + visible: !Clipboard.loading && Clipboard.available + && root.filteredEntries.length === 0 + text: root.filter ? "No matching clipboard items" : "Nothing copied yet" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + ScrollColumn { + anchors.fill: parent + maxHeight: parent.height + spacing: 4 + visible: !Clipboard.loading && Clipboard.available + && root.filteredEntries.length > 0 + + Repeater { + model: root.filteredEntries + + ClipboardEntry { + required property var modelData + + width: parent.width + entry: modelData + onActivated: { + Clipboard.copyEntry(modelData.id); + root.close(); + } + } + } + } + } + } + } + + NumberAnimation { + id: openAnim + target: openScale + property: "yScale" + from: 0.96 + to: 1 + duration: Theme.durFast + easing.type: Easing.OutCubic + } +} diff --git a/config/dot/quickshell/modules/clipboard/ClipboardWidget.qml b/config/dot/quickshell/modules/clipboard/ClipboardWidget.qml new file mode 100644 index 0000000..8eb039d --- /dev/null +++ b/config/dot/quickshell/modules/clipboard/ClipboardWidget.qml @@ -0,0 +1,24 @@ +// The bar entry for clipboard history. The icon is intentionally familiar and +// quiet: the history itself is a utility, not another permanent status readout. + +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Pill { + id: root + + implicitWidth: 30 + implicitHeight: 28 + + onActivated: ShellState.toggle("clipboard") + + ThemedIcon { + anchors.verticalCenter: parent.verticalCenter + size: 16 + icon: "edit-paste-symbolic" + iconFallback: "edit-copy-symbolic" + tint: ShellState.clipboardOpen ? Theme.accent : Theme.fg + } +} diff --git a/config/dot/quickshell/modules/clipboard/SearchField.qml b/config/dot/quickshell/modules/clipboard/SearchField.qml new file mode 100644 index 0000000..e906112 --- /dev/null +++ b/config/dot/quickshell/modules/clipboard/SearchField.qml @@ -0,0 +1,86 @@ +// The filter box at the top of the clipboard popover. Nothing is submitted: +// the text is read live and narrows the list that is already loaded, which is +// how GNOME's own search entries behave. + +import QtQuick +import qs.config + +Rectangle { + id: root + + property alias text: input.text + property string placeholder: "Search" + + signal accepted + signal dismissed + + implicitHeight: 32 + radius: Theme.pillRadius + // border.width is set below rather than left at 0, so the focus ring has + // something to change. See QTBUG-137166 for why 0 matters elsewhere. + color: Theme.alpha(Theme.fg, 0.07) + border.width: 1 + border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent" + + Behavior on border.color { + ColorAnimation { + duration: Theme.durFast + } + } + + function clear(): void { + input.text = ""; + } + + function grab(): void { + input.forceActiveFocus(); + } + + Text { + id: glyph + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "\u{F002}" // nf-fa-search — fontMono is the Nerd Font, icon only + font.family: Theme.fontMono + font.pixelSize: 12 + color: input.activeFocus ? Theme.accent : Theme.fgMuted + } + + TextInput { + id: input + anchors.left: glyph.right + anchors.leftMargin: 9 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.5) + selectedTextColor: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + clip: true + + onAccepted: root.accepted() + + // Escape clears a non-empty filter first and only closes the popover on + // a second press — losing a typed query to a stray Escape is worse than + // one extra keystroke. + Keys.onEscapePressed: { + if (input.text !== "") + input.text = ""; + else + root.dismissed(); + } + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + visible: input.text === "" + text: root.placeholder + color: Theme.fgMuted + font: input.font + } + } +} diff --git a/config/dot/quickshell/modules/clipboard/qmldir b/config/dot/quickshell/modules/clipboard/qmldir new file mode 100644 index 0000000..eac419f --- /dev/null +++ b/config/dot/quickshell/modules/clipboard/qmldir @@ -0,0 +1,5 @@ +module qs.modules.clipboard +ClipboardEntry 1.0 ClipboardEntry.qml +ClipboardPanel 1.0 ClipboardPanel.qml +ClipboardWidget 1.0 ClipboardWidget.qml +SearchField 1.0 SearchField.qml diff --git a/config/dot/quickshell/modules/datemenu/CalendarGrid.qml b/config/dot/quickshell/modules/datemenu/CalendarGrid.qml new file mode 100644 index 0000000..dd61801 --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/CalendarGrid.qml @@ -0,0 +1,233 @@ +// The month grid from GNOME's date menu. +// +// Six week rows always, so the grid height never changes as you page through +// months and the panel below it never jumps. Days belonging to the neighbouring +// months fill the edges, dimmed. Today wears the accent. +// +// Deliberately non-interactive apart from the header: there is no calendar +// backend behind this, so a day cell that highlighted on hover would be +// promising a click that does nothing. + +import Quickshell +import QtQuick +import qs.config + +Item { + id: root + + implicitHeight: body.implicitHeight + + // Cells divide the available width evenly; the height is fixed so the grid + // stays roughly square regardless of how wide the panel gets. + readonly property real cellWidth: root.width / 7 + readonly property int cellHeight: 32 + + // Hours precision is enough: the only thing that has to change on its own + // is which cell counts as "today", and that only moves at midnight. + SystemClock { + id: clock + precision: SystemClock.Hours + } + + readonly property int todayYear: clock.date.getFullYear() + readonly property int todayMonth: clock.date.getMonth() + readonly property int todayDay: clock.date.getDate() + + // The month currently on screen. DateMenu calls showToday() every time the + // panel opens, so it never comes back showing wherever you paged off to. + property int viewYear: root.todayYear + property int viewMonth: root.todayMonth + + readonly property bool onCurrentMonth: root.viewYear === root.todayYear && root.viewMonth === root.todayMonth + + function showToday(): void { + root.viewYear = root.todayYear; + root.viewMonth = root.todayMonth; + } + + function stepMonth(delta: int): void { + // Built through Date rather than by hand so December -> January rolls + // the year over for us. + const d = new Date(root.viewYear, root.viewMonth + delta, 1); + root.viewYear = d.getFullYear(); + root.viewMonth = d.getMonth(); + } + + readonly property var cells: { + const first = new Date(root.viewYear, root.viewMonth, 1); + const offset = first.getDay(); // 0 = Sunday, matching the header row + const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate(); + const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate(); + + const out = []; + for (let i = 0; i < 42; i++) { + const n = i - offset + 1; + if (n < 1) + out.push({ + day: inPrevMonth + n, + current: false + }); + else if (n > inThisMonth) + out.push({ + day: n - inThisMonth, + current: false + }); + else + out.push({ + day: n, + current: true + }); + } + return out; + } + + Column { + id: body + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 6 + + // ── Month header ──────────────────────────────────────────────────── + // Both arrows sit on the right so the left slot is free for the "Today" + // affordance, which only exists while you are looking at another month. + Item { + width: parent.width + height: 30 + + Rectangle { + id: todayPill + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: todayLabel.implicitWidth + 18 + height: 24 + radius: Theme.pillRadius + border.width: 0 + visible: !root.onCurrentMonth + color: todayMouse.containsMouse ? Theme.alpha(Theme.accent, 0.30) : Theme.alpha(Theme.accent, 0.16) + + Behavior on color { + ColorAnimation { + duration: todayMouse.containsMouse ? Theme.durFast : Theme.durNormal + } + } + + Text { + id: todayLabel + anchors.centerIn: parent + text: "Today" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: todayMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.showToday() + } + } + + // Centred on the row rather than between the pill and the arrows, + // so it does not shift sideways when the pill appears. + Text { + anchors.centerIn: parent + text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy") + font.family: Theme.fontFamily + // The year ticks in place when you page across a boundary. + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + color: Theme.fg + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + MonthArrow { + glyph: "\u{F0141}" // md-chevron_left + onActivated: root.stepMonth(-1) + } + + MonthArrow { + glyph: "\u{F0142}" // md-chevron_right + onActivated: root.stepMonth(1) + } + } + } + + // ── Weekday header ────────────────────────────────────────────────── + Row { + width: parent.width + + Repeater { + model: ["S", "M", "T", "W", "T", "F", "S"] + + delegate: Text { + required property string modelData + + width: root.cellWidth + height: 22 + text: modelData + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.fgMuted + } + } + } + + // ── Day grid ──────────────────────────────────────────────────────── + Grid { + width: parent.width + columns: 7 + + Repeater { + model: root.cells + + delegate: Item { + id: cell + + required property var modelData + + readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.onCurrentMonth + + width: root.cellWidth + height: root.cellHeight + + Rectangle { + anchors.centerIn: parent + width: root.cellHeight - 4 + height: root.cellHeight - 4 + radius: width / 2 + border.width: 0 + visible: cell.isToday + color: Theme.accent + } + + Text { + anchors.centerIn: parent + text: cell.modelData.day + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + font.weight: cell.isToday ? Font.DemiBold : Font.Normal + + color: { + if (cell.isToday) + return Theme.bgDark; + return cell.modelData.current ? Theme.fg : Theme.fgMuted; + } + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/datemenu/DateMenu.qml b/config/dot/quickshell/modules/datemenu/DateMenu.qml new file mode 100644 index 0000000..b33b44a --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/DateMenu.qml @@ -0,0 +1,111 @@ +// GNOME's date/time menu: the calendar and the message tray in one panel, +// dropping from the top centre because that is where GNOME put it and the +// muscle memory is the whole point. +// +// A layer-shell surface rather than a PopupWindow because the inline reply +// fields have to be typeable, and because the clock it hangs from is owned by a +// different module. +// +// Layer-shell centres a surface on whichever axis it is not anchored to, so +// anchoring only `top` is what puts it in the middle. + +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.config +import qs.services +import qs.widgets + +PanelWindow { + id: root + + // "notifications" is the existing overlay key, so Super+B, the notification + // IPC target and the clock all land here. + visible: ShellState.notificationsOpen + color: "transparent" + + anchors.top: true + margins.top: Theme.barHeight + Theme.barGap * 2 + exclusiveZone: 0 + + implicitWidth: panel.implicitWidth + implicitHeight: panel.implicitHeight + + // The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua. + WlrLayershell.namespace: "qs-popover-datemenu" + WlrLayershell.layer: WlrLayer.Overlay + // OnDemand rather than Exclusive: the panel must be able to take typing for + // inline replies, but must not steal it while merely visible. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + + onVisibleChanged: { + if (root.visible) { + // Opening the tray is what marks everything read, same as GNOME. + Notifs.markAllRead(); + panel.prepare(); + openAnim.restart(); + } else { + panel.reset(); + } + } + + // Closes the panel when a click lands anywhere else on the desktop. + HyprlandFocusGrab { + windows: [root] + active: root.visible + onCleared: ShellState.close() + } + + Rectangle { + id: surface + anchors.fill: parent + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + // The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px + // border so the two don't fight for the same row of pixels. + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + // Grows out of the top edge it is anchored to, rather than out of its + // own centre — the panel should look like it came from the bar. + transform: Scale { + id: openScale + origin.x: surface.width / 2 + origin.y: 0 + xScale: 1 + yScale: 1 + } + + // Escape reaches here by propagating up from whatever holds focus + // (usually a reply field), so it works in every sub-state. + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: ShellState.close() + + DateMenuPanel { + id: panel + anchors.fill: parent + } + } + } + + NumberAnimation { + id: openAnim + target: openScale + property: "yScale" + from: 0.94 + to: 1.0 + duration: Theme.durNormal + easing.type: Easing.OutCubic + } +} diff --git a/config/dot/quickshell/modules/datemenu/DateMenuPanel.qml b/config/dot/quickshell/modules/datemenu/DateMenuPanel.qml new file mode 100644 index 0000000..25feb20 --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/DateMenuPanel.qml @@ -0,0 +1,221 @@ +// The contents of the date menu, in GNOME's order: date header, notifications, +// calendar, weather. +// +// Kept separate from DateMenu.qml (the PanelWindow) so it can be dropped into a +// FloatingWindow for testing — layer-shell surfaces cannot map outside a +// wlroots compositor. + +import QtQuick +import Quickshell +import qs.config +import qs.services +import qs.widgets + +Item { + id: root + + implicitWidth: 480 + implicitHeight: content.implicitHeight + Theme.popoverPadding * 2 + + // Called by the window when it opens and closes, so the panel is always in + // a known state rather than wherever the last visit left it. + function prepare(): void { + calendar.showToday(); + } + + function reset(): void { + notifications.reset(); + } + + // Minutes precision: the header only shows a date, so this exists purely to + // roll it over at midnight without a per-second wake. + SystemClock { + id: clock + precision: SystemClock.Minutes + } + + Column { + id: content + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.popoverPadding + spacing: Theme.sectionSpacing + + // ── Header ────────────────────────────────────────────────────────── + Item { + width: parent.width + height: 34 + + Text { + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.right: headerActions.left + anchors.rightMargin: Theme.itemSpacing + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDateTime(clock.date, "dddd, MMMM d") + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + // The day number changes in place at midnight. + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeTitle + font.weight: Font.DemiBold + } + + Row { + id: headerActions + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + // Labelled rather than icon-only: an unexplained crossed-out + // bell is exactly the kind of thing a normal person has to + // click to find out about. + Rectangle { + id: dndPill + anchors.verticalCenter: parent.verticalCenter + width: dndRow.implicitWidth + 20 + height: 28 + radius: Theme.pillRadius + border.width: 0 + + color: { + if (Notifs.doNotDisturb) + return dndMouse.containsMouse ? Theme.alpha(Theme.accent, 0.42) : Theme.alpha(Theme.accent, 0.28); + return dndMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08); + } + + Behavior on color { + ColorAnimation { + duration: dndMouse.containsMouse ? Theme.durFast : Theme.durNormal + } + } + + Row { + id: dndRow + anchors.centerIn: parent + spacing: 6 + + ThemedIcon { + anchors.verticalCenter: parent.verticalCenter + size: 14 + icon: "notifications-disabled-symbolic" + iconFallback: "user-invisible-symbolic" + tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Do Not Disturb" + color: Notifs.doNotDisturb ? Theme.accent : Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + } + + MouseArea { + id: dndMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb + } + } + + Rectangle { + id: clearPill + anchors.verticalCenter: parent.verticalCenter + width: clearLabel.implicitWidth + 20 + height: 28 + radius: Theme.pillRadius + border.width: 0 + // Hidden rather than disabled: there is nothing ambiguous + // about an empty tray, so the control has no job. + visible: Notifs.hasNotifications + color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08) + + Behavior on color { + ColorAnimation { + duration: clearMouse.containsMouse ? Theme.durFast : Theme.durNormal + } + } + + Text { + id: clearLabel + anchors.centerIn: parent + text: "Clear all" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: clearMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notifs.dismissAll() + } + } + } + } + + // ── Notifications ─────────────────────────────────────────────────── + Rectangle { + width: parent.width + height: 1 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.1) + } + + MediaCard { + id: media + width: parent.width + } + + Rectangle { + width: parent.width + height: 1 + border.width: 0 + visible: media.visible + color: Theme.alpha(Theme.fg, 0.1) + } + + NotificationList { + id: notifications + width: parent.width + } + + // ── Calendar ──────────────────────────────────────────────────────── + Rectangle { + width: parent.width + height: 1 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.1) + } + + CalendarGrid { + id: calendar + width: parent.width + } + + // ── Weather ───────────────────────────────────────────────────────── + // Separator and card share one condition so the panel does not end on a + // rule with nothing under it. + Rectangle { + width: parent.width + height: 1 + border.width: 0 + visible: Weather.available + color: Theme.alpha(Theme.fg, 0.1) + } + + WeatherCard { + width: parent.width + visible: Weather.available + } + } +} diff --git a/config/dot/quickshell/modules/datemenu/MediaCard.qml b/config/dot/quickshell/modules/datemenu/MediaCard.qml new file mode 100644 index 0000000..bafd84c --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/MediaCard.qml @@ -0,0 +1,150 @@ +// Compact media controls in the date menu: artwork, useful metadata, and the +// three controls people expect. It disappears completely without a track. + +import QtQuick +import Quickshell.Services.Mpris +import qs.config +import qs.widgets + +Rectangle { + id: root + + readonly property MprisPlayer player: { + const players = Mpris.players ? Mpris.players.values : []; + return players.find(p => p.isPlaying) ?? players.find(p => p.trackTitle) ?? null; + } + + visible: root.player !== null + implicitHeight: visible ? 76 : 0 + radius: Theme.cardRadius + border.width: 0 + color: Theme.alpha(Theme.fg, 0.06) + clip: true + + Rectangle { + id: artwork + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 56 + height: 56 + radius: 10 + border.width: 0 + color: Theme.alpha(Theme.accentAlt, 0.16) + clip: true + + Image { + anchors.fill: parent + source: root.player?.trackArtUrl ?? "" + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + visible: status === Image.Ready + } + + ThemedIcon { + anchors.centerIn: parent + size: 24 + icon: "audio-x-generic-symbolic" + iconFallback: "multimedia-player-symbolic" + tint: Theme.accentAlt + visible: !root.player?.trackArtUrl + } + } + + Column { + anchors.left: artwork.right + anchors.leftMargin: 12 + anchors.right: controls.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.player?.trackTitle || "Unknown track" + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: root.player?.trackArtist || root.player?.identity || "Media" + color: Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Row { + id: controls + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + MediaButton { + icon: "media-skip-backward-symbolic" + enabled: root.player?.canGoPrevious ?? false + onClicked: root.player?.previous() + } + + MediaButton { + icon: root.player?.isPlaying ? "media-playback-pause-symbolic" : "media-playback-start-symbolic" + enabled: root.player?.canTogglePlaying ?? false + emphasized: true + onClicked: root.player?.togglePlaying() + } + + MediaButton { + icon: "media-skip-forward-symbolic" + enabled: root.player?.canGoNext ?? false + onClicked: root.player?.next() + } + } + + component MediaButton: Rectangle { + id: button + + required property string icon + property bool emphasized: false + signal clicked + + width: emphasized ? 34 : 30 + height: width + radius: width / 2 + border.width: 0 + opacity: enabled ? 1 : 0.35 + color: { + if (mouse.pressed) + return Theme.alpha(emphasized ? Theme.accent : Theme.fg, 0.34); + if (mouse.containsMouse) + return Theme.alpha(emphasized ? Theme.accent : Theme.fg, 0.22); + return emphasized ? Theme.alpha(Theme.accent, 0.14) : "transparent"; + } + + Behavior on color { + ColorAnimation { duration: Theme.durFast } + } + + ThemedIcon { + anchors.centerIn: parent + size: 15 + icon: button.icon + tint: button.emphasized ? Theme.accent : Theme.fg + } + + MouseArea { + id: mouse + anchors.fill: parent + enabled: button.enabled + hoverEnabled: true + cursorShape: button.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: button.clicked() + } + } +} diff --git a/config/dot/quickshell/modules/datemenu/MonthArrow.qml b/config/dot/quickshell/modules/datemenu/MonthArrow.qml new file mode 100644 index 0000000..ce31c4a --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/MonthArrow.qml @@ -0,0 +1,45 @@ +// Month-stepping arrow in CalendarGrid's header. A round hover target sized +// like GNOME's calendar arrows rather than a bare glyph, so it reads as a +// button before the pointer is anywhere near it. + +import QtQuick +import qs.config + +Rectangle { + id: root + + signal activated + + required property string glyph + + implicitWidth: 26 + implicitHeight: 26 + radius: width / 2 + border.width: 0 + color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent" + + // Fast in, relaxed out — the arrow answers the pointer, so arriving should + // feel immediate and leaving should not snap. + Behavior on color { + ColorAnimation { + duration: mouse.containsMouse ? Theme.durFast : Theme.durNormal + } + } + + Text { + anchors.centerIn: parent + text: root.glyph + // fontMono is the Nerd Font: used here to draw an icon, never text. + font.family: Theme.fontMono + font.pixelSize: Theme.fontSize + color: mouse.containsMouse ? Theme.fg : Theme.fgDim + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + } +} diff --git a/config/dot/quickshell/modules/datemenu/NotificationGroup.qml b/config/dot/quickshell/modules/datemenu/NotificationGroup.qml new file mode 100644 index 0000000..2e017f9 --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/NotificationGroup.qml @@ -0,0 +1,172 @@ +// One app's worth of notifications in the date menu, GNOME-style: a header +// carrying the app's identity and a clear button, over a collapsible stack of +// cards. +// +// The header is the disclosure control in its entirety — a 4px chevron is a +// poor click target, and GNOME lets you hit the whole row. + +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.config +import qs.services +import qs.modules.notifications +import qs.modules.quicksettings +import qs.widgets + +Column { + id: root + + // One entry from Notifs.groups: { app, icon, desktopEntry, items }. + required property var group + + property bool expanded: true + + signal toggled + signal cleared + + spacing: 4 + + // The app icon the notifications themselves declared, falling back to the + // desktop entry — same resolution order NotificationCard uses. + readonly property string iconSource: { + if (root.group.icon) + return Quickshell.iconPath(root.group.icon, true); + const entry = DesktopEntries.byId(root.group.desktopEntry); + return entry ? Quickshell.iconPath(entry.icon, true) : ""; + } + + Rectangle { + id: header + width: parent.width + height: 28 + radius: Theme.cardRadius - 4 + border.width: 0 + color: headerMouse.containsMouse ? Theme.alpha(Theme.fg, 0.07) : "transparent" + + Behavior on color { + ColorAnimation { + duration: headerMouse.containsMouse ? Theme.durFast : Theme.durNormal + } + } + + MouseArea { + id: headerMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggled() + } + + Row { + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.right: clearButton.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + IconImage { + anchors.verticalCenter: parent.verticalCenter + implicitSize: 15 + asynchronous: true + source: root.iconSource + visible: source !== "" + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.group.app + color: Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + // A bare count next to a collapsed group is the only way to know + // how much is hidden, so it is always shown once there is more + // than one. + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: countLabel.implicitWidth + 12 + height: 16 + radius: Theme.pillRadius + border.width: 0 + visible: root.group.items.length > 1 + color: Theme.alpha(Theme.fg, 0.12) + + Text { + id: countLabel + anchors.centerIn: parent + text: root.group.items.length + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + } + } + } + + IconButton { + id: clearButton + anchors.right: chevron.left + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + size: 22 + iconSize: 12 + tint: Theme.fgMuted + icon: "edit-clear-all-symbolic" + iconFallback: "window-close-symbolic" + onClicked: root.cleared() + } + + ThemedIcon { + id: chevron + anchors.right: parent.right + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + size: 12 + icon: "pan-down-symbolic" + iconFallback: "go-down-symbolic" + tint: Theme.fgMuted + + // Pointing down when open, right when closed — the standard + // disclosure direction, animated only on the click that caused it. + rotation: root.expanded ? 0 : -90 + + Behavior on rotation { + NumberAnimation { + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + } + } + } + + // Section animates its own height and hides itself at zero, which also + // collapses the spacing the parent Column would otherwise leave behind. + Section { + width: parent.width + expanded: root.expanded + + Column { + anchors.left: parent.left + anchors.right: parent.right + spacing: 4 + + Repeater { + model: root.group.items + + NotificationCard { + required property var modelData + + width: parent.width + compact: true + notification: modelData + onDismissed: Notifs.dismiss(modelData) + } + } + } + } +} diff --git a/config/dot/quickshell/modules/datemenu/NotificationList.qml b/config/dot/quickshell/modules/datemenu/NotificationList.qml new file mode 100644 index 0000000..ba21765 --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/NotificationList.qml @@ -0,0 +1,73 @@ +// The message tray inside the date menu: everything that has arrived, newest +// first, grouped by app, scrolling once it outgrows `maxHeight`. + +import QtQuick +import qs.config +import qs.services +import qs.modules.quicksettings + +Item { + id: root + + property int maxHeight: 420 + + implicitHeight: Notifs.hasNotifications ? list.implicitHeight : empty.implicitHeight + + // Which app groups are collapsed, keyed by app name. Kept here rather than + // on the group itself because Notifs.groups is rebuilt from scratch on + // every change to history, which destroys and recreates the delegates. + property var collapsedApps: ({}) + + function toggleApp(app: string): void { + // Reassigned rather than mutated: a property holding a JS object only + // re-evaluates bindings when the reference itself changes. + const next = Object.assign({}, root.collapsedApps); + if (next[app]) + delete next[app]; + else + next[app] = true; + root.collapsedApps = next; + } + + // Called when the panel closes, so it always reopens fully expanded. + function reset(): void { + root.collapsedApps = ({}); + } + + Item { + id: empty + anchors.fill: parent + implicitHeight: 132 + visible: !Notifs.hasNotifications + + Text { + anchors.centerIn: parent + text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + ScrollColumn { + id: list + anchors.fill: parent + maxHeight: root.maxHeight + spacing: Theme.itemSpacing + visible: Notifs.hasNotifications + + Repeater { + model: Notifs.groups + + NotificationGroup { + required property var modelData + + width: list.width + group: modelData + expanded: !root.collapsedApps[modelData.app] + onToggled: root.toggleApp(modelData.app) + onCleared: Notifs.dismissApp(modelData.app) + } + } + } +} diff --git a/config/dot/quickshell/modules/datemenu/WeatherCard.qml b/config/dot/quickshell/modules/datemenu/WeatherCard.qml new file mode 100644 index 0000000..fdc1282 --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/WeatherCard.qml @@ -0,0 +1,65 @@ +// Current conditions, at the foot of the date menu — GNOME's weather section. +// +// The whole card disappears when services/Weather.qml has nothing: a weather +// widget that cannot reach the network must go quiet, not show an error. The +// caller collapses the separator above it off the same `Weather.available`. + +import QtQuick +import qs.config +import qs.services + +Rectangle { + id: root + + implicitHeight: 62 + radius: Theme.cardRadius + // Cards inside an already-glass panel do not get a prism edge — the edge + // marks a pane of glass, not every item sitting on one. + border.width: 0 + color: Theme.alpha(Theme.fg, 0.06) + + Text { + id: glyph + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.verticalCenter: parent.verticalCenter + text: Weather.icon + // fontMono is the Nerd Font, used here purely to draw an icon. + font.family: Theme.fontMono + font.pixelSize: 26 + color: Theme.accentAlt + } + + Column { + anchors.left: glyph.right + anchors.leftMargin: 14 + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: Math.round(Weather.temperature) + Weather.unitSuffix + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + // The temperature changes in place on every refresh. + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + + Text { + width: parent.width + // Description is empty for weather codes we don't have a label for, + // in which case the row is just the location. + text: Weather.description === "" ? Settings.weatherLocation : Weather.description + " · " + Settings.weatherLocation + color: Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + } + } +} diff --git a/config/dot/quickshell/modules/datemenu/qmldir b/config/dot/quickshell/modules/datemenu/qmldir new file mode 100644 index 0000000..1ef257a --- /dev/null +++ b/config/dot/quickshell/modules/datemenu/qmldir @@ -0,0 +1,9 @@ +module qs.modules.datemenu +CalendarGrid 1.0 CalendarGrid.qml +DateMenu 1.0 DateMenu.qml +DateMenuPanel 1.0 DateMenuPanel.qml +MediaCard 1.0 MediaCard.qml +MonthArrow 1.0 MonthArrow.qml +NotificationGroup 1.0 NotificationGroup.qml +NotificationList 1.0 NotificationList.qml +WeatherCard 1.0 WeatherCard.qml diff --git a/config/dot/quickshell/modules/dock/Dock.qml b/config/dot/quickshell/modules/dock/Dock.qml new file mode 100644 index 0000000..d1acd21 --- /dev/null +++ b/config/dot/quickshell/modules/dock/Dock.qml @@ -0,0 +1,152 @@ +// The dock — a replacement for Dash-to-Dock configured as: bottom edge, 48px +// icons, intellihide against all windows, dot running-indicators, show-apps +// button at the leading edge. +// +// Intellihide means the dock reserves no space at all (exclusiveZone 0) and +// floats over windows, appearing when the pointer reaches the bottom edge or +// when nothing is in the way. + +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick +import qs.config +import qs.services + +PanelWindow { + id: root + + // Set by Variants when instantiated per-screen. Plain rather than + // `required` so the dock can also be created standalone while testing; + // Variants injects into the existing property either way. + property var modelData: null + screen: root.modelData + + anchors { + bottom: true + left: true + right: true + } + + color: "transparent" + + // A dock that reserved space would not be intellihiding. + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + + // Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename. + WlrLayershell.namespace: "qs-dock" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + // ── Geometry ──────────────────────────────────────────────────────────── + // Height = room for the tooltip above the bar + the bar + the gap under it. + readonly property int revealStripHeight: 3 + readonly property int bottomMargin: Theme.barGap + readonly property int tooltipSpace: 34 + + implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin + + // ── Intellihide ───────────────────────────────────────────────────────── + // Hyprland does not expose live toplevel geometry, so exact overlap cannot + // be computed. "Is anything on this workspace at all" is the robust proxy, + // and it is what Dash-to-Dock's all-windows intellihide felt like in + // practice: an empty workspace keeps the dock out. + readonly property bool workspaceOccupied: { + const ws = Hyprland.focusedWorkspace; + return !!ws && ws.toplevels.values.length > 0; + } + + readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered + + property bool revealed: true + + onWantRevealedChanged: { + if (wantRevealed) { + hideTimer.stop(); + revealTimer.restart(); + } else { + revealTimer.stop(); + hideTimer.restart(); + } + } + + Timer { + id: revealTimer + interval: Settings.dockRevealDelayMs + onTriggered: root.revealed = true + } + + Timer { + id: hideTimer + interval: Settings.dockHideDelayMs + onTriggered: root.revealed = false + } + + // Other modules (the bar, the capture overlay) read this. + onRevealedChanged: ShellState.dockRevealed = revealed + + // wantRevealed's first evaluation emits no change signal when it lands on + // false (the default), so the initial state has to be taken explicitly — + // otherwise a shell started on a busy workspace would leave the dock up. + Component.onCompleted: { + revealed = wantRevealed; + ShellState.dockRevealed = revealed; + } + + // ── Input region ──────────────────────────────────────────────────────── + // Revealed: the bar plus everything below it, so crossing the gap under the + // dock does not count as leaving. Hidden: a sliver along the screen edge, + // which is the only thing that can still trigger the dock — every other + // click passes straight through to the window underneath. + mask: Region { + item: maskItem + } + + Item { + id: surface + anchors.fill: parent + + // Reports the pointer anywhere inside the input region. A HoverHandler + // rather than a MouseArea because it keeps reporting while the icons' + // own MouseAreas are hovered. + HoverHandler { + id: pointer + } + + Item { + id: maskItem + x: root.revealed ? body.x : 0 + y: root.revealed ? body.y : surface.height - root.revealStripHeight + width: root.revealed ? body.width : surface.width + height: root.revealed ? surface.height - body.y : root.revealStripHeight + } + + DockBody { + id: body + anchors.horizontalCenter: parent.horizontalCenter + + // Slides off the bottom edge when hidden. + y: root.revealed ? root.tooltipSpace : surface.height + opacity: root.revealed ? 1 : 0 + + // Asymmetric on purpose. Revealing is a response to something the + // user just did, so it has to feel immediate — any delay there + // reads as lag. Hiding is not a response to anything, so it can + // take its time and stay calm in peripheral vision. + Behavior on y { + NumberAnimation { + duration: root.revealed ? Theme.durDockReveal : Theme.durNormal + easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic + } + } + + Behavior on opacity { + NumberAnimation { + duration: root.revealed ? Theme.durDockReveal : Theme.durNormal + easing.type: Easing.OutCubic + } + } + } + } +} diff --git a/config/dot/quickshell/modules/dock/DockBody.qml b/config/dot/quickshell/modules/dock/DockBody.qml new file mode 100644 index 0000000..b9b4be5 --- /dev/null +++ b/config/dot/quickshell/modules/dock/DockBody.qml @@ -0,0 +1,203 @@ +// The visible dock bar: the rounded translucent pill, its items, and the +// tooltip that floats above it. +// +// Kept separate from Dock.qml so it can be dropped straight into a +// FloatingWindow for testing — PanelWindow cannot map without layer shell. + +import Quickshell +import Quickshell.Hyprland +import QtQuick +import qs.config +import qs.widgets + +Rectangle { + id: root + + // ── Model ─────────────────────────────────────────────────────────────── + // One pass over the live toplevel list produces the whole dock: pinned + // apps first in Settings order, then anything else that is running. + // Entries are plain JS objects: { entry, windows, appId }. + readonly property var items: { + // DesktopEntries is scanned asynchronously at startup, and byId() is a + // plain method call that creates no binding dependency. Reading the + // model here is what makes the dock fill in once the scan lands. + if (DesktopEntries.applications.values.length === 0) + return []; + + const groups = {}; // appId -> [HyprlandToplevel] + const order = []; // appIds in first-seen order + + const toplevels = Hyprland.toplevels.values; + for (let i = 0; i < toplevels.length; i++) { + const t = toplevels[i]; + // `wayland` is null until Hyprland reports the address, and it is + // the only live source of an app id — there is no `class` property. + const appId = t.wayland ? t.wayland.appId : ""; + if (!appId) + continue; + if (!groups[appId]) { + groups[appId] = []; + order.push(appId); + } + groups[appId].push(t); + } + + const out = []; + const claimed = {}; + + const pinned = Settings.dockPinned; + for (let i = 0; i < pinned.length; i++) { + const entry = DesktopEntries.byId(pinned[i]); + // A pin that no longer resolves is dropped rather than drawn as a + // broken icon. + if (!entry) + continue; + + let windows = []; + for (let j = 0; j < order.length; j++) { + if (!claimed[order[j]] && root.matches(order[j], entry, pinned[i])) { + claimed[order[j]] = true; + windows = windows.concat(groups[order[j]]); + } + } + out.push({ + entry: entry, + windows: windows, + appId: pinned[i] + }); + } + + for (let i = 0; i < order.length; i++) { + const appId = order[i]; + if (claimed[appId]) + continue; + out.push({ + entry: DesktopEntries.heuristicLookup(appId), + windows: groups[appId], + appId: appId + }); + } + + return out; + } + + // Wayland app ids and desktop entry ids agree often but not always + // (`org.gnome.Nautilus` vs `nautilus`, `Steam` vs `steam`), so compare the + // usual candidates case-insensitively and fall back to the last + // reverse-DNS segment. + function matches(appId: string, entry: var, pinId: string): bool { + const a = appId.toLowerCase(); + if (a === pinId.toLowerCase()) + return true; + if (entry.id && a === entry.id.toLowerCase()) + return true; + if (entry.startupClass && a === entry.startupClass.toLowerCase()) + return true; + + const tail = a.split(".").pop(); + return tail === pinId.toLowerCase().split(".").pop(); + } + + // ── Appearance ────────────────────────────────────────────────────────── + radius: Theme.dockRadius + color: Theme.alpha(Theme.bgPanel, Theme.dockAlpha) + border.width: 0 // QTBUG-137166: rounded translucent rects lose their corners + + // The prism edge — see widgets/PrismEdge.qml. + PrismEdge { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + implicitWidth: row.implicitWidth + Theme.dockPadding * 2 + implicitHeight: row.implicitHeight + Theme.dockPadding * 2 + + // The item the tooltip is currently describing, or null. + property Item hoveredItem: null + + Row { + id: row + anchors.centerIn: parent + spacing: Theme.dockGap + + ShowAppsButton { + id: showApps + anchors.verticalCenter: parent.verticalCenter + onEntered: root.hoveredItem = showApps + onExited: if (root.hoveredItem === showApps) + root.hoveredItem = null + } + + // Separator between the launcher and the apps, as in GNOME's dash. + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 1 + height: Theme.dockIconSize * 0.7 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.14) + } + + Repeater { + model: root.items + + DockItem { + id: dockItem + required property var modelData + app: modelData + anchors.verticalCenter: parent.verticalCenter + onEntered: root.hoveredItem = dockItem + onExited: if (root.hoveredItem === dockItem) + root.hoveredItem = null + } + } + } + + // ── Tooltip ───────────────────────────────────────────────────────────── + // Styled like widgets/Popover.qml. Deliberately outside the dock's bounds + // (negative y): it renders fine there, and the dock's input mask does not + // cover it, so it can never swallow a click. + Rectangle { + id: tooltip + + readonly property string text: root.hoveredItem ? root.hoveredItem.label : "" + + visible: opacity > 0 + opacity: root.hoveredItem && tipLabel.text ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.durFast + } + } + + // Centred on the hovered item, clamped inside the dock. Reads the item's + // x directly (rather than mapToItem) so the binding re-evaluates when + // the row reflows. + x: { + if (!root.hoveredItem) + return 0; + const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2; + return Math.max(4, Math.min(root.width - width - 4, centre - width / 2)); + } + y: -height - 8 + + width: tipLabel.implicitWidth + Theme.popoverPadding * 2 + height: tipLabel.implicitHeight + 8 + + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + Text { + id: tipLabel + anchors.centerIn: parent + text: tooltip.text + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } +} diff --git a/config/dot/quickshell/modules/dock/DockItem.qml b/config/dot/quickshell/modules/dock/DockItem.qml new file mode 100644 index 0000000..0cee40b --- /dev/null +++ b/config/dot/quickshell/modules/dock/DockItem.qml @@ -0,0 +1,168 @@ +// One dock icon: the app, its running-window dots, hover scale and click +// behaviour. Reproduces Dash-to-Dock's item: 48px icon, up to four dots +// underneath, grow-on-hover, click to focus / cycle / launch. + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Widgets +import QtQuick +import qs.config + +Item { + id: root + + // { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string } + // Built by DockBody so this file stays presentational. + required property var app + + readonly property DesktopEntry entry: app && app.entry ? app.entry : null + readonly property var windows: app && app.windows ? app.windows : [] + readonly property string label: entry ? entry.name : (app && app.appId ? app.appId : "") + readonly property bool running: windows.length > 0 + readonly property bool hovered: mouse.containsMouse + + // Emitted so DockBody can drive the single shared tooltip. + signal entered + signal exited + + // The icon may grow past the cell on hover; the cell itself stays a fixed + // size so the row doesn't reflow. + implicitWidth: Theme.dockIconSize + implicitHeight: Theme.dockIconSize + dots.height + 4 + + // Desktop entries usually carry a freedesktop icon *name*, but some ship an + // absolute path. iconPath() only understands names, so branch on it. The + // `true` argument makes a missing icon return "" instead of a placeholder + // that renders as a black square in some themes. + readonly property string iconSource: { + const name = entry && entry.icon ? entry.icon : (app && app.appId ? app.appId : ""); + if (!name) + return ""; + if (name.startsWith("/")) + return "file://" + name; + return Quickshell.iconPath(name, true); + } + + Item { + id: iconSlot + width: Theme.dockIconSize + height: Theme.dockIconSize + anchors.horizontalCenter: parent.horizontalCenter + y: 0 + + // Grow upward out of the dock, the way GNOME's dash does, so the icon + // never overlaps its own running dots. + transformOrigin: Item.Bottom + scale: root.hovered ? 1.15 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: Theme.durFast + easing.type: Easing.OutBack + easing.overshoot: 1.6 + } + } + + IconImage { + anchors.fill: parent + visible: root.iconSource !== "" + source: root.iconSource + asynchronous: true + mipmap: true + } + + // Last resort for an app with no resolvable icon: an initial, which is + // still recognisable, unlike the theme's broken-icon placeholder. + Rectangle { + anchors.fill: parent + visible: root.iconSource === "" + radius: Theme.cardRadius + border.width: 0 + color: Theme.alpha(Theme.fg, Theme.activeAlpha) + + Text { + anchors.centerIn: parent + text: root.label ? root.label.charAt(0).toUpperCase() : "?" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Math.round(Theme.dockIconSize * 0.5) + } + } + } + + // Running indicators. Dash-to-Dock showed one dot per window up to four. + Row { + id: dots + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + spacing: 3 + height: 4 + + Repeater { + model: Math.min(root.windows.length, 4) + + Rectangle { + width: 4 + height: 4 + radius: 2 + border.width: 0 // QTBUG-137166: transparent rounded rects punch corners + color: Theme.accent + } + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + + onEntered: root.entered() + onExited: root.exited() + + onClicked: mev => { + // Middle click always starts a new instance, as in GNOME. + if (mev.button === Qt.MiddleButton) { + root.launch(); + return; + } + if (root.running) + root.focusNext(); + else + root.launch(); + } + } + + function launch(): void { + if (root.entry) + root.entry.execute(); + } + + // Clicking a running app cycles through its windows, matching GNOME's dash. + function focusNext(): void { + const wins = root.windows; + if (wins.length === 0) + return; + + let next = wins[0]; + for (let i = 0; i < wins.length; i++) { + if (wins[i].activated) { + next = wins[(i + 1) % wins.length]; + break; + } + } + if (!next) + return; + + // Quickshell reports the address without the 0x prefix Hyprland's + // window selector expects; normalise so either form works. + if (next.address) { + const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address; + Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`); + } else if (next.wayland) { + // No address yet (the toplevel is still being reported) — the + // wlr handle can still raise it. + next.wayland.activate(); + } + } +} diff --git a/config/dot/quickshell/modules/dock/ShowAppsButton.qml b/config/dot/quickshell/modules/dock/ShowAppsButton.qml new file mode 100644 index 0000000..85718e8 --- /dev/null +++ b/config/dot/quickshell/modules/dock/ShowAppsButton.qml @@ -0,0 +1,79 @@ +// The leading "show applications" button. GNOME's dash had +// show-apps-at-top: true, which on a bottom dock means the leading edge. +// +// The glyph is drawn rather than pulled from the icon theme: GNOME's app-grid +// icon is literally a 3x3 dot grid, and drawing it removes any dependency on +// which icon theme happens to be installed. + +import Quickshell +import QtQuick +import qs.config + +Item { + id: root + + signal entered + signal exited + + readonly property bool hovered: mouse.containsMouse + readonly property string label: qsTr("Show Applications") + + implicitWidth: Theme.dockIconSize + implicitHeight: Theme.dockIconSize + + Rectangle { + id: bg + anchors.centerIn: parent + width: Theme.dockIconSize + height: Theme.dockIconSize + radius: width / 2 + border.width: 0 // QTBUG-137166 + color: root.hovered ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + transformOrigin: Item.Center + scale: root.hovered ? 1.15 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: Theme.durFast + easing.type: Easing.OutBack + easing.overshoot: 1.6 + } + } + + Grid { + anchors.centerIn: parent + columns: 3 + spacing: Math.round(Theme.dockIconSize * 0.11) + + Repeater { + model: 9 + + Rectangle { + readonly property int dot: Math.round(Theme.dockIconSize * 0.135) + width: dot + height: dot + radius: dot / 2 + border.width: 0 + color: Theme.fg + } + } + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + onEntered: root.entered() + onExited: root.exited() + // vicinae is the launcher this shell uses in place of GNOME's app grid. + onClicked: Quickshell.execDetached(["vicinae", "toggle"]) + } +} diff --git a/config/dot/quickshell/modules/focus/FocusAction.qml b/config/dot/quickshell/modules/focus/FocusAction.qml new file mode 100644 index 0000000..a642657 --- /dev/null +++ b/config/dot/quickshell/modules/focus/FocusAction.qml @@ -0,0 +1,65 @@ +// One action in the focus capsule. Kept local to the module so the capsule's +// low, tailored geometry does not leak into generic shell buttons. + +import QtQuick +import qs.config +import qs.widgets + +Rectangle { + id: root + + property string icon: "" + property string iconFallback: "dialog-information-symbolic" + property string label: "" + property bool destructive: false + + signal activated + + implicitHeight: 34 + radius: Theme.cardRadius - 2 + border.width: 0 + + readonly property color actionColor: root.destructive ? Theme.danger : Theme.fg + + color: { + if (mouse.pressed) + return Theme.alpha(root.actionColor, 0.24); + if (mouse.containsMouse) + return Theme.alpha(root.actionColor, root.destructive ? 0.16 : Theme.hoverAlpha); + return Theme.alpha(Theme.fg, 0.07); + } + + Behavior on color { + ColorAnimation { duration: Theme.durFast } + } + + Row { + anchors.centerIn: parent + spacing: 7 + + ThemedIcon { + anchors.verticalCenter: parent.verticalCenter + size: 15 + icon: root.icon + iconFallback: root.iconFallback + tint: root.actionColor + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.label + color: root.actionColor + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + } +} diff --git a/config/dot/quickshell/modules/focus/FocusIndicator.qml b/config/dot/quickshell/modules/focus/FocusIndicator.qml new file mode 100644 index 0000000..ba9b33d --- /dev/null +++ b/config/dot/quickshell/modules/focus/FocusIndicator.qml @@ -0,0 +1,34 @@ +// The session never disappears completely. Dismissing the larger glass signal +// leaves this quiet bar-native indicator, which reopens it in one click. + +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Pill { + id: root + + visible: FocusSession.active + horizontalPadding: 8 + onActivated: FocusSession.reveal() + onSecondaryActivated: FocusSession.pauseOrResume() + + Text { + anchors.verticalCenter: parent.verticalCenter + text: FocusSession.paused ? "\u{F03E4}" : "\u{F051F}" // pause / timer-outline + color: FocusSession.paused ? Theme.warn : Theme.accent + font.family: Theme.fontMono + font.pixelSize: 14 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: FocusSession.remainingText + color: FocusSession.paused ? Theme.fgDim : Theme.fg + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } +} diff --git a/config/dot/quickshell/modules/focus/qmldir b/config/dot/quickshell/modules/focus/qmldir new file mode 100644 index 0000000..862248d --- /dev/null +++ b/config/dot/quickshell/modules/focus/qmldir @@ -0,0 +1,3 @@ +module qs.modules.focus +FocusAction 1.0 FocusAction.qml +FocusIndicator 1.0 FocusIndicator.qml diff --git a/config/dot/quickshell/modules/notifications/NotificationCard.qml b/config/dot/quickshell/modules/notifications/NotificationCard.qml new file mode 100644 index 0000000..d494f39 --- /dev/null +++ b/config/dot/quickshell/modules/notifications/NotificationCard.qml @@ -0,0 +1,306 @@ +// One notification, rendered GNOME-style: app icon, app name, summary, body, +// optional image, action buttons and an inline reply box. +// +// Shared by the toasts and the notification centre so a notification looks the +// same wherever you meet it. +// +// IconButton/ThemedIcon come from the quicksettings module — they belong in +// widgets/, but that directory is owned elsewhere. + +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import qs.config +import qs.services +import qs.modules.quicksettings +import qs.widgets + +Rectangle { + id: root + + required property var notification + + // The centre renders a denser card than a toast banner. + property bool compact: false + + signal dismissed + readonly property alias hovered: hover.containsMouse + // Raised when the reply field takes focus: the toast window has to ask the + // compositor for keyboard focus before anything can be typed. + signal replyFocusChanged(bool focused) + + // The image sits outside `layout`, so the card has to be tall enough for + // whichever of the two is bigger. + implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0) + radius: Theme.cardRadius + border.width: 0 + color: hover.containsMouse ? Theme.alpha(Theme.fg, 0.1) : Theme.alpha(Theme.fg, 0.06) + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + // Critical notifications get a red edge rather than a colour wash, so the + // text contrast never changes. + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: 6 + width: 3 + radius: 1.5 + border.width: 0 + color: Theme.urgent + visible: root.notification.urgency === NotificationUrgency.Critical + } + + readonly property var defaultAction: { + for (const action of root.notification.actions) { + if (action.identifier === "default") + return action; + } + return null; + } + + // "default" is the card click, so it never becomes a button. + readonly property bool hasButtons: { + for (const action of root.notification.actions) { + if (action.identifier !== "default") + return true; + } + return false; + } + + // Everything except the buttons: clicking the body runs the notification's + // default action, which is what GNOME does. + MouseArea { + id: hover + anchors.fill: parent + hoverEnabled: true + cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (root.defaultAction) + root.defaultAction.invoke(); + } + } + + IconImage { + id: appIcon + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.top: parent.top + anchors.topMargin: 13 + implicitSize: 22 + asynchronous: true + source: { + if (root.notification.appIcon !== "") + return Quickshell.iconPath(root.notification.appIcon, true); + const entry = DesktopEntries.byId(root.notification.desktopEntry); + return entry ? Quickshell.iconPath(entry.icon, true) : ""; + } + visible: source !== "" + } + + IconButton { + id: closeButton + anchors.right: parent.right + anchors.rightMargin: 6 + anchors.top: parent.top + anchors.topMargin: 6 + size: 24 + iconSize: 12 + tint: Theme.fgDim + icon: "window-close-symbolic" + onClicked: root.dismissed() + } + + Column { + id: layout + anchors.left: appIcon.visible ? appIcon.right : parent.left + anchors.leftMargin: appIcon.visible ? 10 : 14 + anchors.right: image.visible ? image.left : closeButton.left + anchors.rightMargin: 8 + anchors.top: parent.top + anchors.topMargin: 12 + spacing: 3 + + Text { + width: parent.width + text: { + const app = root.notification.appName || "Notification"; + const time = Notifs.timeText(root.notification.id); + return time === "" ? app : app + " · " + time; + } + color: Theme.fgMuted + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Text { + width: parent.width + visible: text !== "" + text: root.notification.summary + color: Theme.fg + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.Wrap + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + + Text { + width: parent.width + visible: text !== "" + text: root.notification.body + color: Theme.fgDim + // The server advertises bodyMarkupSupported, so bodies arrive with + // the freedesktop subset of HTML in them. + textFormat: Text.StyledText + linkColor: Theme.accent + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: root.compact ? 6 : 3 + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Item { + width: 1 + height: 4 + visible: actionRow.visible || replyBox.visible + } + + Flow { + id: actionRow + width: parent.width + spacing: 6 + visible: root.hasButtons + + Repeater { + model: root.notification.actions + + Rectangle { + id: actionButton + + required property var modelData + + visible: actionButton.modelData.identifier !== "default" + width: visible ? actionLabel.implicitWidth + 22 : 0 + height: visible ? 28 : 0 + radius: Theme.pillRadius + border.width: 0 + color: actionMouse.containsMouse ? Theme.alpha(Theme.accent, 0.28) : Theme.alpha(Theme.fg, 0.1) + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + Text { + id: actionLabel + anchors.centerIn: parent + text: actionButton.modelData.text + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: actionMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: actionButton.modelData.invoke() + } + } + } + } + + Rectangle { + id: replyBox + width: parent.width + height: 36 + visible: root.notification.hasInlineReply + radius: Theme.cardRadius - 2 + border.width: 1 + border.color: replyInput.activeFocus ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.12) + color: Theme.alpha(Theme.fg, 0.08) + + TextInput { + id: replyInput + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: replySend.left + anchors.rightMargin: 4 + anchors.verticalCenter: parent.verticalCenter + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.5) + selectedTextColor: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + clip: true + + onActiveFocusChanged: root.replyFocusChanged(replyInput.activeFocus) + + onAccepted: { + if (replyInput.text.length === 0) + return; + root.notification.sendInlineReply(replyInput.text); + replyInput.text = ""; + root.dismissed(); + } + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + visible: replyInput.text === "" + text: root.notification.inlineReplyPlaceholder || "Reply" + color: Theme.fgMuted + font: replyInput.font + } + } + + IconButton { + id: replySend + anchors.right: parent.right + anchors.rightMargin: 3 + anchors.verticalCenter: parent.verticalCenter + size: 28 + iconSize: 14 + icon: "go-next-symbolic" + onClicked: replyInput.accepted() + } + } + } + + // Notification images (album art, avatars, screenshots) sit on the right. + ClippingRectangle { + id: image + anchors.right: parent.right + anchors.rightMargin: 14 + anchors.top: closeButton.bottom + anchors.topMargin: 4 + implicitWidth: 52 + implicitHeight: 52 + radius: 8 + border.width: 0 + color: "transparent" + visible: root.notification.image !== "" + + Image { + anchors.fill: parent + source: root.notification.image + fillMode: Image.PreserveAspectCrop + asynchronous: true + sourceSize.width: 104 + sourceSize.height: 104 + } + } +} diff --git a/config/dot/quickshell/modules/notifications/NotificationCenter.qml b/config/dot/quickshell/modules/notifications/NotificationCenter.qml new file mode 100644 index 0000000..64c0ace --- /dev/null +++ b/config/dot/quickshell/modules/notifications/NotificationCenter.qml @@ -0,0 +1,219 @@ +// GNOME's message tray: everything that has arrived, grouped by app. +// +// Drops from the top centre because that is where GNOME's tray lives and the +// muscle memory is the whole point. Layer-shell centres a surface on whichever +// axis it is not anchored to, so anchoring only `top` does it. + +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Widgets +import qs.config +import qs.services +import qs.modules.quicksettings +import qs.widgets + +PanelWindow { + id: root + + visible: ShellState.notificationsOpen + color: "transparent" + + anchors.top: true + margins.top: Theme.barHeight + Theme.barGap * 2 + exclusiveZone: 0 + + implicitWidth: 440 + implicitHeight: surface.implicitHeight + + WlrLayershell.namespace: "qs-popover-notifications" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + + onVisibleChanged: { + if (root.visible) + Notifs.markAllRead(); + } + + HyprlandFocusGrab { + windows: [root] + active: root.visible + onCleared: ShellState.close() + } + + Rectangle { + id: surface + anchors.fill: parent + implicitHeight: content.implicitHeight + Theme.popoverPadding * 2 + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + // The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px + // border so the two don't fight for the same row of pixels. + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: ShellState.close() + + Column { + id: content + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.popoverPadding + spacing: Theme.itemSpacing + + // ── Header ────────────────────────────────────────────────── + Item { + width: parent.width + height: 32 + + Text { + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.verticalCenter: parent.verticalCenter + text: "Notifications" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + IconButton { + size: 30 + iconSize: 16 + icon: "notifications-disabled-symbolic" + tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim + onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb + } + + Rectangle { + width: clearLabel.implicitWidth + 20 + height: 28 + radius: Theme.pillRadius + border.width: 0 + visible: Notifs.hasNotifications + color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08) + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + Text { + id: clearLabel + anchors.centerIn: parent + text: "Clear all" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: clearMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notifs.dismissAll() + } + } + } + } + + // ── Empty state ───────────────────────────────────────────── + Item { + width: parent.width + height: 120 + visible: !Notifs.hasNotifications + + Text { + anchors.centerIn: parent + text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + // ── Grouped history ───────────────────────────────────────── + ScrollColumn { + width: parent.width + maxHeight: 620 + spacing: Theme.itemSpacing + visible: Notifs.hasNotifications + + Repeater { + model: Notifs.groups + + Column { + id: group + + required property var modelData + + width: parent.width + spacing: 4 + + Item { + width: parent.width + height: 26 + + Text { + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.verticalCenter: parent.verticalCenter + text: group.modelData.app + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + IconButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + size: 24 + iconSize: 13 + tint: Theme.fgDim + icon: "edit-clear-all-symbolic" + iconFallback: "window-close-symbolic" + onClicked: Notifs.dismissApp(group.modelData.app) + } + } + + Repeater { + model: group.modelData.items + + NotificationCard { + required property var modelData + + width: group.width + compact: true + notification: modelData + onDismissed: Notifs.dismiss(modelData) + } + } + } + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/notifications/Toast.qml b/config/dot/quickshell/modules/notifications/Toast.qml new file mode 100644 index 0000000..5809b24 --- /dev/null +++ b/config/dot/quickshell/modules/notifications/Toast.qml @@ -0,0 +1,64 @@ +// A single banner: a notification card that slides in and times itself out. + +import QtQuick +import Quickshell.Services.Notifications +import qs.config +import qs.services + +Item { + id: root + + required property var notification + + // Raised while the inline reply field has focus — Toasts.qml turns that + // into a keyboard focus request on the layer surface. + signal replyFocusChanged(bool focused) + + implicitHeight: card.implicitHeight + + // Critical notifications stay until dismissed (the setting is 0). An app + // asking for 0 means "never expire" per the freedesktop spec; -1 means + // "server decides", which is our default. + readonly property int timeoutMs: { + if (root.notification.urgency === NotificationUrgency.Critical) + return Settings.notificationTimeoutCriticalMs; + if (root.notification.expireTimeout === 0) + return 0; + if (root.notification.expireTimeout > 0) + return Math.round(root.notification.expireTimeout * 1000); + return Settings.notificationTimeoutMs; + } + + HoverHandler { + id: hover + } + + Timer { + // Hovering holds the banner open; the countdown restarts on leave. + running: root.timeoutMs > 0 && !hover.hovered + interval: root.timeoutMs + onTriggered: Notifs.dropPopup(root.notification) + } + + NotificationCard { + id: card + width: parent.width + notification: root.notification + onDismissed: Notifs.dropPopup(root.notification) + onReplyFocusChanged: focused => root.replyFocusChanged(focused) + + // Slide in from the right edge. Runs once, on creation. + NumberAnimation on x { + from: 40 + to: 0 + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + + NumberAnimation on opacity { + from: 0 + to: 1 + duration: Theme.durNormal + } + } +} diff --git a/config/dot/quickshell/modules/notifications/Toasts.qml b/config/dot/quickshell/modules/notifications/Toasts.qml new file mode 100644 index 0000000..fc2e2a5 --- /dev/null +++ b/config/dot/quickshell/modules/notifications/Toasts.qml @@ -0,0 +1,59 @@ +// The banner stack: newest at the top, tucked under the bar on the right. +// +// The window is exactly as wide and tall as the stack, so everything outside +// it stays clickable; the mask trims it further to the cards themselves. + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.config +import qs.services + +PanelWindow { + id: root + + visible: !Notifs.doNotDisturb && Notifs.popups.length > 0 + color: "transparent" + + anchors.top: true + anchors.right: true + margins.top: Theme.barHeight + Theme.barGap * 2 + margins.right: Theme.barSideMargin + exclusiveZone: 0 + + implicitWidth: 400 + implicitHeight: Math.max(1, stack.implicitHeight) + + WlrLayershell.namespace: "qs-notifications" + WlrLayershell.layer: WlrLayer.Overlay + + // Normally inert so banners never steal a keystroke. An open inline reply + // is the one case where the surface has to be typeable. + WlrLayershell.keyboardFocus: root.replyFocused ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + + property bool replyFocused: false + + mask: Region { + item: stack + } + + Column { + id: stack + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.itemSpacing + + Repeater { + model: Notifs.popups.slice(0, Settings.maxVisibleToasts) + + Toast { + required property var modelData + + width: stack.width + notification: modelData + onReplyFocusChanged: focused => root.replyFocused = focused + } + } + } +} diff --git a/config/dot/quickshell/modules/overview/Overview.qml b/config/dot/quickshell/modules/overview/Overview.qml new file mode 100644 index 0000000..4d70d86 --- /dev/null +++ b/config/dot/quickshell/modules/overview/Overview.qml @@ -0,0 +1,113 @@ +// The overview — a replacement for GNOME's Activities. Opened with Super+grave +// (the lead wires the IPC; this file only reacts to ShellState.overviewOpen). +// +// The compositor blurs and dims what is behind this surface via the +// `qs-overlay` layer rule; the scrim below adds the tint on top of that. + +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick +import qs.config +import qs.services + +PanelWindow { + id: root + + anchors { + top: true + bottom: true + left: true + right: true + } + + color: "transparent" + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + + // Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename. + WlrLayershell.namespace: "qs-overview" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: root.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + + readonly property bool open: ShellState.overviewOpen + + // The window has to stay mapped for the length of the close animation, + // otherwise it vanishes instantly and only the open transition is ever + // seen. `mapped` tracks overviewOpen, delayed on the way down. + property bool mapped: false + visible: mapped + + // Each open bumps the token, which makes every thumbnail re-capture once. + // Nothing captures while the overview is closed or idle. + property int refreshToken: 0 + + onOpenChanged: { + if (open) { + unmapTimer.stop(); + // The IPC/toplevel caches can lag a fast workspace switch. + Hyprland.refreshWorkspaces(); + Hyprland.refreshToplevels(); + mapped = true; + refreshToken++; + Qt.callLater(() => body.prepare()); + } else { + unmapTimer.restart(); + } + } + + Timer { + id: unmapTimer + interval: Theme.durNormal + onTriggered: root.mapped = false + } + + Item { + id: surface + anchors.fill: parent + focus: true + + Keys.onEscapePressed: ShellState.close() + + opacity: root.open ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + } + + transform: Scale { + origin.x: surface.width / 2 + origin.y: surface.height / 2 + xScale: root.open ? 1 : 0.96 + yScale: xScale + + Behavior on xScale { + NumberAnimation { + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + } + } + + Rectangle { + anchors.fill: parent + color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha) + + // Clicking anywhere that is not a card dismisses, as in GNOME. + MouseArea { + anchors.fill: parent + onClicked: ShellState.close() + } + } + + OverviewBody { + id: body + anchors.fill: parent + refreshToken: root.refreshToken + open: root.open + } + } +} diff --git a/config/dot/quickshell/modules/overview/OverviewBody.qml b/config/dot/quickshell/modules/overview/OverviewBody.qml new file mode 100644 index 0000000..ebadc94 --- /dev/null +++ b/config/dot/quickshell/modules/overview/OverviewBody.qml @@ -0,0 +1,496 @@ +// Continuum Mission Control: a GNOME-like workspace filmstrip above a large +// selected desktop, with cross-workspace window search when the user types. + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + property int refreshToken: 0 + property bool open: false + property int selectedWorkspaceId: 0 + property string query: "" + property int actionRefreshToken: 0 + property int selectedResultIndex: 0 + + readonly property int sideMargin: 52 + readonly property int filmCardWidth: 210 + readonly property int filmCardHeight: 140 + + readonly property var workspaceList: { + const out = []; + const all = Hyprland.workspaces?.values ?? []; + for (const workspace of all) { + if (workspace && workspace.id > 0) + out.push(workspace); + } + out.sort((a, b) => a.id - b.id); + return out; + } + + readonly property var scratchWorkspace: { + const all = Hyprland.workspaces?.values ?? []; + return all.find(workspace => workspace?.name === "special:scratch") ?? null; + } + + readonly property int nextWorkspaceId: { + let max = 0; + for (const workspace of root.workspaceList) + max = Math.max(max, workspace.id); + return max + 1; + } + + readonly property var selectedWorkspace: { + for (const workspace of root.workspaceList) { + if (workspace.id === root.selectedWorkspaceId) + return workspace; + } + return Hyprland.focusedWorkspace?.id > 0 ? Hyprland.focusedWorkspace : (root.workspaceList[0] ?? null); + } + + readonly property real screenAspect: { + const monitor = Hyprland.focusedMonitor; + return monitor && monitor.height > 0 ? monitor.width / monitor.height : 3 / 2; + } + + readonly property var windowEntries: { + const out = []; + for (const workspace of root.workspaceList) { + const windows = workspace.toplevels?.values ?? []; + for (const toplevel of windows) + out.push({ workspace, toplevel }); + } + return out; + } + + readonly property var filteredWindows: { + const needle = root.query.trim().toLowerCase(); + if (!needle) + return []; + return root.windowEntries.filter(entry => { + const title = entry.toplevel?.title ?? ""; + const appId = entry.toplevel?.wayland?.appId ?? ""; + return `${title} ${appId}`.toLowerCase().includes(needle); + }); + } + + readonly property int resultColumns: Math.min(3, Math.max(1, root.filteredWindows.length)) + readonly property var selectedResult: root.filteredWindows.length > 0 + ? root.filteredWindows[Math.min(root.selectedResultIndex, root.filteredWindows.length - 1)] + : null + + onQueryChanged: root.selectedResultIndex = 0 + onFilteredWindowsChanged: { + if (root.selectedResultIndex >= root.filteredWindows.length) + root.selectedResultIndex = Math.max(0, root.filteredWindows.length - 1); + } + + function prepare(): void { + root.query = ShellState.overviewQuery; + const requested = ShellState.overviewWorkspaceId; + const focused = Hyprland.focusedWorkspace?.id ?? 0; + root.selectedWorkspaceId = root.hasWorkspace(requested) ? requested : focused; + Qt.callLater(() => searchInput.forceActiveFocus()); + } + + function hasWorkspace(id: int): bool { + if (id <= 0) + return false; + return root.workspaceList.some(workspace => workspace.id === id); + } + + function selectWorkspace(workspace: var): void { + if (!workspace) + return; + root.selectedWorkspaceId = workspace.id; + workspace.activate(); + } + + function cycleWorkspace(delta: int): void { + if (root.workspaceList.length === 0) + return; + let index = root.workspaceList.findIndex(workspace => workspace.id === root.selectedWorkspaceId); + if (index < 0) + index = 0; + const next = Math.max(0, Math.min(root.workspaceList.length - 1, index + delta)); + root.selectWorkspace(root.workspaceList[next]); + } + + function focusToplevel(toplevel: var): void { + if (!toplevel) + return; + + const workspace = toplevel.workspace; + if (workspace) + workspace.activate(); + + if (toplevel.address) { + const address = toplevel.address.startsWith("0x") ? toplevel.address : "0x" + toplevel.address; + Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`); + } else if (toplevel.wayland) { + toplevel.wayland.activate(); + } + ShellState.close(); + } + + function addressOf(toplevel: var): string { + const raw = String(toplevel?.address ?? ""); + if (!raw) + return ""; + return raw.startsWith("0x") ? raw : "0x" + raw; + } + + function closeToplevel(toplevel: var): void { + const address = root.addressOf(toplevel); + if (!address) + return; + Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${address}" })`); + root.refreshAfterAction(); + } + + function moveToplevel(toplevel: var, workspaceName: string): void { + const address = root.addressOf(toplevel); + if (!address || !/^(?:[1-9]\d*|special:scratch)$/.test(workspaceName)) + return; + Hyprland.dispatch(`hl.dsp.window.move({ workspace = "${workspaceName}", follow = false, window = "address:${address}" })`); + root.refreshAfterAction(); + } + + function restoreToplevel(toplevel: var): void { + const address = root.addressOf(toplevel); + const target = root.selectedWorkspaceId > 0 ? root.selectedWorkspaceId : (Hyprland.focusedWorkspace?.id ?? 1); + if (!address || target <= 0) + return; + Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${target}, follow = true, window = "address:${address}" })`); + ShellState.close(); + } + + function refreshAfterAction(): void { + actionRefresh.restart(); + } + + Timer { + id: actionRefresh + interval: 140 + onTriggered: { + Hyprland.refreshWorkspaces(); + Hyprland.refreshToplevels(); + root.actionRefreshToken++; + } + } + + // Address-only actions make the exact same boundary testable end-to-end + // without synthesizing pointer input. They are also useful to launcher + // commands later; validation still limits destinations to Panama's model. + IpcHandler { + target: "overview-actions" + function move(address: string, workspaceName: string): void { + root.moveToplevel({ address }, workspaceName); + } + function close(address: string): void { + root.closeToplevel({ address }); + } + function restore(address: string): void { + root.restoreToplevel({ address }); + } + } + + // ── Header and search ────────────────────────────────────────────────── + Item { + id: header + anchors.top: parent.top + anchors.topMargin: 42 + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: root.sideMargin + anchors.rightMargin: root.sideMargin + height: 42 + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "Activities" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeTitle + font.weight: Font.DemiBold + } + + Rectangle { + id: searchBox + anchors.centerIn: parent + width: 390 + height: 38 + radius: Theme.pillRadius + border.width: searchInput.activeFocus ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.72) + color: Theme.alpha(Theme.bgPopover, 0.84) + + Text { + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + text: "\u{F0349}" // magnify + color: searchInput.activeFocus ? Theme.accent : Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 16 + } + + TextInput { + id: searchInput + anchors.left: parent.left + anchors.leftMargin: 40 + anchors.right: parent.right + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + text: root.query + color: Theme.fg + selectionColor: Theme.accent + selectedTextColor: Theme.bgDark + clip: true + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + onTextEdited: root.query = text + + Keys.onReturnPressed: { + if (root.query.trim() === "") { + if (root.selectedWorkspace) + root.selectWorkspace(root.selectedWorkspace); + ShellState.close(); + } else if (root.selectedResult) { + root.focusToplevel(root.selectedResult.toplevel); + } + } + + Keys.onLeftPressed: event => { + if (root.query.trim() !== "") + return; + root.cycleWorkspace(-1); + event.accepted = true; + } + + Keys.onRightPressed: event => { + if (root.query.trim() !== "") + return; + root.cycleWorkspace(1); + event.accepted = true; + } + + Keys.onUpPressed: event => { + if (!root.selectedResult) + return; + root.selectedResultIndex = Math.max(0, root.selectedResultIndex - root.resultColumns); + event.accepted = true; + } + + Keys.onDownPressed: event => { + if (!root.selectedResult) + return; + root.selectedResultIndex = Math.min(root.filteredWindows.length - 1, root.selectedResultIndex + root.resultColumns); + event.accepted = true; + } + + Keys.onDeletePressed: event => { + if (!root.selectedResult) + return; + root.closeToplevel(root.selectedResult.toplevel); + event.accepted = true; + } + } + + Text { + anchors.left: searchInput.left + anchors.verticalCenter: parent.verticalCenter + visible: searchInput.text === "" + text: "Find a window" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.workspaceList.length + (root.workspaceList.length === 1 ? " workspace" : " workspaces") + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + ScratchpadShelf { + id: scratchpadShelf + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: root.sideMargin + anchors.rightMargin: root.sideMargin + anchors.bottom: parent.bottom + anchors.bottomMargin: 20 + workspace: root.scratchWorkspace + refreshToken: root.refreshToken + root.actionRefreshToken + onWindowDropped: toplevel => root.moveToplevel(toplevel, "special:scratch") + onRestoreRequested: toplevel => root.restoreToplevel(toplevel) + onCloseRequested: toplevel => root.closeToplevel(toplevel) + } + + // ── Workspace filmstrip ──────────────────────────────────────────────── + Flickable { + id: filmstrip + anchors.top: header.bottom + anchors.topMargin: 18 + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: root.sideMargin + anchors.rightMargin: root.sideMargin + height: root.filmCardHeight + clip: true + contentWidth: Math.max(width, filmRow.implicitWidth) + contentHeight: height + boundsBehavior: Flickable.StopAtBounds + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: event => root.cycleWorkspace(event.angleDelta.y > 0 ? -1 : 1) + } + + Row { + id: filmRow + x: filmstrip.contentWidth > filmstrip.width ? 0 : (filmstrip.width - implicitWidth) / 2 + spacing: Theme.itemSpacing + + Repeater { + model: root.workspaceList + + WorkspaceCard { + required property var modelData + workspace: modelData + refreshToken: root.refreshToken + root.actionRefreshToken + emphasized: modelData.id === root.selectedWorkspaceId + focusBound: FocusSession.active && modelData.id === FocusSession.workspaceId + width: root.filmCardWidth + height: root.filmCardHeight + onActivated: root.selectWorkspace(modelData) + onWindowActivated: toplevel => root.focusToplevel(toplevel) + onWindowCloseRequested: toplevel => root.closeToplevel(toplevel) + onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName) + } + } + + WorkspaceCard { + workspace: null + newWorkspaceId: root.nextWorkspaceId + emphasized: false + width: root.filmCardWidth + height: root.filmCardHeight + onActivated: { + Hyprland.dispatch(`hl.dsp.focus({ workspace = ${root.nextWorkspaceId} })`); + root.selectedWorkspaceId = root.nextWorkspaceId; + } + onWindowCloseRequested: toplevel => root.closeToplevel(toplevel) + onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName) + } + } + } + + // ── Large selected desktop ───────────────────────────────────────────── + Item { + id: desktopStage + anchors.top: filmstrip.bottom + anchors.topMargin: 26 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: hint.top + anchors.bottomMargin: 18 + visible: root.query.trim() === "" + + WorkspaceCard { + id: largeWorkspace + anchors.top: parent.top + anchors.topMargin: 18 + anchors.horizontalCenter: parent.horizontalCenter + workspace: root.selectedWorkspace + refreshToken: root.refreshToken + root.actionRefreshToken + emphasized: true + focusBound: FocusSession.active && root.selectedWorkspace?.id === FocusSession.workspaceId + width: Math.min(parent.width - root.sideMargin * 2, 1760) + height: Math.min(parent.height, Math.round(width / root.screenAspect) + labelHeight) + onActivated: { + if (workspace) + workspace.activate(); + ShellState.close(); + } + onWindowActivated: toplevel => root.focusToplevel(toplevel) + onWindowCloseRequested: toplevel => root.closeToplevel(toplevel) + onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName) + } + } + + // ── Cross-workspace search results ───────────────────────────────────── + Flickable { + id: searchResults + anchors.top: filmstrip.bottom + anchors.topMargin: 28 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: hint.top + anchors.leftMargin: root.sideMargin + anchors.rightMargin: root.sideMargin + anchors.bottomMargin: 18 + visible: root.query.trim() !== "" + clip: true + contentWidth: width + contentHeight: resultFlow.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Flow { + id: resultFlow + readonly property real cardWidth: (searchResults.width - Theme.sectionSpacing * 2) / 3 + x: Math.max(0, (searchResults.width - width) / 2) + width: root.resultColumns * cardWidth + (root.resultColumns - 1) * Theme.sectionSpacing + spacing: Theme.sectionSpacing + + Repeater { + model: root.filteredWindows + + WindowThumbnail { + required property var modelData + required property int index + toplevel: modelData.toplevel + refreshToken: root.refreshToken + root.actionRefreshToken + selected: index === root.selectedResultIndex + width: resultFlow.cardWidth + height: Math.round(width / root.screenAspect) + 18 + onActivated: root.focusToplevel(modelData.toplevel) + onCloseRequested: root.closeToplevel(modelData.toplevel) + } + } + } + + Text { + anchors.centerIn: parent + visible: root.filteredWindows.length === 0 + text: "No windows match “" + root.query + "”" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + } + } + + Text { + id: hint + anchors.bottom: scratchpadShelf.top + anchors.bottomMargin: 8 + anchors.horizontalCenter: parent.horizontalCenter + text: root.query.trim() === "" ? "Drag windows between workspaces or into Scratchpad · Esc to return" : "Type to search every workspace · Enter opens the first match" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } +} diff --git a/config/dot/quickshell/modules/overview/ScratchpadShelf.qml b/config/dot/quickshell/modules/overview/ScratchpadShelf.qml new file mode 100644 index 0000000..72de4bb --- /dev/null +++ b/config/dot/quickshell/modules/overview/ScratchpadShelf.qml @@ -0,0 +1,129 @@ +// One global place to set windows aside. Empty, it is a quiet drop target; +// populated, it expands into a compact row of restorable window cards. + +import QtQuick +import qs.config + +Item { + id: root + + required property var workspace + property int refreshToken: 0 + + signal windowDropped(var toplevel) + signal restoreRequested(var toplevel) + signal closeRequested(var toplevel) + + readonly property var windows: root.workspace?.toplevels?.values ?? [] + readonly property bool populated: root.windows.length > 0 + + implicitHeight: root.populated ? 112 : 42 + + Rectangle { + id: surface + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + width: root.populated + ? Math.min(parent.width, Math.max(360, root.windows.length * 198 + 28)) + : 292 + height: root.populated ? 112 : 42 + radius: root.populated ? Theme.popoverRadius : Theme.pillRadius + border.width: 1 + border.color: dropArea.containsDrag ? Theme.ok : Theme.alpha(Theme.fg, root.populated ? 0.1 : 0.08) + color: Theme.alpha(Theme.bgPopover, root.populated ? 0.76 : 0.42) + + Behavior on width { + NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } + } + Behavior on height { + NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } + } + Behavior on border.color { + ColorAnimation { duration: Theme.durFast } + } + + Row { + anchors.centerIn: parent + visible: !root.populated + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: dropArea.containsDrag ? "\u{F04E7}" : "\u{F02D4}" + color: dropArea.containsDrag ? Theme.ok : Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 15 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: dropArea.containsDrag ? "Release to set aside" : "Drop to Scratchpad" + color: dropArea.containsDrag ? Theme.fg : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + } + + Text { + id: heading + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.top: parent.top + anchors.topMargin: 9 + visible: root.populated + text: "Scratchpad · " + root.windows.length + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + Flickable { + id: windowStrip + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.bottomMargin: 8 + height: 76 + visible: root.populated + clip: true + contentWidth: Math.max(width, cards.implicitWidth) + contentHeight: height + boundsBehavior: Flickable.StopAtBounds + + Row { + id: cards + spacing: Theme.itemSpacing + + Repeater { + model: root.windows + + WindowThumbnail { + required property var modelData + toplevel: modelData + refreshToken: root.refreshToken + width: 190 + height: 76 + onActivated: root.restoreRequested(modelData) + onCloseRequested: root.closeRequested(modelData) + } + } + } + } + + DropArea { + id: dropArea + anchors.fill: parent + z: 5 + onDropped: drop => { + if (!drop.source?.toplevel) + return; + root.windowDropped(drop.source.toplevel); + drop.acceptProposedAction(); + } + } + } +} diff --git a/config/dot/quickshell/modules/overview/WindowThumbnail.qml b/config/dot/quickshell/modules/overview/WindowThumbnail.qml new file mode 100644 index 0000000..4bff7ca --- /dev/null +++ b/config/dot/quickshell/modules/overview/WindowThumbnail.qml @@ -0,0 +1,269 @@ +// A single live window preview inside a workspace card, with the app icon and +// title underneath — GNOME's overview window chrome. +// +// Capture is deliberately one-shot (`live: false` + an explicit captureFrame() +// on open). Streaming every window continuously would repaint the whole +// overview forever, which is exactly the idle-repaint cost this shell avoids. + +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import QtQuick +import qs.config + +Item { + id: root + + required property var toplevel // HyprlandToplevel + + // Bumped by Overview each time it opens; each bump re-captures. + property int refreshToken: 0 + property bool selected: false + + signal activated + signal closeRequested + signal dragStarted + signal dragFinished + + property real dragStartX: 0 + property real dragStartY: 0 + + z: dragHandler.active ? 100 : 0 + opacity: dragHandler.active ? 0.88 : 1 + Drag.active: dragHandler.active + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + Drag.supportedActions: Qt.MoveAction + + // Null until Hyprland reports the address — and always null on compositors + // without wlr-screencopy, which is why every use of it is guarded. + readonly property var source: toplevel ? toplevel.wayland : null + readonly property string title: toplevel && toplevel.title ? toplevel.title : "" + readonly property string appId: source && source.appId ? source.appId : "" + // The `applications` read is deliberate: the entry scan finishes + // asynchronously, and heuristicLookup() alone creates no binding dependency. + readonly property var entry: appId && DesktopEntries.applications.values.length > 0 ? DesktopEntries.heuristicLookup(appId) : null + + // "" when nothing resolves — a missing icon is left blank rather than shown + // as the theme's broken-icon placeholder. + readonly property string iconSource: { + const name = entry && entry.icon ? entry.icon : appId; + if (!name) + return ""; + if (name.startsWith("/")) + return "file://" + name; + return Quickshell.iconPath(name, true); + } + + onRefreshTokenChanged: root.recapture() + + function recapture(): void { + if (shotLoader.item) + shotLoader.item.tryCapture(); + } + + Rectangle { + id: preview + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: caption.top + anchors.bottomMargin: 4 + + radius: Theme.cardRadius + border.width: 0 // QTBUG-137166 + color: Theme.alpha(Theme.bg, 0.6) + clip: true + + scale: dragHandler.active ? 1.04 : (hover.hovered ? 1.025 : 1) + + Behavior on scale { + NumberAnimation { + duration: Theme.durFast + easing.type: Easing.OutCubic + } + } + + // Shown until (or unless) a frame arrives. On a compositor without + // screencopy this is all the user ever sees, rather than an error. + IconImage { + anchors.centerIn: parent + visible: root.iconSource !== "" + source: root.iconSource + implicitSize: Math.max(24, Math.min(64, Math.min(preview.width, preview.height) * 0.4)) + asynchronous: true + mipmap: true + opacity: shotLoader.hasFrame ? 0 : 1 + + Behavior on opacity { + NumberAnimation { + duration: Theme.durFast + } + } + } + + Loader { + id: shotLoader + anchors.fill: parent + + readonly property bool hasFrame: item ? item.hasContent : false + + // Only instantiate a capture when there is something to capture AND + // the overview has been opened at least once. + // + // refreshToken is incremented by Overview.qml at the moment it maps, + // so gating on it guarantees the ScreencopyView is created while the + // window is actually mapped. Creating it earlier makes the initial + // captureFrame() fail with "no recording context is ready", because + // an unmapped layer surface has no capture context yet. + active: !!root.source && root.refreshToken > 0 + sourceComponent: shotComponent + } + + Rectangle { + anchors.fill: parent + visible: root.selected + radius: parent.radius + color: "transparent" + border.width: 2 + border.color: Theme.accent + z: 3 + } + + Rectangle { + id: closeButton + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 7 + visible: hover.hovered && !dragHandler.active + width: 26 + height: 26 + radius: 13 + z: 4 + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.1) + color: closeHover.hovered ? Theme.alpha(Theme.danger, 0.28) : Theme.alpha(Theme.bgDark, 0.82) + + Text { + anchors.centerIn: parent + text: "\u{F0156}" + color: closeHover.hovered ? Theme.danger : Theme.fg + font.family: Theme.fontMono + font.pixelSize: 13 + } + + HoverHandler { id: closeHover } + TapHandler { + onTapped: root.closeRequested() + } + } + } + + Component { + id: shotComponent + + ScreencopyView { + id: shot + captureSource: root.source + live: false + paintCursor: false + constraintSize: Qt.size(preview.width, preview.height) + + // Fit inside the preview without distorting the window's shape. + readonly property real aspect: sourceSize.height > 0 ? sourceSize.width / sourceSize.height : 16 / 9 + anchors.centerIn: parent + width: Math.min(parent.width, parent.height * aspect) + height: aspect > 0 ? width / aspect : parent.height + opacity: hasContent ? 1 : 0 + + // The capture context isn't ready the instant the layer surface is + // told to become visible — it needs the surface to actually map, + // which takes a frame or two. Calling captureFrame() before then + // logs "no recording context is ready" and yields nothing, so + // retry a few times and then give up quietly (the caption and app + // icon are still shown, so a missing thumbnail is cosmetic). + property int captureAttempts: 0 + + function tryCapture(): void { + captureAttempts = 0; + captureRetry.restart(); + } + + // `shot`, not `parent`: Timer is a QtObject, so `parent` does not + // resolve to the enclosing ScreencopyView. + Timer { + id: captureRetry + interval: 80 + repeat: true + running: false + onTriggered: { + if (shot.hasContent || shot.captureAttempts >= 6) { + stop(); + return; + } + shot.captureAttempts++; + shot.captureFrame(); + } + } + + Component.onCompleted: tryCapture() + } + } + + Row { + id: caption + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + spacing: 6 + height: 18 + + IconImage { + anchors.verticalCenter: parent.verticalCenter + visible: root.iconSource !== "" + width: visible ? 16 : 0 + height: 16 + source: root.iconSource + asynchronous: true + mipmap: true + } + + Text { + anchors.verticalCenter: parent.verticalCenter + // Clamped against the thumbnail rather than the Row: sizing against + // the Row would make its width depend on its own implicitWidth. + width: Math.min(implicitWidth, Math.max(0, root.width - 22)) + text: root.title + elide: Text.ElideRight + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + HoverHandler { + id: hover + } + + TapHandler { + onTapped: root.activated() + } + + DragHandler { + id: dragHandler + target: root + cursorShape: Qt.DragMoveCursor + + onActiveChanged: { + if (active) { + root.dragStartX = root.x; + root.dragStartY = root.y; + root.dragStarted(); + } else { + root.Drag.drop(); + root.x = root.dragStartX; + root.y = root.dragStartY; + root.dragFinished(); + } + } + } +} diff --git a/config/dot/quickshell/modules/overview/WorkspaceCard.qml b/config/dot/quickshell/modules/overview/WorkspaceCard.qml new file mode 100644 index 0000000..431cb2a --- /dev/null +++ b/config/dot/quickshell/modules/overview/WorkspaceCard.qml @@ -0,0 +1,182 @@ +// One workspace in the overview: a proportional card holding live thumbnails +// of its windows, labelled underneath. The focused workspace is outlined in the +// accent colour, as GNOME outlines the current workspace. +// +// A card with a null `workspace` is the trailing "new workspace" affordance +// that dynamic-workspace setups (GNOME's, and Hyprland's) always show. + +import Quickshell +import QtQuick +import qs.config + +Item { + id: root + + // HyprlandWorkspace, or null for the trailing "new workspace" card. + required property var workspace + // Shown on the empty trailing card. + property int newWorkspaceId: 0 + + property int refreshToken: 0 + // `emphasized` is the overview's current spatial selection. It normally + // follows compositor focus, but can point at a requested focus workspace + // before the user commits to switching there. + property bool emphasized: focused + property bool focusBound: false + + signal activated + signal windowActivated(var toplevel) + signal windowCloseRequested(var toplevel) + signal windowDropped(var toplevel, string workspaceName) + + readonly property bool isNew: !workspace + readonly property var windows: workspace ? workspace.toplevels.values : [] + readonly property bool focused: workspace ? workspace.focused : false + readonly property string dropWorkspaceName: workspace ? String(workspace.id) + : (newWorkspaceId > 0 ? String(newWorkspaceId) : "") + + readonly property string label: { + if (isNew) + return qsTr("New"); + // Named workspaces keep their name; numbered ones read better as + // "Workspace N" only when there is room, so just show the name. + return workspace.name ? workspace.name : String(workspace.id); + } + + // Card body — the label sits under it, so reserve that space. + readonly property int labelHeight: 22 + + Rectangle { + id: card + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.bottomMargin: root.labelHeight + + radius: Theme.cardRadius + color: Theme.alpha(Theme.bgDark, 0.55) + border.width: 2 + border.color: { + if (dropArea.containsDrag) + return Theme.ok; + if (root.emphasized) + return Theme.accent; + if (root.focusBound) + return Theme.alpha(Theme.accentSecondary, 0.72); + return Theme.alpha(Theme.fg, 0.08); + } + + Behavior on border.color { + ColorAnimation { + duration: Theme.durFast + } + } + + // Windows are laid out on a near-square grid. Hyprland does not expose + // live per-window geometry, so an honest grid beats a wrong guess at + // the real tiling. + readonly property int columns: Math.max(1, Math.ceil(Math.sqrt(root.windows.length))) + readonly property int rows: Math.max(1, Math.ceil(root.windows.length / columns)) + readonly property int cellW: Math.floor((width - Theme.itemSpacing * (columns + 1)) / columns) + readonly property int cellH: Math.floor((height - Theme.itemSpacing * (rows + 1)) / rows) + + Grid { + anchors.centerIn: parent + columns: card.columns + spacing: Theme.itemSpacing + visible: root.windows.length > 0 + + Repeater { + model: root.windows + + WindowThumbnail { + required property var modelData + toplevel: modelData + refreshToken: root.refreshToken + width: card.cellW + height: card.cellH + onActivated: root.windowActivated(modelData) + onCloseRequested: root.windowCloseRequested(modelData) + } + } + } + + DropArea { + id: dropArea + anchors.fill: parent + z: 3 + enabled: root.dropWorkspaceName !== "" + onDropped: drop => { + if (!drop.source?.toplevel) + return; + root.windowDropped(drop.source.toplevel, root.dropWorkspaceName); + drop.acceptProposedAction(); + } + } + + // Empty workspace: a big soft "+" so the card still reads as clickable. + Text { + anchors.centerIn: parent + visible: root.windows.length === 0 + text: "+" + color: Theme.alpha(Theme.fg, 0.25) + font.family: Theme.fontFamily + font.pixelSize: Math.max(Theme.fontSizeTitle, Math.round(card.height * 0.28)) + } + + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 8 + visible: root.focusBound + width: focusBadge.implicitWidth + 16 + height: 24 + radius: Theme.pillRadius + border.width: 0 + color: Theme.alpha(Theme.bgDark, 0.82) + + Row { + id: focusBadge + anchors.centerIn: parent + spacing: 5 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "\u{F051F}" + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: 12 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Focus" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + } + } + + // Sits below the thumbnails so clicking a window still focuses it. + MouseArea { + anchors.fill: parent + z: -1 + onClicked: root.activated() + } + } + + Text { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + height: root.labelHeight + verticalAlignment: Text.AlignVCenter + text: root.label + color: root.emphasized ? Theme.accent : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.bold: root.emphasized + } +} diff --git a/config/dot/quickshell/modules/powermenu/PowerButton.qml b/config/dot/quickshell/modules/powermenu/PowerButton.qml new file mode 100644 index 0000000..884df6e --- /dev/null +++ b/config/dot/quickshell/modules/powermenu/PowerButton.qml @@ -0,0 +1,106 @@ +// One tile in the power menu: big glyph, label underneath. +// +// Anything that ends the session arms on the first press and only fires on the +// second, with the label swapping to "Confirm" — an accidental Ctrl+Alt+Delete +// should never be one click away from losing everything that is open. + +import QtQuick +import qs.config + +Rectangle { + id: root + + property string glyph: "" + property string label: "" + // Tint red on hover and require the two-step confirm. + property bool destructive: false + // Highlighted by keyboard navigation. + property bool selected: false + + signal activated + + readonly property bool armed: confirmTimer.running + + implicitWidth: 136 + implicitHeight: 136 + radius: Theme.cardRadius + 6 + border.width: 0 + + readonly property color accentFor: root.destructive ? Theme.danger : Theme.accent + + color: { + if (root.armed) + return Theme.alpha(Theme.danger, 0.32); + if (mouse.pressed) + return Theme.alpha(root.accentFor, 0.3); + if (mouse.containsMouse || root.selected) + return Theme.alpha(root.accentFor, 0.18); + return Theme.alpha(Theme.fg, 0.07); + } + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + // Keyboard focus ring. + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.width: root.selected ? 2 : 0 + border.color: root.accentFor + visible: root.selected + } + + Column { + anchors.centerIn: parent + spacing: 10 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.glyph + color: root.armed ? Theme.danger : Theme.fg + font.family: Theme.fontMono + font.pixelSize: 40 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.armed ? "Confirm" : root.label + color: root.armed ? Theme.danger : Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + } + + // Trigger from a click or from Enter while keyboard-selected. + function trigger(): void { + if (root.destructive && !root.armed) { + confirmTimer.restart(); + return; + } + confirmTimer.stop(); + root.activated(); + } + + function disarm(): void { + confirmTimer.stop(); + } + + // Arming is not sticky: walk away and the button forgets. + Timer { + id: confirmTimer + interval: 4000 + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.trigger() + } +} diff --git a/config/dot/quickshell/modules/powermenu/PowerMenu.qml b/config/dot/quickshell/modules/powermenu/PowerMenu.qml new file mode 100644 index 0000000..e700d99 --- /dev/null +++ b/config/dot/quickshell/modules/powermenu/PowerMenu.qml @@ -0,0 +1,169 @@ +// Replaces GNOME's power-off dialog. Opened by Ctrl+Alt+Delete and from the +// quick-settings footer. +// +// Log Out, Restart and Power Off arm on the first press and fire on the second +// (see PowerButton); Lock and Suspend are one press because neither loses work. + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services + +PanelWindow { + id: win + + visible: ShellState.powerMenuOpen + color: "transparent" + + anchors { + top: true + bottom: true + left: true + right: true + } + + exclusiveZone: 0 + + WlrLayershell.namespace: "qs-popover-power" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + property int currentIndex: 0 + + // The session is uwsm-managed, so logging out means stopping the uwsm unit + // rather than killing the compositor; the hyprctl branch is only there for + // a session started without it. + readonly property string logoutScript: 'if command -v uwsm >/dev/null 2>&1; then exec uwsm stop; else exec hyprctl dispatch "hl.dsp.exit()"; fi' + + readonly property var entries: [ + { + glyph: "󰌾", + label: "Lock", + destructive: false, + cmd: ["loginctl", "lock-session"] + }, + { + glyph: "󰗼", + label: "Log Out", + destructive: true, + cmd: ["sh", "-c", win.logoutScript] + }, + { + glyph: "󰒲", + label: "Suspend", + destructive: false, + cmd: ["systemctl", "suspend"] + }, + { + glyph: "󰜉", + label: "Restart", + destructive: true, + cmd: ["systemctl", "reboot"] + }, + { + glyph: "󰐥", + label: "Power Off", + destructive: true, + cmd: ["systemctl", "poweroff"] + } + ] + + onVisibleChanged: { + if (!win.visible) + return; + // Never reopen with a destructive button still armed from last time. + win.currentIndex = 0; + for (let i = 0; i < rep.count; i++) + rep.itemAt(i).disarm(); + keys.forceActiveFocus(); + } + + function run(index: int): void { + ShellState.close(); + Quickshell.execDetached(win.entries[index].cmd); + } + + function move(step: int): void { + // Moving away disarms, so Enter can never fire a button the user only + // meant to walk past. + rep.itemAt(win.currentIndex).disarm(); + win.currentIndex = (win.currentIndex + step + win.entries.length) % win.entries.length; + } + + FocusScope { + id: keys + anchors.fill: parent + focus: true + + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Escape: + ShellState.close(); + break; + case Qt.Key_Left: + win.move(-1); + break; + case Qt.Key_Right: + win.move(1); + break; + case Qt.Key_Return: + case Qt.Key_Enter: + case Qt.Key_Space: + rep.itemAt(win.currentIndex).trigger(); + break; + default: + return; + } + event.accepted = true; + } + + Rectangle { + anchors.fill: parent + color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha) + border.width: 0 + + MouseArea { + anchors.fill: parent + onClicked: ShellState.close() + } + } + + Rectangle { + anchors.centerIn: parent + implicitWidth: row.implicitWidth + 2 * 24 + implicitHeight: row.implicitHeight + 2 * 24 + radius: Theme.popoverRadius + 8 + border.width: 0 + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + + // Swallow clicks that land on the panel but miss a tile, so the + // outside-click handler underneath doesn't close the menu. + MouseArea { + anchors.fill: parent + } + + Row { + id: row + anchors.centerIn: parent + spacing: 14 + + Repeater { + id: rep + model: win.entries + + PowerButton { + required property var modelData + required property int index + + glyph: modelData.glyph + label: modelData.label + destructive: modelData.destructive + selected: index === win.currentIndex + onActivated: win.run(index) + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml b/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml new file mode 100644 index 0000000..1fe9c4e --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml @@ -0,0 +1,61 @@ +// Output / input device picker. Writing Pipewire.preferredDefaultAudioSink is +// what actually moves audio; the default* properties are read-only reflections +// of what the session manager settled on. + +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import qs.config + +Item { + id: root + + // false → input devices. + property bool output: true + property alias maxHeight: list.maxHeight + + implicitHeight: list.implicitHeight + + readonly property var nodes: { + return Pipewire.nodes.values.filter(n => { + if (n.isStream) + return false; + // Sources have to be filtered on the type flags: !isSink also + // matches video nodes (webcams show up here otherwise). + return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource; + }); + } + + readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource + + function select(node): void { + if (root.output) + Pipewire.preferredDefaultAudioSink = node; + else + Pipewire.preferredDefaultAudioSource = node; + } + + ScrollColumn { + id: list + anchors.fill: parent + maxHeight: 220 + + Repeater { + model: root.nodes + + RowButton { + id: nodeRow + + required property var modelData + + width: parent.width + implicitHeight: 38 + icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic" + iconFallback: "audio-card-symbolic" + label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name + selected: nodeRow.modelData === root.current + onClicked: root.select(nodeRow.modelData) + } + } + } +} diff --git a/config/dot/quickshell/modules/quicksettings/AudioSlider.qml b/config/dot/quickshell/modules/quicksettings/AudioSlider.qml new file mode 100644 index 0000000..0de497d --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/AudioSlider.qml @@ -0,0 +1,85 @@ +// Volume row: mute button, slider, and a chevron that opens the device picker. +// +// PwObjectTracker is mandatory — without it node.audio.volume reads 0 and +// writes are dropped on the floor with no error. + +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import qs.widgets +import qs.config + +Item { + id: root + + property var node: null + // Output vs input, purely for icon selection. + property bool output: true + property bool expanded: false + + signal toggleExpanded + + implicitHeight: 32 + visible: root.node !== null + + readonly property real volume: root.node && root.node.audio ? root.node.audio.volume : 0 + readonly property bool muted: root.node && root.node.audio ? root.node.audio.muted : false + + PwObjectTracker { + objects: root.node ? [root.node] : [] + } + + function iconName(): string { + if (root.output) { + if (root.muted || root.volume <= 0.001) + return "audio-volume-muted-symbolic"; + if (root.volume < 0.34) + return "audio-volume-low-symbolic"; + if (root.volume < 0.67) + return "audio-volume-medium-symbolic"; + return "audio-volume-high-symbolic"; + } + return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic"; + } + + IconButton { + id: muteButton + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + size: 30 + iconSize: 17 + icon: root.iconName() + iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic" + onClicked: { + if (root.node && root.node.audio) + root.node.audio.muted = !root.node.audio.muted; + } + } + + ValueSlider { + anchors.left: muteButton.right + anchors.leftMargin: 6 + anchors.right: chevron.left + anchors.rightMargin: 4 + anchors.verticalCenter: parent.verticalCenter + value: root.muted ? 0 : root.volume + onMoved: v => { + if (!root.node || !root.node.audio) + return; + // Nudging the slider is also how you unmute, same as GNOME. + root.node.audio.muted = false; + root.node.audio.volume = v; + } + } + + IconButton { + id: chevron + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + size: 28 + iconSize: 14 + icon: root.expanded ? "pan-up-symbolic" : "pan-down-symbolic" + iconFallback: "go-next-symbolic" + onClicked: root.toggleExpanded() + } +} diff --git a/config/dot/quickshell/modules/quicksettings/BluetoothList.qml b/config/dot/quickshell/modules/quicksettings/BluetoothList.qml new file mode 100644 index 0000000..42789fe --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/BluetoothList.qml @@ -0,0 +1,139 @@ +// Bluetooth device picker. Paired devices first (that's what you actually +// reconnect to), then whatever discovery has turned up. + +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Bluetooth +import qs.config + +Item { + id: root + + // True while the section is open. Discovery is expensive, so it only runs + // while the list is on screen — same as the GNOME panel. + property bool active: false + property alias maxHeight: list.maxHeight + + readonly property var adapter: Bluetooth.defaultAdapter + property bool discoveryOwned: false + + implicitHeight: list.implicitHeight + + readonly property var devices: { + const list = Bluetooth.devices.values.slice(); + list.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.name || "").localeCompare(b.name || ""); + }); + return list; + } + + function syncDiscovery(): void { + if (!root.adapter) + return; + const shouldDiscover = root.active && root.adapter.enabled; + if (shouldDiscover && !root.adapter.discovering) { + root.adapter.discovering = true; + root.discoveryOwned = true; + } else if (!shouldDiscover && root.discoveryOwned && root.adapter.discovering) { + root.adapter.discovering = false; + root.discoveryOwned = false; + } + } + + onActiveChanged: root.syncDiscovery() + onAdapterChanged: { + if (root.active) + root.syncDiscovery(); + } + + Component.onDestruction: { + if (root.adapter && root.discoveryOwned && root.adapter.discovering) + root.adapter.discovering = false; + } + + function stateText(device): string { + if (device.pairing) + return "Pairing…"; + switch (device.state) { + case BluetoothDeviceState.Connecting: + return "Connecting…"; + case BluetoothDeviceState.Disconnecting: + return "Disconnecting…"; + case BluetoothDeviceState.Connected: + return device.batteryAvailable ? "Connected · " + Math.round(device.battery * 100) + "%" : "Connected"; + default: + return device.paired ? "Paired" : ""; + } + } + + // Click cycles the obvious next step: pair, connect, disconnect. + function activate(device): void { + if (device.connected) + device.disconnect(); + else if (device.paired) + device.connect(); + else + device.pair(); + } + + ScrollColumn { + id: list + anchors.fill: parent + maxHeight: 300 + + Text { + width: parent.width + visible: root.devices.length === 0 + topPadding: 12 + bottomPadding: 12 + horizontalAlignment: Text.AlignHCenter + text: { + if (!root.adapter) + return "No Bluetooth adapter"; + if (!root.adapter.enabled) + return "Bluetooth is off"; + return "Searching…"; + } + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Repeater { + model: root.devices + + RowButton { + id: deviceRow + + required property var modelData + + width: parent.width + // BlueZ reports a plain freedesktop name ("audio-headphones"); + // the symbolic variant is the one that can be recoloured. + icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic" + iconFallback: "bluetooth-symbolic" + label: deviceRow.modelData.name || deviceRow.modelData.address + sublabel: root.stateText(deviceRow.modelData) + selected: deviceRow.modelData.connected + onClicked: root.activate(deviceRow.modelData) + + // Forgetting is destructive enough that it gets its own + // control rather than sharing the row click. + IconButton { + anchors.verticalCenter: parent.verticalCenter + visible: deviceRow.modelData.paired + size: 26 + iconSize: 13 + icon: "user-trash-symbolic" + iconFallback: "window-close-symbolic" + onClicked: deviceRow.modelData.forget() + } + } + } + } +} diff --git a/config/dot/quickshell/modules/quicksettings/BrightnessControl.qml b/config/dot/quickshell/modules/quicksettings/BrightnessControl.qml new file mode 100644 index 0000000..29cc4bf --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/BrightnessControl.qml @@ -0,0 +1,69 @@ +// Backlight slider, via brightnessctl. +// +// This machine drives an external DisplayPort monitor and has no backlight +// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the +// row removes itself rather than sitting there as a dead control. Probed once +// at startup — backlight devices do not appear and disappear. + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.widgets +import qs.config + +Item { + id: root + + property bool available: false + property real value: 0 + + visible: root.available + implicitHeight: root.available ? 32 : 0 + + // `-m` is the machine-readable form: name,class,current,percent,max + Process { + running: true + command: ["brightnessctl", "-m", "-c", "backlight", "-l"] + stdout: StdioCollector { + onStreamFinished: root.parseDevices(this.text) + } + } + + function parseDevices(text: string): void { + for (const line of text.trim().split("\n")) { + const fields = line.split(","); + if (fields.length < 5 || fields[1] !== "backlight") + continue; + root.available = true; + root.value = parseInt(fields[3]) / 100; + return; + } + } + + function apply(v: real): void { + root.value = v; + // Never go fully dark: a 0% backlight looks like a broken shell. + Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]); + } + + // ValueSlider draws its own leading icon, but symbolic icons need + // recolouring to be visible — see ThemedIcon. + ThemedIcon { + id: glyph + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + size: 17 + icon: "display-brightness-symbolic" + } + + ValueSlider { + anchors.left: glyph.right + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 32 + anchors.verticalCenter: parent.verticalCenter + value: root.value + onMoved: v => root.apply(v) + } +} diff --git a/config/dot/quickshell/modules/quicksettings/IconButton.qml b/config/dot/quickshell/modules/quicksettings/IconButton.qml new file mode 100644 index 0000000..d6c3a8f --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/IconButton.qml @@ -0,0 +1,53 @@ +// A small circular icon button — mute toggles, chevrons, per-row actions. + +import QtQuick +import Quickshell +import qs.config +import qs.widgets + +Rectangle { + id: root + + property string icon: "" + property string iconFallback: "dialog-information-symbolic" + property int size: 28 + property int iconSize: 16 + property color tint: Theme.fg + + signal clicked + + implicitWidth: root.size + implicitHeight: root.size + radius: root.size / 2 + border.width: 0 + + color: { + if (mouse.pressed) + return Theme.alpha(Theme.fg, Theme.activeAlpha); + if (mouse.containsMouse) + return Theme.alpha(Theme.fg, Theme.hoverAlpha); + return "transparent"; + } + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + ThemedIcon { + anchors.centerIn: parent + size: root.iconSize + icon: root.icon + iconFallback: root.iconFallback + tint: root.tint + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/config/dot/quickshell/modules/quicksettings/QuickSettings.qml b/config/dot/quickshell/modules/quicksettings/QuickSettings.qml new file mode 100644 index 0000000..5a1652c --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/QuickSettings.qml @@ -0,0 +1,101 @@ +// The quick settings popover — GNOME's system menu (Super+S). +// +// A layer-shell surface rather than a PopupWindow because it needs keyboard +// focus for the wifi password field, and because the bar it hangs from is +// owned by a different module. + +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.config +import qs.services +import qs.widgets + +PanelWindow { + id: root + + visible: ShellState.quickSettingsOpen + color: "transparent" + + // Hangs off the top-right corner, clear of the floating bar. + anchors.top: true + anchors.right: true + margins.top: Theme.barHeight + Theme.barGap * 2 + margins.right: Theme.barSideMargin + exclusiveZone: 0 + + implicitWidth: Theme.popoverWidth + implicitHeight: panel.implicitHeight + + // The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua. + WlrLayershell.namespace: "qs-popover-quicksettings" + WlrLayershell.layer: WlrLayer.Overlay + // OnDemand rather than Exclusive: the panel must be able to take typing for + // the wifi password field, but must not steal it while merely visible. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + + onVisibleChanged: { + if (root.visible) + openAnim.restart(); + else + panel.reset(); + } + + // Closes the panel when a click lands anywhere else on the desktop. + HyprlandFocusGrab { + windows: [root] + active: root.visible + onCleared: ShellState.close() + } + + Rectangle { + id: surface + anchors.fill: parent + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + // The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px + // border so the two don't fight for the same row of pixels. + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + transform: Scale { + id: openScale + origin.x: surface.width + origin.y: 0 + xScale: 1 + yScale: 1 + } + + // Escape reaches here by propagating up from whatever holds focus + // (usually the password field), so it works in every sub-state. + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: ShellState.close() + + QuickSettingsPanel { + id: panel + anchors.fill: parent + } + } + } + + NumberAnimation { + id: openAnim + target: openScale + property: "yScale" + from: 0.94 + to: 1.0 + duration: Theme.durNormal + easing.type: Easing.OutCubic + } +} diff --git a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml new file mode 100644 index 0000000..1bfb32a --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml @@ -0,0 +1,296 @@ +// The contents of the quick settings popover — GNOME's system menu, in order: +// toggle grid, detail lists, sliders, footer. +// +// Kept separate from QuickSettings.qml (the PanelWindow) so it can be dropped +// into a FloatingWindow for testing: layer-shell surfaces cannot map outside a +// wlroots compositor. + +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Networking +import Quickshell.Bluetooth +import Quickshell.Services.Pipewire +import qs.config +import qs.services +import qs.widgets + +Item { + id: root + + implicitWidth: Theme.popoverWidth + implicitHeight: content.implicitHeight + Theme.popoverPadding * 2 + + // "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is + // open at a time, so the panel never grows past the screen. + property string expandedSection: "" + + function expand(name: string): void { + root.expandedSection = root.expandedSection === name ? "" : name; + } + + // Collapse everything — called when the popover closes so it reopens in a + // known state. + function reset(): void { + root.expandedSection = ""; + } + + // ── System state ──────────────────────────────────────────────────────── + + readonly property var wifiDevice: { + for (const device of Networking.devices.values) { + if (device.type === DeviceType.Wifi) + return device; + } + return null; + } + + readonly property var wiredDevice: { + for (const device of Networking.devices.values) { + if (device.type === DeviceType.Wired && device.connected) + return device; + } + return null; + } + + readonly property string ssid: { + if (!root.wifiDevice || !root.wifiDevice.networks) + return ""; + for (const network of root.wifiDevice.networks.values) { + if (network.connected) + return network.name; + } + return ""; + } + + readonly property var btAdapter: Bluetooth.defaultAdapter + + readonly property string btSummary: { + if (!root.btAdapter) + return "Unavailable"; + if (!root.btAdapter.enabled) + return "Off"; + for (const device of Bluetooth.devices.values) { + if (device.connected) + return device.name || device.address; + } + return "On"; + } + + readonly property int cellWidth: (content.width - Theme.itemSpacing) / 2 + + Column { + id: content + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.popoverPadding + spacing: Theme.itemSpacing + + // ── Toggle grid ───────────────────────────────────────────────────── + Grid { + columns: 2 + spacing: Theme.itemSpacing + + Toggle { + width: root.cellWidth + icon: Networking.wifiEnabled ? "network-wireless-symbolic" : "network-wireless-offline-symbolic" + label: "Wi-Fi" + active: Networking.wifiEnabled + enabled: Networking.wifiHardwareEnabled + sublabel: { + if (!Networking.wifiEnabled) + return "Off"; + return root.ssid !== "" ? root.ssid : "Not connected"; + } + onToggled: Networking.wifiEnabled = !Networking.wifiEnabled + onExpanded: root.expand("wifi") + } + + Toggle { + width: root.cellWidth + icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic" + label: "Bluetooth" + active: root.btAdapter !== null && root.btAdapter.enabled + enabled: root.btAdapter !== null + sublabel: root.btSummary + onToggled: { + if (root.btAdapter) + root.btAdapter.enabled = !root.btAdapter.enabled; + } + onExpanded: root.expand("bluetooth") + } + + Toggle { + width: root.cellWidth + icon: "preferences-desktop-screensaver-symbolic" + label: "Caffeine" + sublabel: Caffeine.enabled ? "Staying awake" : "Off" + active: Caffeine.enabled + onToggled: Caffeine.toggle() + } + + Toggle { + width: root.cellWidth + icon: "night-light-symbolic" + label: "Night Light" + active: NightLight.active + sublabel: { + if (NightLight.automatic) + return NightLight.active ? "Scheduled · on" : "Scheduled"; + return NightLight.active ? NightLight.temperature + "K" : "Off"; + } + onToggled: NightLight.toggle() + // No detail list to open, so the secondary action flips the + // schedule on and off instead. + onExpanded: NightLight.automatic = !NightLight.automatic + } + } + + RowButton { + width: content.width + icon: "preferences-system-time-symbolic" + iconFallback: "appointment-soon-symbolic" + label: FocusSession.active ? "Focus · " + FocusSession.workspaceLabel : "Start focus session" + sublabel: FocusSession.active ? FocusSession.remainingText + (FocusSession.paused ? " · paused" : " remaining") : Settings.focusDurationMinutes + " minutes · current workspace" + selected: FocusSession.active + onClicked: { + ShellState.close(); + FocusSession.reveal(); + } + } + + // ── Detail lists ──────────────────────────────────────────────────── + Section { + width: content.width + expanded: root.expandedSection === "wifi" + + WifiList { + anchors.left: parent.left + anchors.right: parent.right + device: root.wifiDevice + active: root.expandedSection === "wifi" + } + } + + Section { + width: content.width + expanded: root.expandedSection === "bluetooth" + + BluetoothList { + anchors.left: parent.left + anchors.right: parent.right + active: root.expandedSection === "bluetooth" + } + } + + Rectangle { + width: content.width + height: 1 + color: Theme.alpha(Theme.fg, 0.1) + } + + // ── Sliders ───────────────────────────────────────────────────────── + AudioSlider { + width: content.width + node: Pipewire.defaultAudioSink + output: true + expanded: root.expandedSection === "sink" + onToggleExpanded: root.expand("sink") + } + + Section { + width: content.width + expanded: root.expandedSection === "sink" + + AudioDeviceList { + anchors.left: parent.left + anchors.right: parent.right + output: true + } + } + + AudioSlider { + width: content.width + node: Pipewire.defaultAudioSource + output: false + expanded: root.expandedSection === "source" + onToggleExpanded: root.expand("source") + } + + Section { + width: content.width + expanded: root.expandedSection === "source" + + AudioDeviceList { + anchors.left: parent.left + anchors.right: parent.right + output: false + } + } + + BrightnessControl { + width: content.width + } + + Rectangle { + width: content.width + height: 1 + color: Theme.alpha(Theme.fg, 0.1) + } + + // ── Footer ────────────────────────────────────────────────────────── + Item { + width: content.width + height: 36 + + ThemedIcon { + id: avatar + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.verticalCenter: parent.verticalCenter + size: 20 + icon: "avatar-default-symbolic" + iconFallback: "user-info-symbolic" + } + + Text { + anchors.left: avatar.right + anchors.leftMargin: 10 + anchors.right: actions.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: Quickshell.env("USER") || "user" + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + + Row { + id: actions + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + IconButton { + size: 32 + iconSize: 17 + icon: "preferences-system-symbolic" + onClicked: { + ShellState.close(); + ShellState.openSettings("home"); + } + } + + IconButton { + size: 32 + iconSize: 17 + icon: "system-shutdown-symbolic" + onClicked: ShellState.open("powermenu") + } + } + } + } +} diff --git a/config/dot/quickshell/modules/quicksettings/RowButton.qml b/config/dot/quickshell/modules/quicksettings/RowButton.qml new file mode 100644 index 0000000..6938edf --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/RowButton.qml @@ -0,0 +1,108 @@ +// One line in a detail list (a wifi network, a bluetooth device, an output +// device). Icon, two lines of text, and an optional trailing slot. + +import QtQuick +import Quickshell +import qs.config +import qs.widgets + +Rectangle { + id: root + + property string icon: "" + // Used when `icon` is not in the active icon theme — several of the names + // GNOME uses only exist in Adwaita, and iconPath() returns "" otherwise. + property string iconFallback: "dialog-information-symbolic" + property string label: "" + property string sublabel: "" + property bool selected: false + property bool dimmed: false + + default property alias trailing: trailingRow.data + + signal clicked + + implicitHeight: 44 + radius: Theme.cardRadius - 2 + border.width: 0 + + color: { + if (root.selected) + return Theme.alpha(Theme.accent, Theme.activeAlpha); + if (mouse.pressed) + return Theme.alpha(Theme.fg, Theme.activeAlpha); + if (mouse.containsMouse) + return Theme.alpha(Theme.fg, Theme.hoverAlpha); + return "transparent"; + } + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + opacity: root.dimmed ? 0.5 : 1.0 + + // Declared first so anything in the trailing slot stacks above it and can + // take its own clicks (a per-row disconnect button, say). + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } + + ThemedIcon { + id: leading + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + size: 20 + icon: root.icon + iconFallback: root.iconFallback + tint: root.selected ? Theme.accent : Theme.fg + visible: root.icon !== "" + } + + Column { + anchors.left: leading.visible ? leading.right : parent.left + anchors.leftMargin: leading.visible ? 10 : 12 + anchors.right: trailingRow.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + width: parent.width + text: root.label + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: root.selected ? Font.DemiBold : Font.Normal + } + + Text { + width: parent.width + visible: root.sublabel !== "" + text: root.sublabel + color: Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + // Full-height rather than vertically centred so slot contents can anchor + // their own verticalCenter to it without a size loop. + Row { + id: trailingRow + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.top: parent.top + anchors.bottom: parent.bottom + spacing: 6 + } +} diff --git a/config/dot/quickshell/modules/quicksettings/ScrollColumn.qml b/config/dot/quickshell/modules/quicksettings/ScrollColumn.qml new file mode 100644 index 0000000..7b2ab09 --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/ScrollColumn.qml @@ -0,0 +1,42 @@ +// A column that grows to fit its contents up to `maxHeight`, then scrolls. +// Used by every detail list in the panel so they all overflow the same way. + +import QtQuick +import qs.config + +Item { + id: root + + property int maxHeight: 300 + property alias spacing: column.spacing + default property alias content: column.data + + implicitHeight: Math.min(column.implicitHeight, root.maxHeight) + + Flickable { + id: flick + anchors.fill: parent + contentHeight: column.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: column + width: flick.width + spacing: 2 + } + } + + // Static indicator — no fade timers, nothing repainting while idle. It is + // only there so a clipped list doesn't look like the whole list. + Rectangle { + anchors.right: parent.right + width: 3 + radius: 1.5 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.25) + visible: flick.contentHeight > flick.height + 1 + height: Math.max(24, flick.height * (flick.height / Math.max(1, flick.contentHeight))) + y: (flick.height - height) * (flick.contentY / Math.max(1, flick.contentHeight - flick.height)) + } +} diff --git a/config/dot/quickshell/modules/quicksettings/Section.qml b/config/dot/quickshell/modules/quicksettings/Section.qml new file mode 100644 index 0000000..b50a230 --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/Section.qml @@ -0,0 +1,38 @@ +// An expander: collapses to zero height and animates open, GNOME-style. +// +// The child is measured at its natural size and the *section* animates; the +// child itself never re-layouts, so opening a 20-row wifi list costs one +// height animation rather than twenty. + +import QtQuick +import qs.config + +Item { + id: root + + property bool expanded: false + default property alias content: holder.data + + clip: true + // Column/ColumnLayout skip invisible children, so this also collapses the + // surrounding spacing when closed. + visible: implicitHeight > 0 + + implicitHeight: root.expanded ? holder.implicitHeight : 0 + + Behavior on implicitHeight { + NumberAnimation { + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + } + + Item { + id: holder + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: childrenRect.height + height: implicitHeight + } +} diff --git a/config/dot/quickshell/modules/quicksettings/WifiList.qml b/config/dot/quickshell/modules/quicksettings/WifiList.qml new file mode 100644 index 0000000..cf7e907 --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/WifiList.qml @@ -0,0 +1,274 @@ +// The wifi picker. This is the reason the quick settings panel exists: the +// user must never have to drop to nmcli to join a network. +// +// Everything here goes through Quickshell.Networking (NetworkManager over +// DBus) — no shelling out. + +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Networking +import qs.config +import qs.widgets + +Item { + id: root + + // The WifiDevice, or null while NetworkManager is still enumerating. + property var device: null + + // True while the section is open. Drives the NM scan request so we are not + // burning radio time scanning for a list nobody is looking at. + property bool active: false + + property alias maxHeight: list.maxHeight + + // SSID whose inline password field is open, and the last failure. + property string passwordFor: "" + property string failedSsid: "" + property string failedText: "" + + implicitHeight: list.implicitHeight + + // GNOME's ordering: the current network, then saved ones, then by signal. + readonly property var networks: { + if (!root.device || !root.device.networks) + return []; + const list = root.device.networks.values.slice(); + list.sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + if (a.known !== b.known) + return a.known ? -1 : 1; + return b.signalStrength - a.signalStrength; + }); + return list; + } + + function syncScanner(): void { + if (root.device) + root.device.scannerEnabled = root.active; + } + + onActiveChanged: root.syncScanner() + onDeviceChanged: root.syncScanner() + Component.onCompleted: root.syncScanner() + Component.onDestruction: { + if (root.device) + root.device.scannerEnabled = false; + } + + function signalIcon(strength: real): string { + if (strength >= 0.8) + return "network-wireless-signal-excellent-symbolic"; + if (strength >= 0.55) + return "network-wireless-signal-good-symbolic"; + if (strength >= 0.3) + return "network-wireless-signal-ok-symbolic"; + if (strength > 0.05) + return "network-wireless-signal-weak-symbolic"; + return "network-wireless-signal-none-symbolic"; + } + + // NM's failure reasons are useful but not English. + function failureText(reason): string { + switch (reason) { + case ConnectionFailReason.NoSecrets: + return "Wrong password"; + case ConnectionFailReason.WifiAuthTimeout: + return "Authentication timed out"; + case ConnectionFailReason.WifiNetworkLost: + return "Network out of range"; + default: + return "Couldn't connect"; + } + } + + function isSecured(network): bool { + return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe && network.security !== WifiSecurityType.Unknown; + } + + // Left click on a row. Saved and open networks connect immediately; + // anything else reveals the password field rather than failing silently. + function activate(network): void { + root.failedSsid = ""; + if (network.connected) + return; + if (network.known || !root.isSecured(network)) { + root.passwordFor = ""; + network.connect(); + return; + } + root.passwordFor = root.passwordFor === network.name ? "" : network.name; + } + + ScrollColumn { + id: list + anchors.fill: parent + maxHeight: 340 + + Item { + width: parent.width + height: 30 + visible: root.networks.length > 0 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "Available networks" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + } + + Text { + width: parent.width + visible: root.networks.length === 0 + topPadding: 12 + bottomPadding: 12 + horizontalAlignment: Text.AlignHCenter + text: root.device ? (Networking.wifiEnabled ? "Scanning…" : "Wi-Fi is off") : "No Wi-Fi adapter" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Repeater { + model: root.networks + + Column { + id: entry + + required property var modelData + + width: parent.width + spacing: 0 + + readonly property bool secured: root.isSecured(entry.modelData) + readonly property string ssid: entry.modelData.name + + // Reported by NM when a connection attempt gives up. Bad + // passwords land here, so re-open the field to retry. + Connections { + target: entry.modelData + function onConnectionFailed(reason) { + root.failedSsid = entry.ssid; + root.failedText = root.failureText(reason); + if (entry.secured && !entry.modelData.known) + root.passwordFor = entry.ssid; + } + } + + RowButton { + width: parent.width + icon: root.signalIcon(entry.modelData.signalStrength) + iconFallback: "network-wireless-symbolic" + label: entry.ssid !== "" ? entry.ssid : "Hidden network" + selected: entry.modelData.connected + sublabel: { + if (entry.modelData.stateChanging) + return "Connecting…"; + if (entry.modelData.connected) + return "Connected"; + if (root.failedSsid === entry.ssid) + return root.failedText; + if (entry.modelData.known) + return "Saved"; + return ""; + } + onClicked: root.activate(entry.modelData) + + ThemedIcon { + anchors.verticalCenter: parent.verticalCenter + size: 14 + visible: entry.secured + tint: Theme.fgMuted + icon: "network-wireless-encrypted-symbolic" + iconFallback: "changes-prevent-symbolic" + } + + IconButton { + visible: entry.modelData.connected + size: 26 + iconSize: 13 + icon: "window-close-symbolic" + onClicked: entry.modelData.disconnect() + } + } + + Section { + id: pskSection + width: parent.width + expanded: root.passwordFor === entry.ssid + + onExpandedChanged: { + if (pskSection.expanded) + pskInput.forceActiveFocus(); + else + pskInput.text = ""; + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.rightMargin: 10 + height: 42 + y: 4 + radius: Theme.cardRadius - 2 + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.4) + color: Theme.alpha(Theme.fg, 0.08) + + TextInput { + id: pskInput + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: sendButton.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + echoMode: TextInput.Password + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.5) + selectedTextColor: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + clip: true + + onAccepted: { + if (pskInput.text.length > 0) { + root.failedSsid = ""; + entry.modelData.connectWithPsk(pskInput.text); + root.passwordFor = ""; + } + } + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + visible: pskInput.text === "" + text: "Password" + color: Theme.fgMuted + font: pskInput.font + } + } + + IconButton { + id: sendButton + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.verticalCenter: parent.verticalCenter + size: 30 + iconSize: 15 + icon: "go-next-symbolic" + onClicked: pskInput.accepted() + } + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/AboutPage.qml b/config/dot/quickshell/modules/settings/AboutPage.qml new file mode 100644 index 0000000..6c4871c --- /dev/null +++ b/config/dot/quickshell/modules/settings/AboutPage.qml @@ -0,0 +1,41 @@ +import QtQuick +import Quickshell +import qs.config +import qs.services + +Item { + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + Text { text: "A curated Hyprland desktop built around focus, speed, and good taste."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: "Panama Desktop" + subtitle: "Tokyo Night Moon · Prism glass · native tiling" + SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" } + SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion } + SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" } + SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false } + } + + SettingsCard { + title: "Design principles" + SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" } + SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" } + SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml new file mode 100644 index 0000000..b95151d --- /dev/null +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -0,0 +1,61 @@ +import QtQuick +import qs.config + +Item { + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: "Theme" + subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes." + SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" } + SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false } + } + + SettingsCard { + title: "Clock" + SettingRow { + label: "24-hour time" + detail: "Use 18:30 instead of 6:30 PM" + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.use24Hour; onToggled: value => DesktopPreferences.use24Hour = value } + } + SettingRow { + label: "Show seconds" + detail: "Keep a precise clock in the center of the bar" + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showSeconds; onToggled: value => DesktopPreferences.showSeconds = value } + } + SettingRow { + label: "Show weekday" + detail: "Include the abbreviated weekday before the date" + divider: false + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showWeekday; onToggled: value => DesktopPreferences.showWeekday = value } + } + } + + SettingsCard { + title: "System vitals" + subtitle: "Choose what appears beside the workspace indicator." + SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value } } + SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showMemory; onToggled: value => DesktopPreferences.showMemory = value } } + SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showGpu; onToggled: value => DesktopPreferences.showGpu = value } } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml new file mode 100644 index 0000000..9e4c549 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -0,0 +1,88 @@ +import QtQuick +import Quickshell.Networking +import Quickshell.Bluetooth +import qs.config +import qs.services +import qs.modules.quicksettings + +Item { + id: root + + readonly property var wifiDevice: { + for (const device of Networking.devices.values) { + if (device.type === DeviceType.Wifi) + return device; + } + return null; + } + readonly property var bluetoothAdapter: Bluetooth.defaultAdapter + + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: "Wi‑Fi" + subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off" + SettingRow { + label: "Wi‑Fi" + detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found" + controlWidth: 48 + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: Networking.wifiEnabled + enabled: Networking.wifiHardwareEnabled + onToggled: value => Networking.wifiEnabled = value + } + } + WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 } + SettingRow { + label: "Advanced network settings" + detail: "VPN, wired profiles, DNS, and connection details" + divider: false + controlWidth: 104 + SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") } + } + } + + SettingsCard { + title: "Bluetooth" + subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off" + SettingRow { + label: "Bluetooth" + detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found" + controlWidth: 48 + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: root.bluetoothAdapter?.enabled ?? false + enabled: root.bluetoothAdapter !== null + onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; } + } + } + BluetoothList { width: parent.width; active: true; maxHeight: 220 } + SettingRow { + label: "Advanced Bluetooth settings" + detail: "Device details and system-level options" + divider: false + controlWidth: 104 + SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml new file mode 100644 index 0000000..71009b0 --- /dev/null +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -0,0 +1,55 @@ +import QtQuick +import qs.config + +Item { + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { text: "Desktop & Dock"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + Text { text: "Keep the shell instant, spatial, and out of your way."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: "Dock" + SettingRow { + label: "Automatically hide the Dock" + detail: "Reveal it at the bottom edge when a workspace is occupied" + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.dockAutohide; onToggled: value => DesktopPreferences.dockAutohide = value } + } + SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.dockRevealDelayMs === 0 ? "Instant" : `${DesktopPreferences.dockRevealDelayMs} ms` } + SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.dockHideDelayMs} ms`; divider: false } + } + + SettingsCard { + title: "Window layout" + subtitle: "Panama follows the Forge mental model with native Hyprland tiling." + SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" } + SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" } + SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false } + } + + SettingsCard { + title: "Reset" + subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults." + SettingRow { + label: "Desktop preferences" + detail: "Your pinned applications and files are not changed" + divider: false + controlWidth: 122 + SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml new file mode 100644 index 0000000..d3b38ab --- /dev/null +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -0,0 +1,97 @@ +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Item { + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: SystemSettings.monitorName || "Active display" + subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}` + SettingRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" } + SettingRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false } + } + + SettingsCard { + title: "Gaming display policy" + subtitle: "These values apply immediately and are restored when Panama starts." + SettingRow { + label: "Game-aware HDR" + detail: "Enter HDR only for fullscreen content that requests it" + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) } + } + SettingRow { + label: "Content-aware VRR" + detail: "Enable variable refresh only for game and video content" + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) } + } + SettingRow { + label: "Direct scanout for games" + detail: "Bypass compositing only for windows classified as games" + divider: false + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) } + } + } + + SettingsCard { + title: "Night Light" + SettingRow { + label: "Warm display colors" + detail: NightLight.automatic ? "Following the evening schedule" : "Manual control" + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() } + } + SettingRow { + label: "Color temperature" + detail: `${NightLight.temperature} K` + divider: false + controlWidth: 230 + ValueSlider { + anchors.fill: parent + value: (6500 - NightLight.temperature) / 4000 + icon: "weather-clear-night-symbolic" + onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50 + } + } + } + + Rectangle { + width: parent.width + height: warningText.implicitHeight + 30 + radius: Theme.cardRadius + color: Theme.alpha(Theme.warn, 0.085) + border.width: 1 + border.color: Theme.alpha(Theme.warn, 0.22) + Text { + id: warningText + anchors.fill: parent + anchors.margins: 15 + text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games." + color: Theme.mix(Theme.fg, Theme.warn, 0.25) + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/HomePage.qml b/config/dot/quickshell/modules/settings/HomePage.qml new file mode 100644 index 0000000..caac2fb --- /dev/null +++ b/config/dot/quickshell/modules/settings/HomePage.qml @@ -0,0 +1,161 @@ +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + readonly property int openedHour: new Date().getHours() + readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening") + + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { + text: `${root.greeting}, Gabriel` + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: 27 + font.weight: Font.DemiBold + } + + Text { + text: "Your Panama desktop is configured and ready." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + bottomPadding: 6 + } + + SettingsCard { + title: SystemSettings.monitorDescription || "Active display" + subtitle: SystemSettings.monitorName || "Detecting your display…" + + Row { + width: parent.width + height: 164 + spacing: 28 + + Item { + width: parent.width * 0.47 + height: parent.height + + Rectangle { + width: Math.min(parent.width - 24, 260) + height: width * 0.64 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 5 + radius: 12 + color: Theme.alpha(Theme.bgDark, 0.94) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.34) + + Rectangle { + anchors.fill: parent + anchors.margins: 10 + radius: 7 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) } + GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) } + } + + Text { + anchors.centerIn: parent + text: SystemSettings.monitorName || "DISPLAY" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.letterSpacing: 1.5 + } + } + } + } + + Column { + width: parent.width * 0.47 + anchors.verticalCenter: parent.verticalCenter + spacing: 13 + + Text { + text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}` + color: Theme.fg + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + Text { + text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale` + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + } + Text { + text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}` + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + Text { + text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off" + color: SystemSettings.autoHdr ? Theme.ok : Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + } + } + + Row { + width: parent.width + spacing: 16 + + SettingsCard { + width: (parent.width - parent.spacing) / 2 + title: "Quiet focus" + subtitle: "Notifications and focused work" + + SettingRow { + label: "Do Not Disturb" + detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible" + divider: false + controlWidth: 48 + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: Notifs.doNotDisturb + onToggled: value => Notifs.doNotDisturb = value + } + } + } + + SettingsCard { + width: (parent.width - parent.spacing) / 2 + title: "Desktop services" + subtitle: "The essentials are running" + + SettingRow { + label: "Sync & remote access" + detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}` + divider: false + value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review" + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml new file mode 100644 index 0000000..d06c088 --- /dev/null +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -0,0 +1,79 @@ +import QtQuick +import qs.config +import qs.services + +Item { + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { text: "Notifications & Focus"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: "Notifications" + SettingRow { + label: "Do Not Disturb" + detail: "Keep notifications in the center but suppress banners" + controlWidth: 48 + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value } + } + SettingRow { label: "Notification history"; detail: "Live notifications retained by Panama"; value: `${Notifs.history.length} items` } + SettingRow { + label: "Clear notification history" + detail: "Dismiss every item currently in the notification center" + divider: false + controlWidth: 94 + SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() } + } + } + + SettingsCard { + title: "Focus sessions" + subtitle: "A focus session binds quiet mode and Caffeine to the current workspace." + SettingRow { + label: "Default duration" + detail: "Used by Super+Shift+F and Quick Settings" + controlWidth: 264 + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + Repeater { + model: [25, 45, 60, 90] + SettingsButton { + required property int modelData + text: `${modelData}m` + tone: DesktopPreferences.focusDurationMinutes === modelData ? "accent" : "normal" + onClicked: DesktopPreferences.focusDurationMinutes = modelData + } + } + } + } + SettingRow { + label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session" + detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings" + divider: false + controlWidth: 120 + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: FocusSession.active ? "Show controls" : "Start focus" + tone: FocusSession.active ? "normal" : "accent" + onClicked: FocusSession.reveal() + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml b/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml new file mode 100644 index 0000000..dd7a84d --- /dev/null +++ b/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml @@ -0,0 +1,110 @@ +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + Component.onCompleted: ScreenIntelligence.refresh() + + Timer { + id: launchDelay + interval: 180 + onTriggered: Capture.openIntelligence() + } + + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { + text: "Screen Intelligence" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: 27 + font.weight: Font.DemiBold + } + Text { + text: "Turn text and codes on screen into content you can use." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + bottomPadding: 6 + } + + SettingsCard { + title: "Read anything on screen" + subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions." + + SettingRow { + icon: "󰗊" + label: "Read screen text" + detail: "Copy, search, translate, or open detected links" + controlWidth: 160 + divider: false + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Start reading" + tone: "accent" + enabled: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady + onClicked: { + ShellState.closeSettings(); + launchDelay.restart(); + } + } + } + } + + SettingsCard { + title: "Local recognition" + subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result." + + SettingRow { + label: "Text recognition" + detail: "Tesseract with the English language model" + value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install" + } + SettingRow { + label: "QR & barcodes" + detail: "ZBar recognizes codes alongside ordinary text" + value: ScreenIntelligence.codeReady ? "Ready" : "Needs install" + } + SettingRow { + label: "Privacy" + detail: "Only Search, Translate, and Open send the selected result to another application or service" + value: "Local first" + divider: false + } + } + + SettingsCard { + title: "Shortcut" + SettingRow { + label: "Read a screen selection" + detail: "Also available as Read in the Print-screen picker" + value: "Super + Shift + S" + controlWidth: 190 + divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady + } + SettingRow { + visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady + label: "Install recognition engines" + detail: "sudo dnf install -y tesseract zbar" + value: "Required once" + divider: false + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/ServicesPage.qml b/config/dot/quickshell/modules/settings/ServicesPage.qml new file mode 100644 index 0000000..cb5bd60 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ServicesPage.qml @@ -0,0 +1,90 @@ +import QtQuick +import qs.config +import qs.services + +Item { + function status(active: bool): string { return active ? "Running" : "Stopped"; } + + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Row { + width: parent.width + Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() } + } + Text { text: "A clear view of the background tools that make the desktop feel complete."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: "Your services" + SettingRow { + label: "Nextcloud" + detail: "File synchronization and tray status" + controlWidth: 190 + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize } + SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") } + } + } + SettingRow { + label: "RustDesk" + detail: "Remote access through the enabled system service" + controlWidth: 190 + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize } + SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") } + } + } + SettingRow { + label: "KDE Connect" + detail: "Phone pairing, clipboard, files, and remote controls" + divider: false + controlWidth: 190 + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize } + SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") } + } + } + } + + SettingsCard { + title: "Desktop foundation" + SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) } + SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) } + SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false } + } + + SettingsCard { + title: "Fedora system settings" + subtitle: "These remain owned by trusted system services and GNOME's mature panels." + SettingRow { + label: "Network, Bluetooth, printers, users, and accounts" + detail: "GNOME Settings remains searchable from the launcher too" + divider: false + controlWidth: 122 + SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingRow.qml b/config/dot/quickshell/modules/settings/SettingRow.qml new file mode 100644 index 0000000..b5d4222 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingRow.qml @@ -0,0 +1,88 @@ +import QtQuick +import qs.config + +Item { + id: root + + default property alias trailingData: trailing.data + property string icon: "" + property string label: "" + property string detail: "" + property string value: "" + property bool divider: true + property int controlWidth: 150 + + width: parent ? parent.width : 620 + implicitHeight: Math.max(56, copy.implicitHeight + 20) + + Text { + id: iconLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: root.icon === "" ? 0 : 28 + visible: root.icon !== "" + text: root.icon + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: 17 + horizontalAlignment: Text.AlignHCenter + } + + Column { + id: copy + anchors.left: iconLabel.visible ? iconLabel.right : parent.left + anchors.leftMargin: iconLabel.visible ? 10 : 0 + anchors.right: trailing.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.label + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + visible: root.detail !== "" + text: root.detail + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + + Item { + id: trailing + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: root.controlWidth + height: 32 + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.value !== "" + text: root.value + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + } + } + + Rectangle { + anchors.left: copy.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + visible: root.divider + color: Theme.alpha(Theme.fg, 0.065) + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsButton.qml b/config/dot/quickshell/modules/settings/SettingsButton.qml new file mode 100644 index 0000000..f50b4b0 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsButton.qml @@ -0,0 +1,42 @@ +import QtQuick +import qs.config + +Rectangle { + id: root + + property string text: "" + property string tone: "normal" + property bool enabled: true + signal clicked + + implicitWidth: label.implicitWidth + 22 + implicitHeight: 31 + radius: 9 + opacity: enabled ? 1 : 0.45 + color: { + if (tone === "accent") + return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent; + return mouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075); + } + border.width: tone === "accent" ? 0 : 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + Text { + id: label + anchors.centerIn: parent + text: root.text + color: root.tone === "accent" ? Theme.bgDark : Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: mouse + anchors.fill: parent + enabled: root.enabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsCard.qml b/config/dot/quickshell/modules/settings/SettingsCard.qml new file mode 100644 index 0000000..b07c7ad --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsCard.qml @@ -0,0 +1,57 @@ +import QtQuick +import qs.config +import qs.widgets + +Rectangle { + id: root + + default property alias contentData: body.data + property string title: "" + property string subtitle: "" + + width: parent ? parent.width : 680 + implicitHeight: body.implicitHeight + 30 + radius: Theme.cardRadius + 2 + color: Theme.alpha(Theme.bgPanel, 0.72) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.07) + + PrismEdge { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + inset: root.radius + opacity: 0.36 + } + + Column { + id: body + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 15 + spacing: 0 + + Text { + width: parent.width + visible: root.title !== "" + text: root.title + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + bottomPadding: root.subtitle === "" ? 10 : 3 + } + + Text { + width: parent.width + visible: root.subtitle !== "" + text: root.subtitle + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + bottomPadding: 12 + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml new file mode 100644 index 0000000..cc63829 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -0,0 +1,148 @@ +import QtQuick +import qs.config +import qs.services + +Rectangle { + id: root + + property var hostWindow: null + + color: Theme.bg + radius: 18 + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + clip: true + + SettingsSidebar { + id: sidebar + anchors.left: parent.left + anchors.top: titlebar.bottom + anchors.bottom: parent.bottom + selectedPage: ShellState.settingsPage + onPageRequested: page => ShellState.openSettings(page) + } + + Rectangle { + id: titlebar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 48 + color: Theme.alpha(Theme.bgDark, 0.97) + border.width: 0 + + Text { + anchors.left: parent.left + anchors.leftMargin: 18 + anchors.verticalCenter: parent.verticalCenter + text: "Panama Settings" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + + Row { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + Rectangle { + width: 28; height: 28; radius: 9 + color: minMouse.containsMouse ? Theme.alpha(Theme.fg, 0.12) : Theme.alpha(Theme.fg, 0) + Text { anchors.centerIn: parent; text: "—"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize } + MouseArea { id: minMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (root.hostWindow) root.hostWindow.minimized = true } + } + + Rectangle { + width: 28; height: 28; radius: 9 + color: closeMouse.containsMouse ? Theme.alpha(Theme.danger, 0.17) : Theme.alpha(Theme.fg, 0) + Text { anchors.centerIn: parent; text: "×"; color: closeMouse.containsMouse ? Theme.danger : Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: 18 } + MouseArea { id: closeMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: ShellState.closeSettings() } + } + } + + DragHandler { + target: null + onActiveChanged: if (active && root.hostWindow) root.hostWindow.startSystemMove() + } + } + + Rectangle { + anchors.left: sidebar.right + anchors.top: titlebar.bottom + anchors.bottom: parent.bottom + width: 1 + color: Theme.alpha(Theme.fg, 0.06) + } + + Loader { + id: pageLoader + anchors.left: sidebar.right + anchors.right: parent.right + anchors.top: titlebar.bottom + anchors.bottom: parent.bottom + sourceComponent: { + switch (ShellState.settingsPage) { + case "appearance": return appearancePage; + case "displays": return displaysPage; + case "connectivity": return connectivityPage; + case "desktop": return desktopPage; + case "sound": return soundPage; + case "notifications": return notificationsPage; + case "screen-intelligence": return screenIntelligencePage; + case "shortcuts": return shortcutsPage; + case "services": return servicesPage; + case "about": return aboutPage; + default: return homePage; + } + } + } + + Rectangle { + anchors.right: parent.right + anchors.rightMargin: 22 + anchors.bottom: parent.bottom + anchors.bottomMargin: 20 + width: Math.min(460, errorCopy.implicitWidth + 34) + height: 42 + radius: 12 + visible: SystemSettings.lastError !== "" + color: Theme.mix(Theme.bgPopover, Theme.danger, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.danger, 0.28) + + Text { + id: errorCopy + anchors.centerIn: parent + text: SystemSettings.lastError + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: SystemSettings.lastError = "" + } + } + + Component { id: homePage; HomePage {} } + Component { id: appearancePage; AppearancePage {} } + Component { id: displaysPage; DisplaysPage {} } + Component { id: connectivityPage; ConnectivityPage {} } + Component { id: desktopPage; DesktopPage {} } + Component { id: soundPage; SoundPage {} } + Component { id: notificationsPage; NotificationsPage {} } + Component { id: screenIntelligencePage; ScreenIntelligencePage {} } + Component { id: shortcutsPage; ShortcutsPage {} } + Component { id: servicesPage; ServicesPage {} } + Component { id: aboutPage; AboutPage {} } + + Shortcut { + sequence: "Escape" + onActivated: ShellState.closeSettings() + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml new file mode 100644 index 0000000..c665334 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -0,0 +1,191 @@ +import QtQuick +import qs.config + +Rectangle { + id: root + + property string selectedPage: "home" + property string query: searchInput.text.trim().toLowerCase() + signal pageRequested(string page) + + readonly property var destinations: [ + { page: "home", label: "Home", icon: "\u{F02DC}" }, + { page: "appearance", label: "Appearance", icon: "\u{F0E0D}" }, + { page: "displays", label: "Displays", icon: "\u{F0379}" }, + { page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" }, + { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" }, + { page: "sound", label: "Sound", icon: "\u{F057E}" }, + { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, + { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, + { page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" }, + { page: "services", label: "Startup & Services", icon: "\u{F0493}" }, + { page: "about", label: "About Panama", icon: "\u{F02FD}" } + ] + + width: 272 + color: Theme.alpha(Theme.bgDark, 0.96) + border.width: 0 + + Column { + anchors.fill: parent + anchors.margins: 18 + spacing: 12 + + Text { + text: "PANAMA" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + font.letterSpacing: 2.2 + } + + Rectangle { + width: parent.width + height: 36 + radius: 10 + color: Theme.alpha(Theme.fg, searchArea.containsMouse || searchInput.activeFocus ? 0.105 : 0.065) + border.width: 1 + border.color: searchInput.activeFocus ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.055) + + Text { + anchors.left: parent.left + anchors.leftMargin: 11 + anchors.verticalCenter: parent.verticalCenter + text: "\u{F0349}" + color: Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 14 + } + + TextInput { + id: searchInput + anchors.left: parent.left + anchors.leftMargin: 35 + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + color: Theme.fg + selectionColor: Theme.accent + selectedTextColor: Theme.bgDark + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + clip: true + + Text { + anchors.fill: parent + visible: !searchInput.text && !searchInput.activeFocus + text: "Search settings" + color: Theme.fgMuted + font: searchInput.font + verticalAlignment: Text.AlignVCenter + } + } + + MouseArea { + id: searchArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + onClicked: searchInput.forceActiveFocus() + } + } + + Column { + width: parent.width + spacing: 4 + + Repeater { + model: root.destinations.filter(item => root.query === "" || item.label.toLowerCase().includes(root.query)) + + Rectangle { + id: navItem + required property var modelData + width: parent.width + height: 40 + radius: 10 + color: modelData.page === root.selectedPage + ? Theme.alpha(Theme.accent, 0.17) + : (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0)) + border.width: modelData.page === root.selectedPage ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.26) + + Rectangle { + width: 2 + height: 18 + radius: 1 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + visible: navItem.modelData.page === root.selectedPage + gradient: Gradient { + GradientStop { position: 0; color: Theme.accent } + GradientStop { position: 1; color: Theme.accentSecondary } + } + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.verticalCenter: parent.verticalCenter + width: 25 + text: navItem.modelData.icon + color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 15 + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 47 + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + text: navItem.modelData.label + color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + + MouseArea { + id: navMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.pageRequested(navItem.modelData.page) + } + } + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 54 + color: Theme.alpha(Theme.bg, 0.35) + border.width: 0 + + Rectangle { + width: 7 + height: 7 + radius: 4 + anchors.left: parent.left + anchors.leftMargin: 19 + anchors.verticalCenter: parent.verticalCenter + color: Theme.ok + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 36 + anchors.verticalCenter: parent.verticalCenter + text: "Panama desktop is healthy" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsToggle.qml b/config/dot/quickshell/modules/settings/SettingsToggle.qml new file mode 100644 index 0000000..427d32d --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsToggle.qml @@ -0,0 +1,40 @@ +import QtQuick +import qs.config + +Rectangle { + id: root + + property bool checked: false + property bool enabled: true + signal toggled(bool checked) + + implicitWidth: 42 + implicitHeight: 24 + radius: height / 2 + color: checked ? Theme.accent : Theme.alpha(Theme.fg, 0.13) + opacity: enabled ? 1 : 0.45 + border.width: 1 + border.color: checked ? Theme.alpha(Theme.accentAlt, 0.55) : Theme.alpha(Theme.fg, 0.08) + + Behavior on color { ColorAnimation { duration: Theme.durFast } } + + Rectangle { + width: 18 + height: 18 + radius: 9 + x: root.checked ? root.width - width - 3 : 3 + anchors.verticalCenter: parent.verticalCenter + color: root.checked ? Theme.bgDark : Theme.fgDim + border.width: 0 + Behavior on x { + NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onClicked: root.toggled(!root.checked) + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsWindow.qml b/config/dot/quickshell/modules/settings/SettingsWindow.qml new file mode 100644 index 0000000..86bf906 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsWindow.qml @@ -0,0 +1,29 @@ +import Quickshell +import QtQuick +import qs.config +import qs.services + +FloatingWindow { + id: root + + title: "Panama Settings" + visible: ShellState.settingsOpen + implicitWidth: 1120 + implicitHeight: 760 + minimumSize: Qt.size(900, 620) + color: Theme.alpha(Theme.bg, 0) + + onVisibleChanged: { + if (visible) { + SystemSettings.refresh(); + } else if (ShellState.settingsOpen) { + // Keep Super+I deterministic after compositor close/Alt+F4. + ShellState.closeSettings(); + } + } + + SettingsShell { + anchors.fill: parent + hostWindow: root + } +} diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml new file mode 100644 index 0000000..2978cd0 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -0,0 +1,73 @@ +import QtQuick +import qs.config +import qs.services + +Item { + id: root + readonly property var shortcuts: [ + { key: "Super + T", action: "Terminal" }, + { key: "Super + N", action: "Neovim in the current directory" }, + { key: "Super + W", action: "Web browser" }, + { key: "Super + F", action: "Files" }, + { key: "Super + I", action: "Panama Settings" }, + { key: "Super + Space", action: "Launcher" }, + { key: "Super + Grave", action: "Continuum overview" }, + { key: "Super + V", action: "Clipboard history" }, + { key: "Super + S", action: "Quick Settings" }, + { key: "Super + B", action: "Notifications" }, + { key: "Super + Shift + S", action: "Screen Intelligence" }, + { key: "Super + Shift + F", action: "Focus session" }, + { key: "Alt + H / L", action: "Previous / next workspace" }, + { key: "Alt + Shift + H / L", action: "Move window between workspaces" }, + { key: "Super + H / J / K / L", action: "Focus a tiled window" }, + { key: "Super + Shift + H / J / K / L", action: "Move a tiled window" }, + { key: "Super + Shift + X", action: "Send window to scratchpad" }, + { key: "Super + X", action: "Toggle scratchpad" }, + { key: "Print", action: "Capture, record, or read" } + ] + + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { text: "Input & Shortcuts"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + Text { text: "The Forge mental model, carried forward into native tiling."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: "Panama shortcuts" + Repeater { + model: root.shortcuts + SettingRow { + required property var modelData + required property int index + label: modelData.action + value: modelData.key + controlWidth: 210 + divider: index < root.shortcuts.length - 1 + } + } + } + + SettingsCard { + title: "Hardware input" + SettingRow { + label: "Mouse, touchpad, and keyboard devices" + detail: "Use Fedora's hardware-backed input panels" + divider: false + controlWidth: 126 + SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/SoundPage.qml b/config/dot/quickshell/modules/settings/SoundPage.qml new file mode 100644 index 0000000..b8c956e --- /dev/null +++ b/config/dot/quickshell/modules/settings/SoundPage.qml @@ -0,0 +1,53 @@ +import QtQuick +import Quickshell.Services.Pipewire +import qs.config +import qs.services +import qs.modules.quicksettings + +Item { + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { text: "Sound"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } + Text { text: "Live PipeWire output, input, and device selection."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } + + SettingsCard { + title: "Output" + subtitle: Pipewire.defaultAudioSink?.description ?? "No output device" + AudioSlider { width: parent.width; node: Pipewire.defaultAudioSink; output: true } + Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) } + AudioDeviceList { width: parent.width; output: true; maxHeight: 190 } + } + + SettingsCard { + title: "Input" + subtitle: Pipewire.defaultAudioSource?.description ?? "No input device" + AudioSlider { width: parent.width; node: Pipewire.defaultAudioSource; output: false } + Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) } + AudioDeviceList { width: parent.width; output: false; maxHeight: 160 } + } + + SettingsCard { + title: "Advanced sound" + SettingRow { + label: "Application volumes and profiles" + detail: "Open Fedora's complete sound panel" + divider: false + controlWidth: 104 + SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir new file mode 100644 index 0000000..9b0960d --- /dev/null +++ b/config/dot/quickshell/modules/settings/qmldir @@ -0,0 +1,19 @@ +module qs.modules.settings +AboutPage 1.0 AboutPage.qml +AppearancePage 1.0 AppearancePage.qml +ConnectivityPage 1.0 ConnectivityPage.qml +DesktopPage 1.0 DesktopPage.qml +DisplaysPage 1.0 DisplaysPage.qml +HomePage 1.0 HomePage.qml +NotificationsPage 1.0 NotificationsPage.qml +ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml +ServicesPage 1.0 ServicesPage.qml +SettingRow 1.0 SettingRow.qml +SettingsCard 1.0 SettingsCard.qml +SettingsButton 1.0 SettingsButton.qml +SettingsShell 1.0 SettingsShell.qml +SettingsSidebar 1.0 SettingsSidebar.qml +SettingsToggle 1.0 SettingsToggle.qml +SettingsWindow 1.0 SettingsWindow.qml +ShortcutsPage 1.0 ShortcutsPage.qml +SoundPage 1.0 SoundPage.qml diff --git a/config/dot/quickshell/modules/signals/EventCard.qml b/config/dot/quickshell/modules/signals/EventCard.qml new file mode 100644 index 0000000..3e376d8 --- /dev/null +++ b/config/dot/quickshell/modules/signals/EventCard.qml @@ -0,0 +1,134 @@ +// Quiet transient Signal Glass content. It acknowledges one meaningful state +// transition and then leaves; it is not a notification and has no history. + +import QtQuick +import qs.config +import qs.services +import qs.widgets + +Rectangle { + id: root + + required property var event + readonly property color toneColor: { + if (event?.tone === "danger") + return Theme.danger; + if (event?.tone === "warn") + return Theme.warn; + if (event?.tone === "ok") + return Theme.ok; + return Theme.accent; + } + + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, 0.86) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.09) + + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + Rectangle { + id: mark + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + width: 38 + height: 38 + radius: 19 + border.width: 0 + color: Theme.alpha(root.toneColor, 0.14) + + Text { + anchors.centerIn: parent + visible: root.event?.glyph !== "" + text: root.event?.glyph ?? "" + color: root.toneColor + font.family: Theme.fontMono + font.pixelSize: 19 + } + + ThemedIcon { + anchors.centerIn: parent + visible: root.event?.glyph === "" + size: 19 + icon: root.event?.icon ?? "dialog-information-symbolic" + tint: root.toneColor + } + } + + Column { + anchors.left: mark.right + anchors.leftMargin: 11 + anchors.right: closeButton.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: root.event?.title ?? "" + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + + Text { + width: parent.width + visible: text !== "" + text: root.event?.detail ?? "" + color: Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Rectangle { + id: closeButton + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + radius: 14 + border.width: 0 + color: closeMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent" + + ThemedIcon { + anchors.centerIn: parent + size: 14 + icon: "window-close-symbolic" + tint: Theme.fgDim + } + + MouseArea { + id: closeMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: StatusEvents.dismiss() + } + } + + MouseArea { + anchors.left: parent.left + anchors.right: closeButton.left + anchors.top: parent.top + anchors.bottom: parent.bottom + cursorShape: root.event?.actionId ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (root.event?.actionId) + StatusEvents.invoke(); + else + StatusEvents.dismiss(); + } + } +} diff --git a/config/dot/quickshell/modules/signals/FocusCard.qml b/config/dot/quickshell/modules/signals/FocusCard.qml new file mode 100644 index 0000000..37bd9af --- /dev/null +++ b/config/dot/quickshell/modules/signals/FocusCard.qml @@ -0,0 +1,169 @@ +// Rich persistent mode of Signal Glass. Dismiss hides controls only; the +// compact bar timer remains until the session ends. + +import QtQuick +import qs.config +import qs.modules.focus +import qs.services +import qs.widgets + +Rectangle { + id: root + + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, 0.88) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.1) + + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + Item { + id: primary + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + height: 54 + + Rectangle { + id: focusMark + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 38 + height: 38 + radius: 19 + border.width: 0 + color: Theme.alpha(Theme.accent, 0.16) + + Text { + anchors.centerIn: parent + text: "\u{F051F}" + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: 20 + } + } + + Column { + anchors.left: focusMark.right + anchors.leftMargin: 11 + anchors.right: remaining.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: FocusSession.workspaceLabel || "Focus session" + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: FocusSession.statusText + color: FocusSession.paused ? Theme.warn : Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Text { + id: remaining + anchors.right: closeButton.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: FocusSession.remainingText + color: FocusSession.paused ? Theme.warn : Theme.fg + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + + Rectangle { + id: closeButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + radius: 14 + border.width: 0 + color: closeMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent" + + ThemedIcon { + anchors.centerIn: parent + size: 14 + icon: "window-close-symbolic" + tint: Theme.fgDim + } + + MouseArea { + id: closeMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: FocusSession.dismiss() + } + } + + MouseArea { + anchors.left: parent.left + anchors.right: closeButton.left + anchors.top: parent.top + anchors.bottom: parent.bottom + cursorShape: Qt.PointingHandCursor + z: -1 + onClicked: { + FocusSession.activateWorkspace(); + FocusSession.dismiss(); + } + } + } + + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.bottomMargin: 12 + spacing: Theme.itemSpacing + + FocusAction { + width: (parent.width - parent.spacing * 2) / 3 + icon: FocusSession.paused ? "media-playback-start-symbolic" : "media-playback-pause-symbolic" + label: FocusSession.paused ? "Resume" : "Pause" + onActivated: FocusSession.pauseOrResume() + } + + FocusAction { + width: (parent.width - parent.spacing * 2) / 3 + icon: "view-grid-symbolic" + iconFallback: "view-app-grid-symbolic" + label: "Workspace" + onActivated: { + ShellState.openOverview(FocusSession.workspaceId); + FocusSession.dismiss(); + } + } + + FocusAction { + width: (parent.width - parent.spacing * 2) / 3 + icon: "process-stop-symbolic" + label: "End" + destructive: true + onActivated: FocusSession.end(false) + } + } +} diff --git a/config/dot/quickshell/modules/signals/SignalGlass.qml b/config/dot/quickshell/modules/signals/SignalGlass.qml new file mode 100644 index 0000000..25537b7 --- /dev/null +++ b/config/dot/quickshell/modules/signals/SignalGlass.qml @@ -0,0 +1,98 @@ +// The one Signal Glass layer surface. Transient curated events briefly take +// precedence; when they expire, an explicitly revealed focus session returns. + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services + +PanelWindow { + id: root + + property var modelData: null + readonly property var event: StatusEvents.activeEvent + readonly property string eventMonitor: root.event?.monitorName ?? Hyprland.focusedMonitor?.name ?? "" + readonly property bool eventBelongsHere: StatusEvents.active && (!root.eventMonitor || !root.modelData || root.modelData.name === root.eventMonitor) + readonly property bool focusBelongsHere: FocusSession.active && FocusSession.capsuleVisible + && (!FocusSession.monitorName || !root.modelData || root.modelData.name === FocusSession.monitorName) + readonly property bool showingEvent: root.eventBelongsHere + readonly property bool requestedVisible: root.showingEvent || root.focusBelongsHere + + screen: root.modelData + anchors.top: true + margins.top: Theme.barHeight + Theme.barGap * 2 + exclusiveZone: 0 + implicitWidth: root.showingEvent ? 388 : 438 + implicitHeight: root.showingEvent ? 76 : 132 + color: "transparent" + + WlrLayershell.namespace: "qs-signal-glass" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + property bool mapped: false + visible: root.mapped + + onRequestedVisibleChanged: { + if (root.requestedVisible) { + unmapTimer.stop(); + root.mapped = true; + entrance.restart(); + } else if (root.mapped) { + unmapTimer.restart(); + } + } + + onShowingEventChanged: { + if (root.requestedVisible) + entrance.restart(); + } + + Component.onCompleted: { + if (root.requestedVisible) + root.mapped = true; + } + + Timer { + id: unmapTimer + interval: Theme.durNormal + onTriggered: root.mapped = false + } + + Loader { + id: content + anchors.fill: parent + sourceComponent: root.showingEvent ? eventComponent : focusComponent + opacity: root.requestedVisible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } + } + + transform: Translate { + id: slide + y: root.requestedVisible ? 0 : -6 + Behavior on y { + NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } + } + } + } + + Component { + id: eventComponent + EventCard { event: root.event } + } + + Component { + id: focusComponent + FocusCard {} + } + + ParallelAnimation { + id: entrance + NumberAnimation { target: content; property: "opacity"; from: 0; to: 1; duration: Theme.durNormal; easing.type: Easing.OutCubic } + NumberAnimation { target: slide; property: "y"; from: -6; to: 0; duration: Theme.durNormal; easing.type: Easing.OutCubic } + } +} diff --git a/config/dot/quickshell/modules/signals/qmldir b/config/dot/quickshell/modules/signals/qmldir new file mode 100644 index 0000000..29bdd0c --- /dev/null +++ b/config/dot/quickshell/modules/signals/qmldir @@ -0,0 +1,4 @@ +module qs.modules.signals +EventCard 1.0 EventCard.qml +FocusCard 1.0 FocusCard.qml +SignalGlass 1.0 SignalGlass.qml diff --git a/config/dot/quickshell/scripts/screen-intelligence b/config/dot/quickshell/scripts/screen-intelligence new file mode 100755 index 0000000..960da80 --- /dev/null +++ b/config/dot/quickshell/scripts/screen-intelligence @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -u + +json_probe() { + local tesseract_ready=false + local zbar_ready=false + local english_ready=false + + command -v tesseract >/dev/null 2>&1 && tesseract_ready=true + command -v zbarimg >/dev/null 2>&1 && zbar_ready=true + if [[ "$tesseract_ready" == true ]] && tesseract --list-langs 2>/dev/null | grep -qx 'eng'; then + english_ready=true + fi + + jq -cn \ + --argjson tesseract "$tesseract_ready" \ + --argjson zbar "$zbar_ready" \ + --argjson english "$english_ready" \ + '{tesseract: $tesseract, zbar: $zbar, english: $english}' +} + +json_error() { + jq -cn --arg error "$1" '{ok: false, text: "", codeType: "", codeValue: "", error: $error}' +} + +analyze_file() { + local image="${1:-}" + if [[ -z "$image" || ! -f "$image" || ! -r "$image" ]]; then + json_error 'The captured image is unavailable.' + return 2 + fi + if ! command -v tesseract >/dev/null 2>&1; then + json_error 'Local text recognition is not installed. Install tesseract and zbar.' + return 3 + fi + if ! tesseract --list-langs 2>/dev/null | grep -qx 'eng'; then + json_error 'The English Tesseract language pack is not installed.' + return 4 + fi + + local work_dir + work_dir="$(mktemp -d)" || { + json_error 'Panama could not create recognition workspace.' + return 5 + } + trap 'rm -rf "$work_dir"' RETURN + + local text='' + local ocr_error='' + local ocr_status=0 + text="$(tesseract "$image" stdout -l eng --psm 3 2>"$work_dir/tesseract.err")" || ocr_status=$? + ocr_error="$(<"$work_dir/tesseract.err")" + + # ZBar is additive: OCR remains useful when the optional code reader is + # absent. The first symbol is surfaced because the result sheet is designed + # for one deliberate screen selection, not bulk barcode inventory. + local code_line='' + local code_type='' + local code_value='' + if command -v zbarimg >/dev/null 2>&1; then + code_line="$(zbarimg --quiet "$image" 2>/dev/null | sed -n '1p')" + if [[ -n "$code_line" ]]; then + code_type="${code_line%%:*}" + code_value="${code_line#*:}" + fi + fi + + if ((ocr_status != 0)) && [[ -z "$code_value" ]]; then + [[ -n "$ocr_error" ]] || ocr_error='Tesseract could not read the captured image.' + json_error "$ocr_error" + return 6 + fi + + jq -cn \ + --arg text "$text" \ + --arg codeType "$code_type" \ + --arg codeValue "$code_value" \ + '{ok: true, text: $text, codeType: $codeType, codeValue: $codeValue, error: ""}' +} + +case "${1:-}" in + probe) + json_probe + ;; + analyze-file) + analyze_file "${2:-}" + ;; + *) + json_error 'Usage: screen-intelligence probe | analyze-file IMAGE' + exit 64 + ;; +esac diff --git a/config/dot/quickshell/services/Caffeine.qml b/config/dot/quickshell/services/Caffeine.qml new file mode 100644 index 0000000..dcfc0ee --- /dev/null +++ b/config/dot/quickshell/services/Caffeine.qml @@ -0,0 +1,39 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Idle inhibit — the replacement for the GNOME Caffeine extension. +// +// Implemented with systemd-inhibit rather than the Wayland idle-inhibit +// protocol because the Wayland version is scoped to a surface (it would only +// hold while some window of ours is mapped) and it cannot block *sleep*, only +// the screensaver. A logind lock covers both, and outlives any UI. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + // Writable: the quick-settings tile and the bar indicator both bind to it. + property bool enabled: false + + function toggle(): void { + root.enabled = !root.enabled; + } + + // `sleep infinity` is the process the lock is held *for*: systemd releases + // the inhibitor when its child exits, so stopping this Process (SIGTERM) + // is all that's needed to drop it. + Process { + command: ["systemd-inhibit", "--what=idle:sleep", "--mode=block", "--who=Panama", "--why=Caffeine", "sleep", "infinity"] + running: root.enabled + + onExited: (code, status) => { + // Only interesting if it died while we still wanted the lock. + if (root.enabled) + console.warn("Caffeine: systemd-inhibit exited unexpectedly, code", code); + } + } +} diff --git a/config/dot/quickshell/services/Capture.qml b/config/dot/quickshell/services/Capture.qml new file mode 100644 index 0000000..558e20c --- /dev/null +++ b/config/dot/quickshell/services/Capture.qml @@ -0,0 +1,446 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Screenshot / screen-recording engine. +// +// This is the whole non-visual half of the GNOME 42 screenshot experience: +// freeze the screen, hand the frozen frame to the picker UI, then run grim or +// wf-recorder against whatever region the user chose. +// +// The UI (modules/capture) only ever reads state from here and calls the +// shoot*/record* functions -- it never spawns a process itself. +// +// Verified against the installed tools on 2026-08-17: +// grim 1.x -g ", x" <- SPACE before WxH, not a comma. +// "0,0,100x100" is rejected with +// "invalid geometry". +// -s output image scale +// -c include the cursor +// wf-recorder -g ", x" same format as grim +// no cursor toggle in this build; the pointer is always +// recorded, so "Show Pointer" only affects screenshots. +// notify-send -A NAME=Label implies --wait and prints NAME on stdout, +// which is how the notification +// actions below are dispatched. +// satty 0.22 --filename / --output-filename / --early-exit +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick +import qs.config +import qs.services + +Singleton { + id: root + + // ── Picker state (bound by modules/capture) ───────────────────────────── + // "screen" | "window" | "selection" + property string mode: "screen" + // Capture action. The booleans stay explicit because the existing picker + // reads them directly, while the setters guarantee only one special mode + // can be active at a time. + property bool recordMode: false + property bool intelligenceMode: false + // GNOME's "Show Pointer" checkbox. Screenshots only -- see the note above. + property bool showPointer: false + + // file:// URL of the frozen frame the overlay paints as its backdrop, or + // "" while the grab is still in flight / after it failed. + readonly property string freezeUrl: root._freezePath === "" ? "" : "file://" + root._freezePath + + // Top-left of the captured layout in Hyprland's global coordinate space. + // Everything the picker reports back is layout-global; the overlay + // subtracts these to get window-local pixels. + property real originX: 0 + property real originY: 0 + + // Windows on the focused workspace, newest-focused first, as + // { address, class, title, x, y, w, h } in layout-global logical pixels. + property var windows: [] + + // ── Recording state (read by the bar / recording indicator) ───────────── + readonly property bool recording: recProc.running + property int recordingSeconds: 0 + property string recordingPath: "" + + // ── Coordinate space ──────────────────────────────────────────────────── + // `hyprctl -j clients` reports `at`/`size` in Hyprland's *logical* layout + // coordinates (the 3000x2000 space on this 1.5x display), and grim's and + // wf-recorder's -g flags consume the same logical space -- so no scaling is + // needed anywhere and this stays 1.0. It exists because that is the one + // thing here that could not be verified without a live Hyprland session: if + // window outlines land at 2/3 size, set it to 1.5 and the whole pipeline + // (outlines and grim geometry alike) corrects together. + property real coordinateScale: 1.0 + + // ── Paths ─────────────────────────────────────────────────────────────── + readonly property string _home: Quickshell.env("HOME") || "/home/gib" + readonly property string shotDir: root._home + "/" + Settings.screenshotDir + readonly property string recDir: root._home + "/" + Settings.recordingDir + + property string _freezePath: "" + property string _prevFreezePath: "" + + function _stamp(): string { + return Qt.formatDateTime(new Date(), "yyyy-MM-dd HH-mm-ss"); + } + + // grim/wf-recorder geometry string, or "" for "the whole thing". + function _geom(x: real, y: real, w: real, h: real): string { + const s = root.coordinateScale; + return Math.round(x * s) + "," + Math.round(y * s) + " " + Math.round(w * s) + "x" + Math.round(h * s); + } + + // ── Public API (driven by shell.qml's IpcHandler) ─────────────────────── + + // Open the GNOME-style picker. The frozen frame is grabbed *before* the + // overlay is shown, otherwise the overlay would appear in its own backdrop. + function open(): void { + if (root.recording) { + // Print while recording = stop, matching the way GNOME's indicator + // behaves. The lead binds this to the same key. + root.stopRecording(); + return; + } + if (ShellState.captureOpen) { + // Print again while the picker is up dismisses it. Re-grabbing here + // would freeze a screen with the picker already in it. + root.close(); + return; + } + if (ScreenIntelligence.visible) + ScreenIntelligence.close(); + root.refreshWindows(); + root._grabFreeze(); + } + + function openIntelligence(): void { + root.mode = "selection"; + root.selectIntelligence(); + root.open(); + } + + function selectScreenshot(): void { + root.recordMode = false; + root.intelligenceMode = false; + } + + function selectRecording(): void { + root.recordMode = true; + root.intelligenceMode = false; + } + + function selectIntelligence(): void { + root.recordMode = false; + root.intelligenceMode = true; + } + + function close(): void { + ShellState.close(); + root._dropFreeze(); + } + + // Immediate whole-screen screenshot, no UI. (Shift+Print) + function screenNow(): void { + root.shootRegion(""); + } + + // Immediate active-window screenshot, no UI. (Alt+Print) + function windowNow(): void { + activeWindowProc.running = false; + activeWindowProc.running = true; + } + + function stopRecording(): void { + if (!recProc.running) + return; + // SIGINT, never SIGKILL: wf-recorder needs to flush and write the + // container trailer or the file is unplayable. + recProc.signal(2); + } + + // ── Execution ─────────────────────────────────────────────────────────── + + // geom: "" for the full output, otherwise a grim geometry string. + function shootRegion(geom: string): void { + // Capital "From" matches the files GNOME already left in + // ~/Pictures/Screenshots, so old and new shots sort together. + const name = "Screenshot From " + root._stamp() + ".png"; + const args = ["sh", "-c", root._shotScript, "qs-capture", root.shotDir, name]; + if (root.showPointer) + args.push("-c"); + if (geom !== "") + args.push("-g", geom); + else if (root._outputName !== "") + args.push("-o", root._outputName); + Quickshell.execDetached(args); + } + + function recordRegion(geom: string): void { + if (recProc.running) + return; + // .mp4 rather than GNOME's .webm because Settings.recorderArgs encodes + // h264 on the AMD VAAPI device. + const name = "Screencast From " + root._stamp() + ".mp4"; + root.recordingPath = root.recDir + "/" + name; + + // mkdir + exec so the PID we later SIGINT is wf-recorder itself and not + // the shell wrapping it. + let cmd = ["sh", "-c", 'mkdir -p "$1" && shift && exec "$@"', "qs-capture", root.recDir, "wf-recorder", "-y"]; + cmd = cmd.concat(Settings.recorderArgs.split(" ").filter(a => a !== "")); + if (geom !== "") + cmd.push("-g", geom); + else if (root._outputName !== "") + cmd.push("-o", root._outputName); + cmd.push("-f", root.recordingPath); + + root.recordingSeconds = 0; + recProc.command = cmd; + recProc.running = true; + } + + // Convenience wrappers used by the overlay, in layout-global logical px. + function shootRect(x: real, y: real, w: real, h: real): void { + root.shootRegion(root._geom(x, y, w, h)); + } + + function recordRect(x: real, y: real, w: real, h: real): void { + root.recordRegion(root._geom(x, y, w, h)); + } + + // Fire whatever the picker currently has selected, then dismiss it. + // rect is null for "the whole output". + function commit(rect: var): void { + ShellState.close(); + root._dropFreeze(); + // Let the overlay actually unmap before anything reads the screen, + // otherwise the dimming layer ends up in the picture. + commitDelay.rect = rect; + commitDelay.record = root.recordMode; + commitDelay.intelligence = root.intelligenceMode; + commitDelay.restart(); + } + + // ── Window enumeration ────────────────────────────────────────────────── + // Quickshell's HyprlandToplevel exposes no geometry, so this shells out. + function refreshWindows(): void { + clientsProc.running = false; + clientsProc.running = true; + } + + readonly property string _outputName: { + const m = Hyprland.focusedMonitor; + return m ? m.name : ""; + } + + // ── Internals ─────────────────────────────────────────────────────────── + + // Shot pipeline: capture, copy to the clipboard, then notify with the + // GNOME-style follow-up actions. Runs detached so a lingering + // `notify-send --wait` never blocks the shell. + // $1 = directory, $2 = filename, $3.. = extra grim args + readonly property string _shotScript: 'd=$1; n=$2; shift 2 +mkdir -p "$d" || exit 1 +p="$d/$n" +if ! grim "$@" "$p"; then + notify-send -a Screenshot -u critical "Screenshot failed" "grim could not capture the screen" + exit 1 +fi +wl-copy --type image/png < "$p" +qs ipc call status-events screenshot "$p" >/dev/null 2>&1 || true +act=$(notify-send -a Screenshot -i "$p" -A open=Open -A annotate=Annotate -A folder=Folder "Screenshot captured" "$n") +case "$act" in + open) xdg-open "$p" ;; + annotate) satty --filename "$p" --output-filename "$p" --copy-command wl-copy --early-exit all ;; + folder) xdg-open "$d" ;; +esac' + + readonly property string _recDoneScript: 'p=$1 +d=$(dirname "$p") +act=$(notify-send -a Screencast -A open=Open -A folder=Folder "Screen recording saved" "$(basename "$p")") +case "$act" in + open) xdg-open "$p" ;; + folder) xdg-open "$d" ;; +esac' + + function _grabFreeze(): void { + root._prevFreezePath = root._freezePath; + root._freezePath = ""; + // Unique name per open: QML caches Image sources by URL, so reusing one + // path would repaint the previous frame. + const path = Quickshell.cachePath("capture-freeze-" + Date.now() + ".ppm"); + // PPM, not PNG: this is a 4500x3000 grab and libpng would spend the + // better part of a second compressing an image we throw away seconds + // later. PPM is a raw dump, so the freeze appears immediately. + // -o pins the grab to the focused output so the picker's coordinate + // space is exactly this window's. + const cmd = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && d=$1 && shift && exec grim -t ppm "$@" "$d"', "qs-capture", path]; + if (root._outputName !== "") + cmd.push("-o", root._outputName); + freezeProc.pendingPath = path; + freezeProc.command = cmd; + freezeProc.running = false; + freezeProc.running = true; + } + + function _dropFreeze(): void { + const stale = root._freezePath; + root._freezePath = ""; + if (stale !== "") + Quickshell.execDetached(["rm", "-f", stale]); + } + + Timer { + id: commitDelay + property var rect: null + property bool record: false + property bool intelligence: false + // A handful of frames at 60Hz, enough for the compositor to recomposite + // the output without the overlay on it. + interval: 90 + onTriggered: { + const r = commitDelay.rect; + const geom = r ? root._geom(r.x, r.y, r.width, r.height) : ""; + if (commitDelay.intelligence) + ScreenIntelligence.analyzeRegion(geom, root._outputName); + else if (commitDelay.record) + root.recordRegion(geom); + else + root.shootRegion(geom); + commitDelay.rect = null; + } + } + + Process { + id: freezeProc + property string pendingPath: "" + onExited: (code, status) => { + if (code === 0) { + root._freezePath = freezeProc.pendingPath; + } else { + // No wlr-screencopy (or no compositor at all): still open the + // picker so Selection/Window mode remain usable, just without + // the frozen backdrop. + root._freezePath = ""; + console.warn("Capture: grim freeze failed with exit code", code); + } + if (root._prevFreezePath !== "") { + Quickshell.execDetached(["rm", "-f", root._prevFreezePath]); + root._prevFreezePath = ""; + } + ShellState.open("capture"); + } + } + + Process { + id: clientsProc + command: ["hyprctl", "-j", "clients"] + stdout: StdioCollector { + onStreamFinished: { + const focused = Hyprland.focusedWorkspace; + let out = []; + try { + const raw = JSON.parse(this.text); + for (const c of raw) { + if (!c.mapped || c.hidden) + continue; + if (!c.size || c.size[0] <= 0 || c.size[1] <= 0) + continue; + // Only the workspace the user is looking at. If + // Hyprland hasn't reported a focused workspace yet, + // show everything rather than nothing. + if (focused && c.workspace && c.workspace.id !== focused.id) + continue; + out.push({ + address: c.address || "", + appId: c.class || "", + title: c.title || "", + order: c.focusHistoryID === undefined ? 999 : c.focusHistoryID, + x: c.at[0], + y: c.at[1], + w: c.size[0], + h: c.size[1] + }); + } + // Ascending focusHistoryID == front to back, so a hit test + // walking this list picks the topmost window first. + out.sort((a, b) => a.order - b.order); + } catch (e) { + // Not running under Hyprland, or hyprctl printed its + // "HYPRLAND_INSTANCE_SIGNATURE not set!" error. Window mode + // simply shows nothing instead of throwing. + out = []; + } + root.windows = out; + } + } + } + + Process { + id: activeWindowProc + command: ["hyprctl", "-j", "activewindow"] + stdout: StdioCollector { + onStreamFinished: { + try { + const c = JSON.parse(this.text); + if (c && c.at && c.size && c.size[0] > 0) + root.shootRect(c.at[0], c.at[1], c.size[0], c.size[1]); + else + root.shootRegion(""); + } catch (e) { + // No Hyprland / no focused window: fall back to the whole + // screen rather than doing nothing. + root.shootRegion(""); + } + } + } + } + + Process { + id: recProc + onExited: (code, status) => { + recTimer.stop(); + // SIGINT gives exit code 2 (or 130 through a shell); both mean the + // user pressed stop and the file was finalised normally. + if (root.recordingPath !== "") { + StatusEvents.publish({ + key: "capture-recording", + glyph: "\u{F044A}", + title: "Screen recording saved", + detail: root.recordingPath.split("/").pop(), + tone: "ok", + priority: StatusEvents.importantPriority, + actionId: "open-path", + actionData: root.recordingPath + }); + Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]); + } + root.recordingPath = ""; + root.recordingSeconds = 0; + } + onStarted: { + root.recordingSeconds = 0; + recTimer.start(); + StatusEvents.publish({ + key: "capture-recording", + glyph: "\u{F044A}", + title: "Screen recording started", + detail: "Panama is capturing the selected area", + tone: "danger", + priority: StatusEvents.criticalPriority, + actionId: "open-activity" + }); + } + } + + // Only ticks while a recording is live -- nothing in this shell repaints + // when idle. + Timer { + id: recTimer + interval: 1000 + repeat: true + onTriggered: root.recordingSeconds += 1 + } +} diff --git a/config/dot/quickshell/services/Clipboard.qml b/config/dot/quickshell/services/Clipboard.qml new file mode 100644 index 0000000..7bfa5bf --- /dev/null +++ b/config/dot/quickshell/services/Clipboard.qml @@ -0,0 +1,240 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Clipboard history — read out of Vicinae's store. +// +// Vicinae (the launcher) already runs a clipboard watcher, so this service is +// deliberately a *reader* and nothing else. A second `wl-paste --watch` would +// record every copy twice and give the desktop two disagreeing opinions about +// which pastes are sensitive enough to conceal. +// +// Two files back every entry: +// ~/.local/share/vicinae/clipboard.db metadata + a 50-char preview +// ~/.local/share/vicinae/clipboard-data/ the full payload, text or binary +// +// The stored preview is truncated far too short to fill a row, so the query +// pulls the first few hundred bytes out of the payload file instead (sqlite's +// readfile()) and falls back to the stored preview if that file has been reaped. +// +// Read-only and on demand: the query runs when refresh() is called, never on a +// timer. Nothing in here wakes up while the popover is closed. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + // ── Entry kinds ───────────────────────────────────────────────────────── + // Vicinae's `selection.kind` column, confirmed empirically by copying one + // of each: plain text, a URL, a PNG and a file drag. + readonly property int kindText: 1 + readonly property int kindLink: 2 + readonly property int kindImage: 3 + readonly property int kindFile: 4 + + readonly property string dbPath: Quickshell.env("HOME") + "/.local/share/vicinae/clipboard.db" + readonly property string dataDir: Quickshell.env("HOME") + "/.local/share/vicinae/clipboard-data" + + // How many rows to load. The popover scrolls, so this is a memory budget + // rather than a display limit; anything older is a job for the launcher. + readonly property int limit: 50 + + // Newest first, pinned entries hoisted above them. Each element: + // { id, offerId, mime, kind, source, host, pinned, ts, size, + // preview, encrypted, textual, typeLabel, dataPath } + property var entries: [] + + // True between refresh() and the query finishing. The popover uses it to + // tell "nothing copied yet" apart from "not loaded yet". + property bool loading: false + + // False if the query failed outright — a missing database, or Vicinae not + // installed. Distinct from an empty history. + property bool available: true + + // Wall-clock seconds at the moment `entries` was filled. Relative times are + // rendered against this rather than against a live clock, so a row's label + // cannot change while the user is reading it and nothing has to tick. + property real queriedAt: 0 + + signal refreshed + + // Reload the history. Cheap enough to call on every popover open; a call + // made while a query is already in flight is dropped rather than queued, + // because the in-flight result is already at most milliseconds stale. + function refresh(): void { + if (query.running) + return; + root.loading = true; + query.running = true; + } + + // Put an entry back on the clipboard. The payload file is the source of + // truth — the preview in `entries` is truncated, so copying that back would + // silently hand the user a fragment of what they asked for. + function copyEntry(id: string): void { + const entry = root.entries.find(e => e.id === id); + if (!entry || entry.encrypted) + return; + + // Arguments after the -c script become $0..$n, so neither the path nor + // the mime type is ever spliced into shell source. wl-copy has no + // read-from-file flag, hence the redirect. + copy.command = ["sh", "-c", 'exec wl-copy --type "$2" < "$1"', "qs-clipboard", entry.dataPath, entry.mime]; + copy.running = true; + } + + // "just now" / "5m" / "2h" / "Mon" / "Mar 4", against the time the list was + // loaded. Deliberately terse: this sits in the corner of a dense row. + function relativeTime(ts: real): string { + const delta = Math.max(0, root.queriedAt - ts); + + if (delta < 60) + return "just now"; + if (delta < 3600) + return Math.floor(delta / 60) + "m"; + if (delta < 86400) + return Math.floor(delta / 3600) + "h"; + + const date = new Date(ts * 1000); + if (delta < 7 * 86400) + return Qt.formatDateTime(date, "ddd"); + return Qt.formatDateTime(date, "MMM d"); + } + + // ── The query ─────────────────────────────────────────────────────────── + // -readonly so a click in the shell can never take a write lock on a + // database another process owns; -json because clipboard text is full of + // newlines, pipes and quotes and any separator we picked would appear in it. + // + // The payload file is only read for unencrypted text: casting an encrypted + // blob or a PNG to text yields noise, and those rows carry a usable label in + // `text_preview` already ("Image (60x60)"). + readonly property string sql: ` + select s.id as id, + o.id as offerId, + s.preferred_mime_type as mime, + s.kind as kind, + o.encryption_type as enc, + coalesce(s.source, '') as source, + coalesce(o.url_host, '') as host, + s.pinned_at is not null as pinned, + s.updated_at as ts, + o.size as size, + case when s.preferred_mime_type like 'text/%' and o.encryption_type = 0 + then coalesce(cast(substr(readfile('${root._sqlLiteral(root.dataDir)}/' || o.id), 1, 400) as text), + o.text_preview) + else o.text_preview + end as preview + from selection s + join data_offer o + on o.selection_id = s.id and o.mime_type = s.preferred_mime_type + where o.id = ( + select min(o2.id) + from data_offer o2 + where o2.selection_id = s.id + and o2.mime_type = s.preferred_mime_type + ) + order by s.pinned_at is null, s.pinned_at desc, s.updated_at desc + limit ${root.limit}` + + Process { + id: query + command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql] + + stdout: StdioCollector { + onStreamFinished: root._parse(this.text) + } + + onExited: exitCode => { + root.loading = false; + if (exitCode !== 0) { + root.available = false; + root.entries = []; + root.refreshed(); + } + } + } + + Process { + id: copy + onExited: exitCode => { + // Silent success, loud failure: a copy that quietly did nothing + // leaves the user pasting whatever was there before. + if (exitCode !== 0) + console.warn("Clipboard: wl-copy exited", exitCode); + } + } + + function _parse(text: string): void { + root.available = true; + root.queriedAt = Date.now() / 1000; + + // sqlite3 -json prints nothing at all for an empty result set. + if (!text || !text.trim()) { + root.entries = []; + root.refreshed(); + return; + } + + try { + root.entries = JSON.parse(text).map(row => root._normalise(row)); + } catch (e) { + console.warn("Clipboard: could not parse history —", e); + root.entries = []; + root.available = false; + } + root.refreshed(); + } + + // Turn a raw row into what the UI actually asks questions of, so no + // delegate has to know about column names or Vicinae's integer kinds. + function _normalise(row: var): var { + const encrypted = row.enc !== 0; + const textual = row.kind === root.kindText || row.kind === root.kindLink; + const raw = row.preview || ""; + + return { + id: row.id, + offerId: row.offerId, + mime: row.mime, + kind: row.kind, + source: row.source, + host: row.host, + pinned: row.pinned === 1, + ts: row.ts, + size: row.size, + encrypted: encrypted, + textual: textual, + typeLabel: root._typeLabel(row.kind, row.mime), + // Newlines and tabs become spaces: a row is one line of a list, and + // a pasted shell script must not turn it into a paragraph. + preview: encrypted ? "" : raw.replace(/\s+/g, " ").trim(), + dataPath: root.dataDir + "/" + row.offerId + }; + } + + // Short chip text for entries with nothing readable to show. + function _typeLabel(kind: int, mime: string): string { + switch (kind) { + case root.kindImage: + // "image/png" -> "PNG". Falls back to the whole type if it is odd. + return (mime.split("/")[1] || mime).split(";")[0].toUpperCase(); + case root.kindFile: + return "Files"; + case root.kindLink: + return "Link"; + default: + return "Text"; + } + } + + // Escape a path for embedding in a SQL string literal. HOME is not + // attacker-controlled, but a stray apostrophe would break the query. + function _sqlLiteral(value: string): string { + return value.replace(/'/g, "''"); + } +} diff --git a/config/dot/quickshell/services/DeviceEvents.qml b/config/dot/quickshell/services/DeviceEvents.qml new file mode 100644 index 0000000..e36beb8 --- /dev/null +++ b/config/dot/quickshell/services/DeviceEvents.qml @@ -0,0 +1,132 @@ +pragma Singleton + +// Transition-only device monitoring. Resting state is intentionally silent. + +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Io +import Quickshell.Services.Pipewire +import QtQuick + +Singleton { + id: root + + property bool outputInitialized: false + property bool bluetoothInitialized: false + property bool kdeInitialized: false + property string previousOutput: "" + property string previousBluetooth: "" + property string previousKde: "" + + PwObjectTracker { objects: [Pipewire.defaultAudioSink] } + + readonly property string outputName: { + const sink = Pipewire.defaultAudioSink; + return sink?.description || sink?.nickname || sink?.name || ""; + } + + readonly property string bluetoothNames: { + const devices = Bluetooth.devices?.values ?? []; + return devices.filter(device => device.connected) + .map(device => device.name || device.address) + .sort() + .join("\u001f"); + } + + onOutputNameChanged: { + if (!root.outputInitialized) { + root.previousOutput = root.outputName; + return; + } + if (root.outputInitialized && root.outputName && root.outputName !== root.previousOutput) { + StatusEvents.publish({ + key: "device-output", + glyph: "\u{F07E7}", + title: root.outputName, + detail: "Audio output selected", + priority: StatusEvents.ambientPriority + }); + } + root.previousOutput = root.outputName; + } + + onBluetoothNamesChanged: { + if (!root.bluetoothInitialized) { + root.previousBluetooth = root.bluetoothNames; + return; + } + if (root.bluetoothInitialized && root.bluetoothNames !== root.previousBluetooth) { + const current = root.bluetoothNames ? root.bluetoothNames.split("\u001f") : []; + const previous = root.previousBluetooth ? root.previousBluetooth.split("\u001f") : []; + const connected = current.find(name => !previous.includes(name)); + const disconnected = previous.find(name => !current.includes(name)); + StatusEvents.publish({ + key: "device-bluetooth", + glyph: "\u{F00B1}", + title: connected || disconnected || "Bluetooth device", + detail: connected ? "Connected" : "Disconnected", + tone: connected ? "accent" : "warn", + priority: StatusEvents.ambientPriority + }); + } + root.previousBluetooth = root.bluetoothNames; + } + + Timer { + id: discoverySettle + interval: 1800 + onTriggered: { + root.previousOutput = root.outputName; + root.previousBluetooth = root.bluetoothNames; + root.outputInitialized = true; + root.bluetoothInitialized = true; + } + } + + Timer { + interval: 15000 + repeat: true + running: true + triggeredOnStart: true + onTriggered: { + if (!kdeProbe.running) + kdeProbe.running = true; + } + } + + Process { + id: kdeProbe + command: ["kdeconnect-cli", "-a", "--name-only"] + stdout: StdioCollector { + onStreamFinished: { + const names = this.text.split("\n") + .map(line => line.trim()) + .filter(line => line && !/^\d+ devices? found$/.test(line)) + .sort() + .join("\u001f"); + if (root.kdeInitialized && names !== root.previousKde) { + const current = names ? names.split("\u001f") : []; + const previous = root.previousKde ? root.previousKde.split("\u001f") : []; + const connected = current.find(name => !previous.includes(name)); + const disconnected = previous.find(name => !current.includes(name)); + StatusEvents.publish({ + key: "device-kdeconnect", + glyph: "\u{F03F2}", + title: connected || disconnected || "Phone", + detail: connected ? "KDE Connect available" : "KDE Connect disconnected", + tone: connected ? "accent" : "warn", + priority: StatusEvents.ambientPriority + }); + } + root.previousKde = names; + root.kdeInitialized = true; + } + } + } + + Component.onCompleted: { + root.previousOutput = root.outputName; + root.previousBluetooth = root.bluetoothNames; + discoverySettle.start(); + } +} diff --git a/config/dot/quickshell/services/FocusSession.qml b/config/dot/quickshell/services/FocusSession.qml new file mode 100644 index 0000000..6157cc2 --- /dev/null +++ b/config/dot/quickshell/services/FocusSession.qml @@ -0,0 +1,252 @@ +pragma Singleton + +// A single focus session bound to a Hyprland workspace. The deadline and the +// few user decisions are persisted; the once-per-second display value is not, +// so an active session does not turn into a constant disk writer. + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + readonly property alias active: state.active + readonly property alias paused: state.paused + readonly property alias capsuleVisible: state.capsuleVisible + readonly property alias workspaceId: state.workspaceId + readonly property alias workspaceLabel: state.workspaceLabel + readonly property alias monitorName: state.monitorName + + // Kept in memory only. The persisted deadline is enough to reconstruct it + // after a reload, and paused sessions persist their fixed remainder. + property double nowMs: Date.now() + + readonly property int remainingSeconds: { + if (!state.active) + return 0; + if (state.paused) + return Math.max(0, state.pausedRemainingSeconds); + return Math.max(0, Math.ceil((state.deadlineMs - root.nowMs) / 1000)); + } + + readonly property string remainingText: { + const total = root.remainingSeconds; + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const pad = value => String(value).padStart(2, "0"); + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`; + } + + readonly property string statusText: state.paused ? "Paused" : "Do Not Disturb · Caffeine"; + + FileView { + id: stateFile + + path: Quickshell.stateDir + "/focus-session.json" + blockLoading: true + printErrors: false + atomicWrites: true + onLoaded: restoreTimer.restart() + + JsonAdapter { + id: state + + property bool active: false + property bool paused: false + property bool capsuleVisible: false + property int workspaceId: 0 + property string workspaceLabel: "" + property string monitorName: "" + property double deadlineMs: 0 + property int pausedRemainingSeconds: 0 + property bool previousDnd: false + property bool previousCaffeine: false + } + } + + Timer { + interval: 1000 + repeat: true + running: state.active && !state.paused + triggeredOnStart: true + onTriggered: { + root.nowMs = Date.now(); + if (state.deadlineMs > 0 && root.nowMs >= state.deadlineMs) + root.end(true); + } + } + + // Defer restoration until the other service singletons have completed + // construction. An active focus session owns these states across reloads. + Timer { + id: restoreTimer + interval: 0 + onTriggered: root.restore() + } + + Component.onCompleted: { + // `blockLoading` usually makes this true immediately. Keep the loaded + // signal above as the authoritative path for slower storage. + if (stateFile.loaded) + restoreTimer.restart(); + } + + function start(minutes: int): void { + const duration = Math.max(1, minutes); + const workspace = root.currentPositiveWorkspace(); + + // Replacing an active session first releases exactly the state it owns. + if (state.active) + root.end(false); + + state.previousDnd = Notifs.doNotDisturb; + state.previousCaffeine = Caffeine.enabled; + Notifs.doNotDisturb = true; + Caffeine.enabled = true; + + state.workspaceId = workspace ? workspace.id : 1; + state.workspaceLabel = root.displayWorkspaceName(workspace); + state.monitorName = Hyprland.focusedMonitor?.name ?? ""; + state.pausedRemainingSeconds = duration * 60; + state.deadlineMs = Date.now() + duration * 60 * 1000; + state.paused = false; + state.capsuleVisible = true; + state.active = true; + root.nowMs = Date.now(); + stateFile.writeAdapter(); + } + + function startDefault(): void { + root.start(Settings.focusDurationMinutes); + } + + // The discoverable "start or show" behavior used by quick settings and + // Super+Shift+F. A second invocation never destroys work accidentally. + function reveal(): void { + if (!state.active) { + root.startDefault(); + return; + } + state.capsuleVisible = true; + stateFile.writeAdapter(); + } + + function dismiss(): void { + state.capsuleVisible = false; + stateFile.writeAdapter(); + } + + function pauseOrResume(): void { + if (!state.active) + return; + + root.nowMs = Date.now(); + if (state.paused) { + state.deadlineMs = root.nowMs + state.pausedRemainingSeconds * 1000; + state.paused = false; + } else { + state.pausedRemainingSeconds = root.remainingSeconds; + state.paused = true; + } + stateFile.writeAdapter(); + } + + function activateWorkspace(): void { + if (!state.active || state.workspaceId <= 0) + return; + + const all = Hyprland.workspaces?.values ?? []; + for (const workspace of all) { + if (workspace && workspace.id === state.workspaceId) { + workspace.activate(); + return; + } + } + + // Dynamic workspaces can disappear while the session is running. + // Focusing the stored positive id recreates the destination cleanly. + Hyprland.dispatch(`hl.dsp.focus({ workspace = ${state.workspaceId} })`); + } + + function end(completed: bool): void { + if (!state.active) + return; + + const previousDnd = state.previousDnd; + const previousCaffeine = state.previousCaffeine; + const finishedWorkspaceId = state.workspaceId; + const finishedWorkspace = state.workspaceLabel; + + state.active = false; + state.paused = false; + state.capsuleVisible = false; + state.workspaceId = 0; + state.workspaceLabel = ""; + state.monitorName = ""; + state.deadlineMs = 0; + state.pausedRemainingSeconds = 0; + stateFile.writeAdapter(); + + Notifs.doNotDisturb = previousDnd; + Caffeine.enabled = previousCaffeine; + + if (completed) { + StatusEvents.publish({ + key: "focus-complete", + glyph: "\u{F051F}", + title: "Focus complete", + detail: finishedWorkspace ? `${finishedWorkspace} session finished` : "Your focus session finished", + tone: "ok", + priority: StatusEvents.importantPriority, + actionId: "open-workspace", + actionData: String(finishedWorkspaceId) + }); + Quickshell.execDetached([ + "notify-send", + "-a", "Panama", + "-i", "appointment-soon-symbolic", + "Focus complete", + finishedWorkspace ? `${finishedWorkspace} session finished` : "Your focus session finished" + ]); + } + } + + function restore(): void { + root.nowMs = Date.now(); + if (!state.active) + return; + if (!state.paused && state.deadlineMs <= root.nowMs) { + root.end(true); + return; + } + + // Caffeine and the in-process notification server restart on a shell + // reload. The persisted session therefore has to reclaim both states. + Notifs.doNotDisturb = true; + Caffeine.enabled = true; + } + + function currentPositiveWorkspace(): var { + const focused = Hyprland.focusedWorkspace; + if (focused && focused.id > 0) + return focused; + + const all = Hyprland.workspaces?.values ?? []; + for (const workspace of all) { + if (workspace && workspace.id > 0) + return workspace; + } + return null; + } + + function displayWorkspaceName(workspace: var): string { + if (!workspace) + return "Workspace 1"; + const name = workspace.name || String(workspace.id); + return /^\d+$/.test(name) ? `Workspace ${name}` : name; + } +} diff --git a/config/dot/quickshell/services/NightLight.qml b/config/dot/quickshell/services/NightLight.qml new file mode 100644 index 0000000..c1a9054 --- /dev/null +++ b/config/dot/quickshell/services/NightLight.qml @@ -0,0 +1,106 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Night light — a thin wrapper around hyprsunset (0.4.0). +// +// hyprsunset is a daemon: `hyprsunset -t ` grabs wlr-gamma-control and +// holds it until it exits, and `hyprctl hyprsunset ` re-tunes the +// running instance over its socket. So the shape here is: +// * `active` drives whether the daemon process runs at all, +// * temperature changes are pushed to the *running* daemon rather than +// restarting it, which would flash the screen back to 6500K. +// +// Gamma is restored by the compositor when the daemon's gamma-control object +// dies, so stopping the Process is a complete "off" — no `-i` pass needed. +// +// Only works under Hyprland; there is no gamma protocol on GNOME, so the +// daemon exits immediately there. That is reported once, not retried. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + property bool initialized: false + + // The manual switch. Ignored while `automatic` is on. + property bool enabled: Settings.nightLightEnabledByDefault + property int temperature: Settings.nightLightTemperature + + // Follow Settings.nightLightFrom .. nightLightTo instead of the manual + // switch. Off by default because GNOME's schedule was disabled. + property bool automatic: DesktopPreferences.nightLightAutomatic + + // What is actually applied right now. + readonly property bool active: root.automatic ? root.scheduled : root.enabled + + // True while the wall clock is inside the scheduled window. + readonly property bool scheduled: root.inWindow(clock.hours + clock.minutes / 60) + + // A manual toggle always wins: it drops out of the schedule rather than + // being silently reverted a minute later. Same as GNOME's behaviour when + // you flip night light off during a scheduled evening. + function toggle(): void { + if (root.automatic) { + root.automatic = false; + root.enabled = !root.scheduled; + } else { + root.enabled = !root.enabled; + } + } + + // The window wraps midnight (17:00 → 10:00), so the comparison flips when + // `from` is later in the day than `to`. + function inWindow(hour: real): bool { + const from = Settings.nightLightFrom; + const to = Settings.nightLightTo; + return from <= to ? (hour >= from && hour < to) : (hour >= from || hour < to); + } + + // Only ticks while the schedule is in charge — no idle work otherwise. + SystemClock { + id: clock + precision: SystemClock.Minutes + enabled: root.automatic + } + + Process { + id: daemon + command: ["hyprsunset", "-t", String(root.temperature)] + running: root.active + + onExited: (code, status) => { + if (root.active) + console.warn("NightLight: hyprsunset exited with", code, "- is Hyprland running?"); + } + } + + // Re-tune in place; restarting the daemon would flash the display. + onTemperatureChanged: { + DesktopPreferences.nightLightTemperature = root.temperature; + if (daemon.running) + Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", String(root.temperature)]); + } + + onEnabledChanged: DesktopPreferences.nightLightEnabled = root.enabled + onAutomaticChanged: DesktopPreferences.nightLightAutomatic = root.automatic + + onActiveChanged: { + if (!root.initialized) + return; + StatusEvents.publish({ + key: "display-night-light", + glyph: "\u{F0F31}", + title: root.active ? "Night Light on" : "Night Light off", + detail: root.active ? String(root.temperature) + " K" : "Display colors restored", + tone: root.active ? "warn" : "accent", + priority: StatusEvents.ambientPriority + }); + } + + Component.onCompleted: Qt.callLater(() => root.initialized = true) +} diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml new file mode 100644 index 0000000..5fc4943 --- /dev/null +++ b/config/dot/quickshell/services/Notifs.qml @@ -0,0 +1,176 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// The freedesktop notification server, plus the two lists the UI renders: +// +// popups — what Toasts.qml is currently showing (transient, timed) +// history — GNOME's message tray, what NotificationCenter.qml shows +// +// A notification lives exactly as long as `tracked` is true, so history holds +// the *live* objects rather than copies: that keeps actions and inline replies +// working from the tray, which is what GNOME does. The cost is that a +// notification an app closes itself (progress bars, "download finished" +// replacing "downloading") disappears from history too — correct behaviour, +// but the reason history is not append-only. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Services.Notifications +import QtQuick +import qs.config + +Singleton { + id: root + + readonly property alias server: server + + // Live tracked set, straight from the server. Mostly useful for counting; + // the UI wants `popups` / `history`, which are ordered newest-first. + readonly property alias active: server.trackedNotifications + + property var history: [] + property var popups: [] + + // Suppresses toasts entirely. Notifications still reach history. + property bool doNotDisturb: false + + // Cleared when the notification centre is opened. The bar binds to this. + property int unreadCount: 0 + + // Arrival times, keyed by notification id — the protocol carries no + // timestamp. Deliberately formatted once at arrival rather than shown as + // "5 minutes ago", which would need a clock ticking behind every card. + readonly property var arrivals: ({}) + + function timeText(id: int): string { + const at = root.arrivals[id]; + return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : ""; + } + + readonly property bool hasNotifications: root.history.length > 0 + + // history grouped by app, in most-recent-app-first order — the shape + // NotificationCenter.qml renders directly. + readonly property var groups: { + const out = []; + const byApp = {}; + for (const n of root.history) { + const key = n.appName || "Notifications"; + let group = byApp[key]; + if (!group) { + group = { + app: key, + icon: n.appIcon, + desktopEntry: n.desktopEntry, + items: [] + }; + byApp[key] = group; + out.push(group); + } + group.items.push(n); + } + return out; + } + + NotificationServer { + id: server + + keepOnReload: true + persistenceSupported: true + bodySupported: true + bodyMarkupSupported: true + bodyImagesSupported: true + imageSupported: true + actionsSupported: true + actionIconsSupported: true + inlineReplySupported: true + + onNotification: notification => { + // Replayed from before a shell reload. Letting these through would + // re-toast and re-list everything on every edit, so they are left + // untracked and allowed to die. + if (notification.lastGeneration) + return; + + // Without this the object is destroyed the instant this returns. + notification.tracked = true; + root.arrivals[notification.id] = new Date(); + + // The object may go away at any time (app-side close, dismiss()). + // Drop our references synchronously when it does. + notification.closed.connect(() => root.forget(notification)); + + // `transient` is the volume-OSD case: show it, never file it. + if (!notification.transient) { + root.pushHistory(notification); + root.unreadCount += 1; + } + + if (!root.doNotDisturb) + root.popups = [notification].concat(root.popups); + } + } + + // ── Mutation ──────────────────────────────────────────────────────────── + + function pushHistory(n: Notification): void { + const next = [n].concat(root.history); + + // Anything past the cap is released, otherwise it stays tracked + // forever and the server's set grows without bound. + const evicted = next.splice(Settings.notificationHistoryLimit); + root.history = next; + for (const old of evicted) + old.dismiss(); + } + + // Hide a toast without filing the notification away as read. Mirrors + // GNOME: closing a banner leaves it in the tray. + function dropPopup(n: Notification): void { + const next = root.popups.filter(x => x !== n); + if (next.length === root.popups.length) + return; + root.popups = next; + + // Transients were never in history, so nothing else holds them. + if (n.transient) + n.dismiss(); + } + + function dismiss(n: Notification): void { + n.dismiss(); + } + + function dismissAll(): void { + // Copy first: dismiss() re-enters through forget() and rewrites both + // lists while we iterate. + const all = root.history.slice(); + root.history = []; + root.popups = []; + for (const n of all) + n.dismiss(); + root.unreadCount = 0; + } + + function dismissApp(appName: string): void { + const doomed = root.history.filter(n => (n.appName || "Notifications") === appName); + root.history = root.history.filter(n => doomed.indexOf(n) === -1); + root.popups = root.popups.filter(n => doomed.indexOf(n) === -1); + for (const n of doomed) + n.dismiss(); + } + + function markAllRead(): void { + root.unreadCount = 0; + } + + // Called from the `closed` signal — the object is on its way out, so this + // only ever removes references, never touches the notification. + function forget(n: Notification): void { + delete root.arrivals[n.id]; + if (root.history.indexOf(n) !== -1) + root.history = root.history.filter(x => x !== n); + if (root.popups.indexOf(n) !== -1) + root.popups = root.popups.filter(x => x !== n); + } +} diff --git a/config/dot/quickshell/services/PrivacyState.qml b/config/dot/quickshell/services/PrivacyState.qml new file mode 100644 index 0000000..c024fc8 --- /dev/null +++ b/config/dot/quickshell/services/PrivacyState.qml @@ -0,0 +1,178 @@ +pragma Singleton + +// Persistent privacy state is separate from transient Signal Glass events. +// The low-frequency PipeWire probe only assigns when state changes, so the bar +// remains completely idle when nothing is capturing. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property bool microphoneActive: false + property bool cameraActive: false + property bool screenSharingActive: false + readonly property bool recordingActive: Capture.recording + property string microphoneApp: "" + property string cameraApp: "" + property string screenSharingApp: "" + + property bool initialized: false + property bool fixtureMode: false + property bool warned: false + + readonly property bool anyActive: root.microphoneActive || root.cameraActive + || root.screenSharingActive || root.recordingActive + readonly property var activeKinds: { + const kinds = []; + if (root.recordingActive) + kinds.push("Recording"); + if (root.screenSharingActive) + kinds.push("Screen sharing"); + if (root.cameraActive) + kinds.push("Camera"); + if (root.microphoneActive) + kinds.push("Microphone"); + return kinds; + } + + function applyStates(microphone: bool, camera: bool, screen: bool, + micApp: string, cameraApp: string, screenApp: string, + announce: bool): void { + root.updateKind("microphone", microphone, micApp, announce); + root.updateKind("camera", camera, cameraApp, announce); + root.updateKind("screen", screen, screenApp, announce); + root.initialized = true; + } + + function updateKind(kind: string, active: bool, app: string, announce: bool): void { + let previous = false; + let label = ""; + if (kind === "microphone") { + previous = root.microphoneActive; + root.microphoneActive = active; + root.microphoneApp = app; + label = "Microphone"; + } else if (kind === "camera") { + previous = root.cameraActive; + root.cameraActive = active; + root.cameraApp = app; + label = "Camera"; + } else { + previous = root.screenSharingActive; + root.screenSharingActive = active; + root.screenSharingApp = app; + label = "Screen sharing"; + } + + if (!announce || previous === active) + return; + + StatusEvents.publish({ + key: "privacy-" + kind, + glyph: kind === "microphone" ? "\u{F036C}" : (kind === "camera" ? "\u{F0100}" : "\u{F0379}"), + title: active ? label + " in use" : label + " released", + detail: active ? (app || "An application is using " + label.toLowerCase()) : "No application is using " + label.toLowerCase(), + tone: active ? "danger" : "ok", + priority: StatusEvents.criticalPriority, + durationMs: active ? 3600 : 2400, + actionId: active ? "open-activity" : "" + }); + } + + function applyProbe(text: string): void { + if (root.fixtureMode) + return; + + let nodes = []; + try { + nodes = JSON.parse(text); + } catch (error) { + if (!root.warned) { + console.warn("PrivacyState: could not parse PipeWire state"); + root.warned = true; + } + return; + } + + let microphone = false; + let camera = false; + let screen = false; + let micApp = ""; + let cameraApp = ""; + let screenApp = ""; + + for (const object of nodes) { + if (object?.type !== "PipeWire:Interface:Node" || object?.info?.state !== "running") + continue; + const props = object.info.props ?? {}; + const mediaClass = String(props["media.class"] ?? ""); + const nodeName = String(props["node.name"] ?? "").toLowerCase(); + const role = String(props["media.role"] ?? "").toLowerCase(); + const app = String(props["application.name"] ?? props["node.description"] ?? ""); + + const looksLikeScreen = role.includes("screen") || nodeName.includes("screencast") + || nodeName.includes("screen-cast") || nodeName.includes("xdg-desktop-portal"); + if (mediaClass === "Stream/Input/Audio") { + microphone = true; + micApp = micApp || app; + } else if (mediaClass === "Stream/Input/Video" && !looksLikeScreen) { + camera = true; + cameraApp = cameraApp || app; + } else if ((mediaClass === "Stream/Input/Video" || mediaClass === "Stream/Output/Video") && looksLikeScreen) { + screen = true; + screenApp = screenApp || app; + } + } + + root.applyStates(microphone, camera, screen, micApp, cameraApp, screenApp, root.initialized); + } + + function applyFixture(name: string): void { + root.fixtureMode = true; + if (name === "microphone-on") + root.applyStates(true, false, false, "Contract fixture", "", "", true); + else if (name === "camera-on") + root.applyStates(false, true, false, "", "Contract fixture", "", true); + else if (name === "screen-on") + root.applyStates(false, false, true, "", "", "Contract fixture", true); + else if (name === "all-off") + root.applyStates(false, false, false, "", "", "", true); + } + + function clearFixture(): void { + root.fixtureMode = false; + // The next real probe is discovery, not a transition away from test + // data; otherwise contract cleanup would flash a bogus release event. + root.initialized = false; + probe.running = false; + probe.running = true; + } + + Timer { + interval: 2500 + repeat: true + running: true + triggeredOnStart: true + onTriggered: { + if (!root.fixtureMode && !probe.running) + probe.running = true; + } + } + + Process { + id: probe + command: ["pw-dump"] + stdout: StdioCollector { + onStreamFinished: root.applyProbe(this.text) + } + onExited: (code, status) => { + if (code !== 0 && !root.warned) { + console.warn("PrivacyState: pw-dump exited with", code); + root.warned = true; + } + } + } +} diff --git a/config/dot/quickshell/services/ScreenIntelligence.qml b/config/dot/quickshell/services/ScreenIntelligence.qml new file mode 100644 index 0000000..d03e6b8 --- /dev/null +++ b/config/dot/quickshell/services/ScreenIntelligence.qml @@ -0,0 +1,209 @@ +pragma Singleton + +// Local screen recognition. Capture owns geometry; this service owns the +// Tesseract/ZBar process boundary, result state, explicit follow-up actions, +// and cleanup of temporary screen images. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/screen-intelligence" + + // "idle" | "capturing" | "reading" | "ready" | "error" + property string phase: "idle" + // Do not map the result surface until grim has finished, or the overlay + // can appear in the very pixels it is trying to analyze. + readonly property bool visible: phase !== "idle" && phase !== "capturing" + readonly property bool busy: phase === "capturing" || phase === "reading" + + property bool ocrReady: false + property bool codeReady: false + property bool englishReady: false + property string text: "" + property string codeType: "" + property string codeValue: "" + property string error: "" + property string imagePath: "" + property bool ownsImage: false + + readonly property string primaryUrl: root._firstWebUrl(root.codeValue) || root._firstWebUrl(root.text) + + function refresh(): void { + probeProc.running = false; + probeProc.running = true; + } + + function analyzeRegion(geometry: string, outputName: string): void { + root._prepare(); + root.imagePath = Quickshell.cachePath("screen-intelligence-" + Date.now() + ".png"); + root.ownsImage = true; + root.phase = "capturing"; + + const command = ["grim"]; + if (geometry !== "") + command.push("-g", geometry); + else if (outputName !== "") + command.push("-o", outputName); + command.push(root.imagePath); + + captureProc.command = command; + captureProc.running = false; + captureProc.running = true; + } + + function analyzeFile(path: string): void { + root._prepare(); + root.imagePath = path; + root.ownsImage = false; + root._startRecognition(); + } + + function copyText(): void { + if (root.text !== "") + Quickshell.execDetached(["wl-copy", root.text]); + } + + function copyCode(): void { + if (root.codeValue !== "") + Quickshell.execDetached(["wl-copy", root.codeValue]); + } + + function search(): void { + if (root.text === "") + return; + Quickshell.execDetached(["xdg-open", "https://www.google.com/search?q=" + encodeURIComponent(root.text.slice(0, 4000))]); + } + + function translate(): void { + if (root.text === "") + return; + Quickshell.execDetached(["xdg-open", "https://translate.google.com/?sl=auto&tl=en&text=" + encodeURIComponent(root.text.slice(0, 4000))]); + } + + function openDetected(): void { + if (root.primaryUrl !== "") + Quickshell.execDetached(["xdg-open", root.primaryUrl]); + } + + function close(): void { + captureProc.running = false; + recognizeProc.running = false; + root._dropOwnedImage(); + root.phase = "idle"; + root.text = ""; + root.codeType = ""; + root.codeValue = ""; + root.error = ""; + root.imagePath = ""; + root.ownsImage = false; + } + + function _prepare(): void { + captureProc.running = false; + recognizeProc.running = false; + root._dropOwnedImage(); + root.text = ""; + root.codeType = ""; + root.codeValue = ""; + root.error = ""; + root.imagePath = ""; + root.ownsImage = false; + } + + function _startRecognition(): void { + root.phase = "reading"; + recognizeProc.sawOutput = false; + recognizeProc.command = [root.helperPath, "analyze-file", root.imagePath]; + recognizeProc.running = false; + recognizeProc.running = true; + } + + function _consume(raw: string): void { + try { + const result = JSON.parse(raw); + if (!result.ok) { + root.error = result.error || "Panama could not read that capture."; + root.phase = "error"; + return; + } + root.text = (result.text || "").trim(); + root.codeType = result.codeType || ""; + root.codeValue = result.codeValue || ""; + root.error = ""; + root.phase = "ready"; + } catch (e) { + root.error = "The local recognition engine returned an unreadable result."; + root.phase = "error"; + } + } + + function _dropOwnedImage(): void { + if (root.ownsImage && root.imagePath !== "") + Quickshell.execDetached(["rm", "-f", root.imagePath]); + } + + function _firstWebUrl(value: string): string { + if (!value) + return ""; + const match = value.match(/https?:\/\/[^\s<>'\"]+/i); + if (!match) + return ""; + return match[0].replace(/[),.;!?]+$/, ""); + } + + Process { + id: probeProc + command: [root.helperPath, "probe"] + stdout: StdioCollector { + onStreamFinished: { + try { + const result = JSON.parse(this.text); + root.ocrReady = result.tesseract === true; + root.codeReady = result.zbar === true; + root.englishReady = result.english === true; + } catch (e) { + root.ocrReady = false; + root.codeReady = false; + root.englishReady = false; + } + } + } + } + + Process { + id: captureProc + onExited: (code, status) => { + if (root.phase !== "capturing") + return; + if (code !== 0) { + root.error = "Panama could not capture the selected area."; + root.phase = "error"; + return; + } + root._startRecognition(); + } + } + + Process { + id: recognizeProc + property bool sawOutput: false + stdout: StdioCollector { + onStreamFinished: { + recognizeProc.sawOutput = this.text !== ""; + root._consume(this.text); + } + } + onExited: (code, status) => { + if (!recognizeProc.sawOutput && root.phase === "reading") { + root.error = "The local recognition engine did not return a result."; + root.phase = "error"; + } + } + } + + Component.onCompleted: root.refresh() +} diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml new file mode 100644 index 0000000..f0fb240 --- /dev/null +++ b/config/dot/quickshell/services/ShellState.qml @@ -0,0 +1,93 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Shared UI state. +// +// Every overlay in the shell is mutually exclusive with the others -- opening +// the overview should close the quick settings, and so on. Centralising that +// here means no module needs a reference to any other module, and the IPC +// handlers in shell.qml have exactly one thing to talk to. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import QtQuick +import qs.config + +Singleton { + id: root + + // Exactly one of these may be non-empty at a time. + // "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu" + property string activeOverlay: "" + + readonly property bool overviewOpen: activeOverlay === "overview" + readonly property bool quickSettingsOpen: activeOverlay === "quicksettings" + readonly property bool notificationsOpen: activeOverlay === "notifications" + readonly property bool clipboardOpen: activeOverlay === "clipboard" + readonly property bool captureOpen: activeOverlay === "capture" + readonly property bool activityOpen: activeOverlay === "activity" + readonly property bool powerMenuOpen: activeOverlay === "powermenu" + + // Settings is a normal application window rather than a transient overlay. + // It can stay open while Quick Settings or the notification center appears. + property bool settingsOpen: false + property string settingsPage: "home" + + readonly property bool anyOverlayOpen: activeOverlay !== "" + + // 0 means "use the currently focused workspace". A positive value lets a + // contextual surface, such as Focus, open Mission Control at its target. + property int overviewWorkspaceId: 0 + property string overviewQuery: "" + + function toggle(name: string): void { + root.activeOverlay = (root.activeOverlay === name) ? "" : name; + } + + function open(name: string): void { + root.activeOverlay = name; + } + + function openOverview(workspaceId: int): void { + root.overviewWorkspaceId = Math.max(0, workspaceId); + root.overviewQuery = ""; + root.activeOverlay = "overview"; + } + + function searchOverview(query: string): void { + root.overviewWorkspaceId = 0; + root.overviewQuery = query; + root.activeOverlay = "overview"; + } + + function close(): void { + if (root.activeOverlay === "overview") { + root.overviewWorkspaceId = 0; + root.overviewQuery = ""; + } + root.activeOverlay = ""; + } + + function openSettings(page: string): void { + const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"]; + root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; + DesktopPreferences.lastPage = root.settingsPage; + root.settingsOpen = true; + } + + function toggleSettings(): void { + if (root.settingsOpen) { + root.closeSettings(); + return; + } + root.openSettings(DesktopPreferences.lastPage || "home"); + } + + function closeSettings(): void { + root.settingsOpen = false; + } + + // Set by Dock.qml so the bar can avoid fighting it for pointer grabs, and + // read by the capture overlay so the dock isn't in the screenshot. + property bool dockRevealed: false +} diff --git a/config/dot/quickshell/services/StatusEvents.qml b/config/dot/quickshell/services/StatusEvents.qml new file mode 100644 index 0000000..85078e2 --- /dev/null +++ b/config/dot/quickshell/services/StatusEvents.qml @@ -0,0 +1,151 @@ +pragma Singleton + +// Curated shell feedback. This is deliberately not a notification store: one +// event is visible, at most four wait behind it, and expired entries vanish. + +import Quickshell +import Quickshell.Hyprland +import QtQuick + +Singleton { + id: root + + readonly property int ambientPriority: 10 + readonly property int importantPriority: 50 + readonly property int criticalPriority: 90 + + property var activeEvent: null + property var pendingEvents: [] + property int sequence: 0 + + readonly property bool active: root.activeEvent !== null + readonly property int queueLength: root.pendingEvents.length + + signal eventPublished(var event) + signal eventDismissed(string key) + + Timer { + id: expiryTimer + repeat: false + onTriggered: root.dismiss() + } + + function publish(candidate: var): bool { + const event = root.normalize(candidate); + if (!event) + return false; + + // DND is allowed to quiet device ambience, but it must never hide a + // privacy transition or the result of something the user initiated. + if (Notifs.doNotDisturb && event.priority < root.importantPriority) + return false; + + if (root.activeEvent && root.activeEvent.key === event.key) { + root.activeEvent = event; + root.armExpiry(); + root.eventPublished(event); + return true; + } + + const withoutEquivalent = root.pendingEvents.filter(item => item.key !== event.key); + if (!root.activeEvent || event.priority > root.activeEvent.priority) { + if (root.activeEvent) + withoutEquivalent.push(root.activeEvent); + root.pendingEvents = root.trimQueue(withoutEquivalent); + root.activeEvent = event; + root.armExpiry(); + } else { + withoutEquivalent.push(event); + root.pendingEvents = root.trimQueue(withoutEquivalent); + } + + root.eventPublished(event); + return true; + } + + function dismiss(): void { + if (!root.activeEvent) + return; + const dismissedKey = root.activeEvent.key; + expiryTimer.stop(); + root.activeEvent = null; + root.eventDismissed(dismissedKey); + root.advance(); + } + + function invoke(): void { + if (!root.activeEvent) + return; + + const action = root.activeEvent.actionId; + const data = root.activeEvent.actionData; + root.dismiss(); + + if (action === "focus-workspace") { + FocusSession.activateWorkspace(); + } else if (action === "open-workspace") { + const workspaceId = Number(data); + if (Number.isInteger(workspaceId) && workspaceId > 0) + Hyprland.dispatch(`hl.dsp.focus({ workspace = ${workspaceId} })`); + } else if (action === "open-activity") { + ShellState.open("activity"); + } else if (action === "open-path" && data) { + Quickshell.execDetached(["xdg-open", data]); + } + } + + function reset(): void { + expiryTimer.stop(); + root.activeEvent = null; + root.pendingEvents = []; + } + + function normalize(candidate: var): var { + if (!candidate || !candidate.key || !candidate.title) + return null; + + root.sequence++; + const priority = Number(candidate.priority ?? root.ambientPriority); + const duration = Number(candidate.durationMs ?? 3200); + return { + key: String(candidate.key), + icon: String(candidate.icon ?? "dialog-information-symbolic"), + glyph: String(candidate.glyph ?? ""), + title: String(candidate.title), + detail: String(candidate.detail ?? ""), + tone: String(candidate.tone ?? "accent"), + priority: Number.isFinite(priority) ? priority : root.ambientPriority, + durationMs: Math.max(1200, Number.isFinite(duration) ? duration : 3200), + actionId: String(candidate.actionId ?? ""), + actionData: String(candidate.actionData ?? ""), + monitorName: String(candidate.monitorName ?? ""), + sequence: root.sequence + }; + } + + function trimQueue(events: var): var { + const sorted = events.slice().sort((a, b) => { + if (a.priority !== b.priority) + return b.priority - a.priority; + return a.sequence - b.sequence; + }); + return sorted.slice(0, 4); + } + + function advance(): void { + if (root.pendingEvents.length === 0) + return; + const next = root.pendingEvents[0]; + root.pendingEvents = root.pendingEvents.slice(1); + root.activeEvent = next; + root.armExpiry(); + } + + function armExpiry(): void { + expiryTimer.stop(); + if (!root.activeEvent) + return; + expiryTimer.interval = root.activeEvent.durationMs; + expiryTimer.start(); + } +} diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml new file mode 100644 index 0000000..9f83aef --- /dev/null +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -0,0 +1,238 @@ +pragma Singleton + +// The allow-listed machine boundary for Panama Settings. Visual pages call +// these methods; no UI text is ever interpolated into a shell command. + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + property string monitorName: "" + property string monitorDescription: "" + property int monitorWidth: 0 + property int monitorHeight: 0 + property real monitorRefreshRate: 0 + property real monitorScale: 1 + property string monitorFormat: "" + property string colorPreset: "" + property bool monitorVrrActive: false + + property bool nextcloudActive: false + property bool rustdeskActive: false + property bool kdeconnectActive: false + property bool hyprpaperActive: false + property bool hypridleActive: false + property bool vicinaeActive: false + + property string hyprlandVersion: "" + property string quickshellVersion: "0.3.0" + property string lastError: "" + + readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running + || autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running + + readonly property bool autoHdr: DesktopPreferences.autoHdr + readonly property int vrrPolicy: DesktopPreferences.vrrPolicy + readonly property int directScanoutPolicy: DesktopPreferences.directScanoutPolicy + + Process { + id: monitorQuery + command: ["hyprctl", "-j", "monitors"] + stdout: StdioCollector { + onStreamFinished: root.parseMonitors(this.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + root.lastError = "Could not read the active display."; + } + } + + Process { + id: serviceQuery + command: [ + "bash", "-lc", + "printf '{\"nextcloud\":%s,\"rustdesk\":%s,\"kdeconnect\":%s,\"hyprpaper\":%s,\"hypridle\":%s,\"vicinae\":%s}\\n' " + + "$(pgrep -x nextcloud >/dev/null && printf true || printf false) " + + "$(systemctl is-active --quiet rustdesk.service && printf true || printf false) " + + "$(pgrep -x kdeconnectd >/dev/null && printf true || printf false) " + + "$(systemctl --user is-active --quiet hyprpaper.service && printf true || printf false) " + + "$(systemctl --user is-active --quiet hypridle.service && printf true || printf false) " + + "$(systemctl --user is-active --quiet vicinae.service && printf true || printf false)" + ] + stdout: StdioCollector { + onStreamFinished: root.parseServices(this.text) + } + } + + Process { + id: versionQuery + command: ["Hyprland", "--version"] + stdout: StdioCollector { + onStreamFinished: { + const match = this.text.match(/Hyprland\s+([0-9.]+)/); + root.hyprlandVersion = match ? match[1] : this.text.trim().split("\n")[0]; + } + } + } + + Process { + id: autoHdrWrite + property bool requested: true + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + DesktopPreferences.autoHdr = requested; + root.lastError = ""; + } else { + root.lastError = "Hyprland rejected the HDR policy."; + } + } + } + + Process { + id: vrrWrite + property int requested: 3 + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + DesktopPreferences.vrrPolicy = requested; + root.lastError = ""; + } else { + root.lastError = "Hyprland rejected the VRR policy."; + } + } + } + + Process { + id: directScanoutWrite + property int requested: 2 + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + DesktopPreferences.directScanoutPolicy = requested; + root.lastError = ""; + } else { + root.lastError = "Hyprland rejected the direct-scanout policy."; + } + } + } + + Timer { + // Preferences load asynchronously from disk. Applying after one quiet + // second avoids racing their restore and runs only once per shell start. + interval: 1000 + running: true + onTriggered: root.applyPersistedDisplayPolicy() + } + + Component.onCompleted: root.refresh() + + function refresh(): void { + root.lastError = ""; + if (!monitorQuery.running) + monitorQuery.running = true; + if (!serviceQuery.running) + serviceQuery.running = true; + if (!versionQuery.running && !root.hyprlandVersion) + versionQuery.running = true; + } + + function parseMonitors(text: string): void { + try { + const monitors = JSON.parse(text); + const monitor = monitors.find(item => item.focused) ?? monitors[0]; + if (!monitor) + throw new Error("No active monitor"); + root.monitorName = monitor.name ?? ""; + root.monitorDescription = monitor.description ?? monitor.model ?? "Display"; + root.monitorWidth = monitor.width ?? 0; + root.monitorHeight = monitor.height ?? 0; + root.monitorRefreshRate = monitor.refreshRate ?? 0; + root.monitorScale = monitor.scale ?? 1; + root.monitorFormat = monitor.currentFormat ?? ""; + root.colorPreset = monitor.colorManagementPreset ?? ""; + root.monitorVrrActive = monitor.vrr ?? false; + } catch (error) { + root.lastError = "The display response could not be read."; + } + } + + function parseServices(text: string): void { + try { + const state = JSON.parse(text); + root.nextcloudActive = state.nextcloud === true; + root.rustdeskActive = state.rustdesk === true; + root.kdeconnectActive = state.kdeconnect === true; + root.hyprpaperActive = state.hyprpaper === true; + root.hypridleActive = state.hypridle === true; + root.vicinaeActive = state.vicinae === true; + } catch (error) { + root.lastError = "Startup-service status could not be read."; + } + } + + function setAutoHdr(enabled: bool): void { + autoHdrWrite.requested = enabled; + autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]); + } + + function setVrrPolicy(policy: int): void { + if (policy !== 0 && policy !== 3) { + root.lastError = "Unsupported VRR policy."; + return; + } + vrrWrite.requested = policy; + vrrWrite.exec(["hyprctl", "keyword", "misc:vrr", String(policy)]); + } + + function setDirectScanoutPolicy(policy: int): void { + if (policy !== 0 && policy !== 2) { + root.lastError = "Unsupported direct-scanout policy."; + return; + } + directScanoutWrite.requested = policy; + directScanoutWrite.exec(["hyprctl", "keyword", "render:direct_scanout", String(policy)]); + } + + function applyPersistedDisplayPolicy(): void { + root.setAutoHdr(DesktopPreferences.autoHdr); + root.setVrrPolicy(DesktopPreferences.vrrPolicy); + root.setDirectScanoutPolicy(DesktopPreferences.directScanoutPolicy); + } + + function isGnomePanelAllowed(panel: string): bool { + return [ + "wifi", "network", "bluetooth", "sound", "power", "printers", + "online-accounts", "users", "mouse", "keyboard", "sharing" + ].indexOf(panel) >= 0; + } + + function openGnomePanel(panel: string): bool { + if (!root.isGnomePanelAllowed(panel)) { + root.lastError = "That GNOME Settings panel is not available."; + return false; + } + Quickshell.execDetached({ + command: ["gnome-control-center", panel], + environment: { "XDG_CURRENT_DESKTOP": "GNOME" } + }); + return true; + } + + function openApplication(id: string): bool { + const commands = { + "nextcloud": ["nextcloud"], + "rustdesk": ["rustdesk"], + "kdeconnect": ["kdeconnect-app"], + "mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"] + }; + const command = commands[id]; + if (!command) { + root.lastError = "That application is not managed by Panama Settings."; + return false; + } + Quickshell.execDetached(command); + return true; + } +} diff --git a/config/dot/quickshell/services/Vitals.qml b/config/dot/quickshell/services/Vitals.qml new file mode 100644 index 0000000..f65d7b8 --- /dev/null +++ b/config/dot/quickshell/services/Vitals.qml @@ -0,0 +1,128 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// System vitals — CPU, memory and GPU utilisation. +// +// Replaces the GNOME Vitals extension, which showed exactly these three in +// exactly this order. Everything comes from procfs/sysfs, so there are no +// subprocesses: one timer kicks three async re-reads of very small files and +// the parsing happens in the `loaded` handlers. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + // All three are percentages, 0-100. + property real cpu: 0 + property real memory: 0 + property real gpu: 0 + + // False when Settings.gpuBusyPath is missing or unreadable (no amdgpu, or a + // different card index). The widget hides the GPU field rather than + // reporting a permanent 0%. + property bool gpuAvailable: false + + // /proc/stat is cumulative since boot, so utilisation is only meaningful as + // a delta between two samples. These hold the previous one. + property real _prevTotal: 0 + property real _prevIdle: 0 + + Timer { + interval: Settings.vitalsIntervalMs + running: true + repeat: true + triggeredOnStart: true + onTriggered: { + cpuFile.reload(); + memFile.reload(); + if (Settings.showGpu) + gpuFile.reload(); + } + } + + // ── CPU ───────────────────────────────────────────────────────────────── + FileView { + id: cpuFile + path: "/proc/stat" + printErrors: false + onLoaded: root._parseCpu(text()) + } + + // ── Memory ────────────────────────────────────────────────────────────── + FileView { + id: memFile + path: "/proc/meminfo" + printErrors: false + onLoaded: root._parseMemory(text()) + } + + // ── GPU ───────────────────────────────────────────────────────────────── + // amdgpu exposes a bare integer 0-100 here. + FileView { + id: gpuFile + path: Settings.gpuBusyPath + printErrors: false + onLoaded: root._parseGpu(text()) + onLoadFailed: root.gpuAvailable = false + } + + function _parseCpu(text: string): void { + if (!text) + return; + + // First line is the aggregate: "cpu user nice system idle iowait ..." + const fields = text.slice(0, text.indexOf("\n")).split(/\s+/).slice(1).map(parseFloat).filter(n => !isNaN(n)); + if (fields.length < 5) + return; + + // idle + iowait — the kernel counts both as "not doing work". + const idle = fields[3] + fields[4]; + let total = 0; + for (let i = 0; i < fields.length; i++) + total += fields[i]; + + const dTotal = total - root._prevTotal; + const dIdle = idle - root._prevIdle; + root._prevTotal = total; + root._prevIdle = idle; + + // The very first sample has no predecessor; reporting it would show the + // since-boot average, which is never what you want. + if (dTotal <= 0) + return; + + root.cpu = Math.max(0, Math.min(100, (1 - dIdle / dTotal) * 100)); + } + + function _parseMemory(text: string): void { + // MemAvailable is the kernel's own estimate of what a new allocation + // could get; a much better "used" basis than MemFree. + const total = root._meminfoField(text, "MemTotal"); + const available = root._meminfoField(text, "MemAvailable"); + if (total <= 0 || available < 0) + return; + + root.memory = Math.max(0, Math.min(100, (1 - available / total) * 100)); + } + + function _meminfoField(text: string, key: string): real { + const match = text.match(new RegExp("^" + key + ":\\s+(\\d+)", "m")); + return match ? parseFloat(match[1]) : -1; + } + + function _parseGpu(text: string): void { + const value = parseInt(text, 10); + if (isNaN(value)) { + root.gpuAvailable = false; + return; + } + + root.gpuAvailable = true; + root.gpu = Math.max(0, Math.min(100, value)); + } +} diff --git a/config/dot/quickshell/services/Weather.qml b/config/dot/quickshell/services/Weather.qml new file mode 100644 index 0000000..ce7a6e6 --- /dev/null +++ b/config/dot/quickshell/services/Weather.qml @@ -0,0 +1,192 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Current conditions from Open-Meteo (no API key, no account). +// +// Fetched with curl rather than XMLHttpRequest: curl is guaranteed present and +// the response is a single small JSON blob, so there is nothing to stream. +// +// Failure is silent by design — a weather widget that can't reach the network +// must not produce error popups or retry storms. A failed fetch just leaves +// `available` false until the next scheduled refresh. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + // False until a fetch has succeeded, and again after one fails. + property bool available: false + + property real temperature: 0 + property int weatherCode: -1 + + // Nerd Font glyph and a short human label for `weatherCode`. + readonly property string icon: root._codeIcon(root.weatherCode) + readonly property string description: root._codeDescription(root.weatherCode) + + readonly property string unitSuffix: Settings.temperatureUnit === "fahrenheit" ? "°F" : "°C" + + readonly property string url: "https://api.open-meteo.com/v1/forecast" + "?latitude=" + Settings.latitude + "&longitude=" + Settings.longitude + "¤t=temperature_2m,weather_code" + "&temperature_unit=" + Settings.temperatureUnit + + // The timer is the *only* thing that starts a fetch, so a hard failure can + // never retry faster than the refresh interval. + Timer { + interval: Settings.weatherRefreshMinutes * 60 * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refresh() + } + + Process { + id: fetch + // -s silences the progress meter, --max-time keeps a black-holed + // connection from leaving the process alive until the next refresh. + command: ["curl", "-s", "--max-time", "10", root.url] + + stdout: StdioCollector { + onStreamFinished: root._parse(this.text) + } + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + root.available = false; + } + } + + function refresh(): void { + // Skip if the previous fetch is somehow still in flight. + if (!fetch.running) + fetch.running = true; + } + + function _parse(text: string): void { + if (!text) { + root.available = false; + return; + } + + try { + const current = JSON.parse(text).current; + if (!current || current.temperature_2m === undefined) { + root.available = false; + return; + } + + root.temperature = current.temperature_2m; + root.weatherCode = current.weather_code; + root.available = true; + } catch (e) { + root.available = false; + } + } + + // ── WMO 4677 weather codes ────────────────────────────────────────────── + // Open-Meteo's `weather_code` is the WMO set. Grouped here into the same + // buckets GNOME Weather uses, because the distinctions finer than this are + // not legible at bar size. + // Glyphs come from the nf-weather range. They are written as escapes rather + // than literal characters so tooling that mishandles private-use codepoints + // cannot silently eat them. + function _codeIcon(code: int): string { + switch (code) { + case 0: + return "\u{E30D}"; // weather-day_sunny + case 1: + return "\u{E30C}"; // weather-day_sunny_overcast + case 2: + return "\u{E302}"; // weather-day_cloudy + case 3: + return "\u{E312}"; // weather-cloudy + case 45: + case 48: + return "\u{E313}"; // weather-fog + case 51: + case 53: + case 55: + return "\u{E31B}"; // weather-sprinkle + case 56: + case 57: + case 66: + case 67: + return "\u{E316}"; // weather-rain_mix (freezing) + case 61: + case 63: + case 65: + return "\u{E318}"; // weather-rain + case 71: + case 73: + case 75: + case 77: + case 85: + case 86: + return "\u{E31A}"; // weather-snow + case 80: + case 81: + case 82: + return "\u{E319}"; // weather-showers + case 95: + case 96: + case 99: + return "\u{E31D}"; // weather-thunderstorm + default: + return "\u{E374}"; // weather-na — unknown, or nothing fetched yet + } + } + + function _codeDescription(code: int): string { + switch (code) { + case 0: + return "Clear"; + case 1: + return "Mainly clear"; + case 2: + return "Partly cloudy"; + case 3: + return "Overcast"; + case 45: + return "Fog"; + case 48: + return "Rime fog"; + case 51: + case 53: + case 55: + return "Drizzle"; + case 56: + case 57: + return "Freezing drizzle"; + case 61: + case 63: + case 65: + return "Rain"; + case 66: + case 67: + return "Freezing rain"; + case 71: + case 73: + case 75: + return "Snow"; + case 77: + return "Snow grains"; + case 80: + case 81: + case 82: + return "Showers"; + case 85: + case 86: + return "Snow showers"; + case 95: + return "Thunderstorm"; + case 96: + case 99: + return "Thunderstorm, hail"; + default: + return ""; + } + } +} diff --git a/config/dot/quickshell/settings-preferences-harness.qml b/config/dot/quickshell/settings-preferences-harness.qml new file mode 100644 index 0000000..95b0595 --- /dev/null +++ b/config/dot/quickshell/settings-preferences-harness.qml @@ -0,0 +1,42 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config + +ShellRoot { + IpcHandler { + target: "settings-pref-test" + + function applyFixture(): void { + DesktopPreferences.use24Hour = true; + DesktopPreferences.showSeconds = false; + DesktopPreferences.dockAutohide = false; + DesktopPreferences.focusDurationMinutes = 70; + DesktopPreferences.autoHdr = false; + DesktopPreferences.vrrPolicy = 0; + DesktopPreferences.directScanoutPolicy = 0; + DesktopPreferences.nightLightEnabled = true; + DesktopPreferences.nightLightAutomatic = true; + DesktopPreferences.nightLightTemperature = 4100; + DesktopPreferences.lastPage = "desktop"; + } + + function status(): string { + return JSON.stringify({ + use24Hour: DesktopPreferences.use24Hour, + showSeconds: DesktopPreferences.showSeconds, + dockAutohide: DesktopPreferences.dockAutohide, + focusDurationMinutes: DesktopPreferences.focusDurationMinutes, + autoHdr: DesktopPreferences.autoHdr, + vrrPolicy: DesktopPreferences.vrrPolicy, + directScanoutPolicy: DesktopPreferences.directScanoutPolicy, + nightLightEnabled: DesktopPreferences.nightLightEnabled, + nightLightAutomatic: DesktopPreferences.nightLightAutomatic, + nightLightTemperature: DesktopPreferences.nightLightTemperature, + lastPage: DesktopPreferences.lastPage, + stateDir: Quickshell.stateDir + }); + } + } +} diff --git a/config/dot/quickshell/settings-system-harness.qml b/config/dot/quickshell/settings-system-harness.qml new file mode 100644 index 0000000..5e110b4 --- /dev/null +++ b/config/dot/quickshell/settings-system-harness.qml @@ -0,0 +1,37 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + IpcHandler { + target: "settings-system-test" + + function refresh(): void { SystemSettings.refresh(); } + + function apply(autoHdr: bool, vrr: int, directScanout: int): void { + SystemSettings.setAutoHdr(autoHdr); + SystemSettings.setVrrPolicy(vrr); + SystemSettings.setDirectScanoutPolicy(directScanout); + } + + function panelAllowed(panel: string): bool { + return SystemSettings.isGnomePanelAllowed(panel); + } + + function status(): string { + return JSON.stringify({ + monitorName: SystemSettings.monitorName, + monitorDescription: SystemSettings.monitorDescription, + width: SystemSettings.monitorWidth, + height: SystemSettings.monitorHeight, + scale: SystemSettings.monitorScale, + format: SystemSettings.monitorFormat, + busy: SystemSettings.busy, + lastError: SystemSettings.lastError + }); + } + } +} diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml new file mode 100644 index 0000000..cb5881b --- /dev/null +++ b/config/dot/quickshell/shell.qml @@ -0,0 +1,331 @@ +//@ pragma IconTheme Adwaita +//@ pragma DropExpensiveFonts + +// ───────────────────────────────────────────────────────────────────────────── +// Panama :: Quickshell +// +// The desktop shell — top bar, dock, overview, quick settings, notifications, +// screenshot/record UI and power menu. Replaces GNOME Shell plus the Forge, +// Dash-to-Dock, Openbar, Vitals, AppIndicator, Caffeine and clipboard +// extensions it was carrying. +// +// This file is wiring only. It owns two things and nothing else: +// 1. Which windows exist, and on which screens. +// 2. The IPC surface that hypr/keybinds.lua calls into. +// +// All shared state lives in services/ShellState.qml, so no module needs a +// reference to any other module. +// +// Layer namespaces set by the modules below are matched by layer rules in +// hypr/rules.lua (blur, dim, no-screen-share). Keep the two in sync. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services +import qs.modules.bar +import qs.modules.dock +import qs.modules.overview +import qs.modules.quicksettings +import qs.modules.notifications +import qs.modules.capture +import qs.modules.powermenu +import qs.modules.clipboard +import qs.modules.datemenu +import qs.modules.focus +import qs.modules.signals +import qs.modules.settings + +// Clipboard and datemenu ship explicit qmldir manifests because they were +// added while this daily-driver shell was already running; that also keeps +// future hot reloads deterministic instead of relying on module synthesis. + +ShellRoot { + id: root + + // ── Per-screen surfaces ───────────────────────────────────────────────── + // Variants rebuilds these when monitors are added or removed, so plugging + // in a second display Just Works without a reload. + // + // Do NOT redeclare `required property var modelData` on the delegate here: + // Bar and Dock already declare a plain `modelData` and bind `screen` to it. + // Redeclaring it shadows theirs, `screen` never resolves, and the windows + // are constructed but silently never map — with no error logged. + Variants { + model: Quickshell.screens + Bar { + idleInhibited: Caffeine.enabled + onRequestQuickSettings: ShellState.toggle("quicksettings") + } + } + + Variants { + model: Quickshell.screens + Dock {} + } + + Variants { + model: Quickshell.screens + SignalGlass {} + } + + // ── Single-instance overlays ──────────────────────────────────────────── + // These are always constructed but only *visible* when ShellState says so. + // They're cheap while hidden, and keeping them alive means opening the + // overview or quick settings is instant rather than paying a load. + Overview {} + // QuickSettings is the window; QuickSettingsPanel is its content. + QuickSettings {} + DateMenu {} + ClipboardPanel {} + CaptureOverlay {} + IntelligenceResult {} + ActivityPanel {} + PowerMenu {} + SettingsWindow {} + + // Toasts are their own always-on layer; they must be able to appear + // without any overlay being open. + Toasts {} + + // ── IPC ───────────────────────────────────────────────────────────────── + // Called from hypr/keybinds.lua as `qs ipc call `. + // + // Every parameter AND the return type must be annotated, or Quickshell + // silently declines to register the function — it will not warn you. + + IpcHandler { + target: "overview" + function toggle(): void { ShellState.toggle("overview"); } + function open(): void { ShellState.openOverview(0); } + function search(query: string): void { ShellState.searchOverview(query); } + function close(): void { ShellState.close(); } + } + + IpcHandler { + target: "focus" + + function start(): void { FocusSession.startDefault(); } + function reveal(): void { FocusSession.reveal(); } + function dismiss(): void { FocusSession.dismiss(); } + function pause(): void { FocusSession.pauseOrResume(); } + function workspace(): void { FocusSession.activateWorkspace(); } + function overview(): void { + ShellState.openOverview(FocusSession.workspaceId); + FocusSession.dismiss(); + } + function end(): void { FocusSession.end(false); } + function status(): string { + return JSON.stringify({ + active: FocusSession.active, + paused: FocusSession.paused, + capsuleVisible: FocusSession.capsuleVisible, + workspaceId: FocusSession.workspaceId, + remaining: FocusSession.remainingText, + caffeine: Caffeine.enabled, + doNotDisturb: Notifs.doNotDisturb + }); + } + } + + // A small diagnostics surface doubles as a deterministic contract harness. + // Real producers call StatusEvents.publish() directly; fixtures never run + // unless explicitly requested over IPC by the test suite. + IpcHandler { + target: "status-events" + + function screenshot(path: string): void { + StatusEvents.publish({ + key: "capture-screenshot", + glyph: "\u{F0100}", + title: "Screenshot captured", + detail: path.split("/").pop(), + tone: "ok", + priority: StatusEvents.importantPriority, + actionId: "open-path", + actionData: path + }); + } + + function fixture(name: string): void { + if (name === "ambient") { + StatusEvents.publish({ + key: "device-output", + glyph: "\u{F07E7}", + title: "USB Audio", + detail: "Audio output connected", + priority: StatusEvents.ambientPriority + }); + } else if (name === "ambient-replacement") { + StatusEvents.publish({ + key: "device-output", + glyph: "\u{F07E7}", + title: "Studio Display", + detail: "Audio output connected", + priority: StatusEvents.ambientPriority + }); + } else if (name === "critical") { + StatusEvents.publish({ + key: "privacy-microphone", + glyph: "\u{F036C}", + title: "Microphone in use", + detail: "An application is capturing audio", + tone: "danger", + priority: StatusEvents.criticalPriority, + actionId: "open-activity" + }); + } + } + + function dismiss(): void { StatusEvents.dismiss(); } + function reset(): void { StatusEvents.reset(); } + function status(): string { + const event = StatusEvents.activeEvent; + return JSON.stringify({ + active: StatusEvents.active, + activeKey: event?.key ?? "", + title: event?.title ?? "", + priority: event?.priority ?? 0, + queueLength: StatusEvents.queueLength + }); + } + } + + IpcHandler { + target: "activity" + + function fixture(name: string): void { PrivacyState.applyFixture(name); } + function clear(): void { PrivacyState.clearFixture(); } + function open(): void { ShellState.open("activity"); } + function close(): void { ShellState.close(); } + function toggle(): void { ShellState.toggle("activity"); } + function stopRecording(): void { Capture.stopRecording(); } + function status(): string { + return JSON.stringify({ + microphoneActive: PrivacyState.microphoneActive, + cameraActive: PrivacyState.cameraActive, + screenSharingActive: PrivacyState.screenSharingActive, + recordingActive: PrivacyState.recordingActive, + activeKinds: PrivacyState.activeKinds + }); + } + } + + // Referencing the transition monitor here ensures the singleton is alive + // even before any quick-settings device list has been opened. + Connections { target: DeviceEvents } + + IpcHandler { + target: "quicksettings" + function toggle(): void { ShellState.toggle("quicksettings"); } + } + + IpcHandler { + target: "settings" + function open(): void { ShellState.openSettings(DesktopPreferences.lastPage || "home"); } + function toggle(): void { ShellState.toggleSettings(); } + function close(): void { ShellState.closeSettings(); } + function page(name: string): void { ShellState.openSettings(name); } + function status(): string { + return JSON.stringify({ open: ShellState.settingsOpen, page: ShellState.settingsPage }); + } + } + + IpcHandler { + target: "settings-system" + function refresh(): void { SystemSettings.refresh(); } + function status(): string { + return JSON.stringify({ + monitor: SystemSettings.monitorName, + width: SystemSettings.monitorWidth, + height: SystemSettings.monitorHeight, + autoHdr: SystemSettings.autoHdr, + vrrPolicy: SystemSettings.vrrPolicy, + directScanoutPolicy: SystemSettings.directScanoutPolicy, + busy: SystemSettings.busy, + lastError: SystemSettings.lastError + }); + } + } + + IpcHandler { + target: "notifications" + function toggle(): void { ShellState.toggle("notifications"); } + function clear(): void { Notifs.dismissAll(); } + function dnd(): bool { + Notifs.doNotDisturb = !Notifs.doNotDisturb; + return Notifs.doNotDisturb; + } + } + + IpcHandler { + target: "clipboard" + function toggle(): void { ShellState.toggle("clipboard"); } + function open(): void { ShellState.open("clipboard"); } + } + + IpcHandler { + target: "powermenu" + function toggle(): void { ShellState.toggle("powermenu"); } + } + + IpcHandler { + target: "capture" + // Pressing Print while recording stops the recording instead of + // opening the picker again — otherwise there's no way to stop it + // without the indicator. + function open(): void { + if (Capture.recording) + Capture.stopRecording(); + else + Capture.open(); + } + function screenNow(): void { Capture.screenNow(); } + function windowNow(): void { Capture.windowNow(); } + function stop(): void { Capture.stopRecording(); } + function close(): void { Capture.close(); } + function status(): string { + return JSON.stringify({ + open: ShellState.captureOpen, + mode: Capture.mode, + recordMode: Capture.recordMode, + intelligenceMode: Capture.intelligenceMode + }); + } + } + + IpcHandler { + target: "screen-intelligence" + function open(): void { Capture.openIntelligence(); } + function close(): void { ScreenIntelligence.close(); } + function analyzeFile(path: string): void { ScreenIntelligence.analyzeFile(path); } + function copy(): void { ScreenIntelligence.copyText(); } + function status(): string { + return JSON.stringify({ + phase: ScreenIntelligence.phase, + ocrReady: ScreenIntelligence.ocrReady, + codeReady: ScreenIntelligence.codeReady, + englishReady: ScreenIntelligence.englishReady, + text: ScreenIntelligence.text, + codeType: ScreenIntelligence.codeType, + codeValue: ScreenIntelligence.codeValue, + error: ScreenIntelligence.error + }); + } + } + + // ── Diagnostics ───────────────────────────────────────────────────────── + // Quickshell pops a reload notice on every hot reload, which is noise when + // you're editing. Failures still surface in the log. + Connections { + target: Quickshell + function onReloadCompleted(): void { Quickshell.inhibitReloadPopup(); } + function onReloadFailed(error: string): void { + console.warn("Panama shell reload failed:", error); + Quickshell.inhibitReloadPopup(); + } + } +} diff --git a/config/dot/quickshell/widgets/Pill.qml b/config/dot/quickshell/widgets/Pill.qml new file mode 100644 index 0000000..389feb2 --- /dev/null +++ b/config/dot/quickshell/widgets/Pill.qml @@ -0,0 +1,73 @@ +// A bar module container: rounded, hover-highlighted, optionally clickable. +// Every widget that sits in the top bar wraps itself in one of these so +// spacing, hover feedback and press feedback stay identical across the bar. + +import QtQuick +import qs.config + +Rectangle { + id: root + + // Fired on left click; if nothing is connected the pill is inert and + // shows no hover affordance. + signal activated + signal secondaryActivated + signal scrolled(int delta) + + property alias hovered: mouse.containsMouse + property bool interactive: true + property int horizontalPadding: 10 + + default property alias content: layout.data + + implicitWidth: layout.implicitWidth + horizontalPadding * 2 + implicitHeight: Theme.barHeight - 8 + + radius: Theme.pillRadius + // border.width: 0 works around QTBUG-137166, where transparent rounded + // rectangles can render with holes at the corners. + border.width: 0 + + color: { + if (!interactive) + return "transparent"; + if (mouse.pressed) + return Theme.alpha(Theme.accent, Theme.activeAlpha); + if (mouse.containsMouse) + return Theme.alpha(Theme.fg, Theme.hoverAlpha); + return "transparent"; + } + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + easing.type: Easing.OutQuad + } + } + + Row { + id: layout + anchors.centerIn: parent + spacing: 6 + } + + MouseArea { + id: mouse + anchors.fill: parent + enabled: root.interactive + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: root.interactive ? Qt.PointingHandCursor : Qt.ArrowCursor + + onClicked: event => { + if (event.button === Qt.RightButton) + root.secondaryActivated(); + else + root.activated(); + } + + onWheel: event => { + root.scrolled(event.angleDelta.y > 0 ? 1 : -1); + } + } +} diff --git a/config/dot/quickshell/widgets/Popover.qml b/config/dot/quickshell/widgets/Popover.qml new file mode 100644 index 0000000..199c59d --- /dev/null +++ b/config/dot/quickshell/widgets/Popover.qml @@ -0,0 +1,90 @@ +// A themed popup panel anchored under a bar item, in the style of GNOME's +// shell menus: near-opaque, heavily rounded, blurred behind (the blur comes +// from the `qs-popover` layer rule in hypr/rules.lua), closing on outside click. +// +// Usage: +// Popover { +// id: pop +// anchorItem: someBarPill +// Column { ... } +// } +// ... +// pop.visible = true + +import Quickshell +import QtQuick +import qs.config + +PopupWindow { + id: root + + // The bar item this popover hangs from. + property Item anchorItem: null + property int contentPadding: Theme.popoverPadding + + default property alias content: container.data + + anchor.item: anchorItem + // Attach to the bottom-left of the anchor and grow down-right. + anchor.edges: Edges.Bottom | Edges.Left + anchor.gravity: Edges.Bottom | Edges.Right + anchor.margins.top: 8 + + implicitWidth: Math.max(container.implicitWidth + contentPadding * 2, 240) + implicitHeight: container.implicitHeight + contentPadding * 2 + + color: "transparent" + visible: false + + // Clicking anywhere outside dismisses the popover and clears `visible`. + grabFocus: true + + Rectangle { + id: surface + anchors.fill: parent + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + // The prism edge — see widgets/PrismEdge.qml. Sits just inside the + // 1px border so the two don't fight for the same row of pixels. + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + // Scale + fade in. Runs once on open, never while idle. + transform: Scale { + id: popScale + origin.x: 0 + origin.y: 0 + xScale: 1 + yScale: 1 + } + + Item { + id: container + anchors.fill: parent + anchors.margins: root.contentPadding + } + } + + onVisibleChanged: { + if (visible) + openAnim.restart(); + } + + NumberAnimation { + id: openAnim + target: popScale + property: "yScale" + from: 0.96 + to: 1.0 + duration: Theme.durNormal + easing.type: Easing.OutCubic + } +} diff --git a/config/dot/quickshell/widgets/PrismEdge.qml b/config/dot/quickshell/widgets/PrismEdge.qml new file mode 100644 index 0000000..090e905 --- /dev/null +++ b/config/dot/quickshell/widgets/PrismEdge.qml @@ -0,0 +1,57 @@ +// The signature: a one-pixel hairline along the top edge of a glass surface, +// running blue on the left to orchid on the right and fading out before it +// reaches either corner. +// +// It reads as light catching the bevel of a pane of glass, which is why it sits +// at the very top edge and nowhere else — put it on all four sides and it stops +// being light and becomes a border. +// +// Every glass surface in the shell carries one, so the bar, dock, popovers and +// notification cards all read as the same material. +// +// Usage — anchor it to the top of the surface and inset it past the corner +// radius so it doesn't overhang the curve: +// +// PrismEdge { +// anchors { top: parent.top; left: parent.left; right: parent.right } +// inset: parent.radius +// } + +import QtQuick +import qs.config + +Rectangle { + id: root + + // Horizontal inset, normally the parent's corner radius. Below that the + // line runs out onto the curve and looks like a rendering artefact. + property real inset: Theme.popoverRadius + + anchors.leftMargin: root.inset + anchors.rightMargin: root.inset + + implicitHeight: 1 + height: 1 + + // The colour stops are the only place the two accents touch. + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + position: 0.0 + color: Theme.alpha(Theme.accent, 0) + } + GradientStop { + position: Theme.prismStart + color: Theme.alpha(Theme.accent, Theme.prismEdgeAlpha) + } + GradientStop { + position: Theme.prismEnd + color: Theme.alpha(Theme.accentSecondary, Theme.prismEdgeAlpha) + } + GradientStop { + position: 1.0 + color: Theme.alpha(Theme.accentSecondary, 0) + } + } +} diff --git a/config/dot/quickshell/widgets/ThemedIcon.qml b/config/dot/quickshell/widgets/ThemedIcon.qml new file mode 100644 index 0000000..b442257 --- /dev/null +++ b/config/dot/quickshell/widgets/ThemedIcon.qml @@ -0,0 +1,50 @@ +// A freedesktop symbolic icon, recoloured to a Theme token. +// +// Adwaita's symbolic SVGs carry a hardcoded near-black fill (#2e3436) and Qt +// does not do GTK's runtime recolouring, so drawn as-is they are invisible on +// a Tokyo Night background — verified on this machine. The fix is to paint a +// themed rectangle through the icon's alpha channel. + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import qs.config + +Item { + id: root + + property string icon: "" + property string iconFallback: "dialog-information-symbolic" + property color tint: Theme.fg + property int size: 18 + + implicitWidth: root.size + implicitHeight: root.size + + // Both inputs are invisible but layered, so they render into textures + // without ever being composited onto the panel themselves. + IconImage { + id: glyph + anchors.fill: parent + asynchronous: true + source: Quickshell.iconPath(root.icon, root.iconFallback) + visible: false + layer.enabled: true + } + + Rectangle { + id: fill + anchors.fill: parent + color: root.tint + visible: false + layer.enabled: true + } + + MultiEffect { + anchors.fill: parent + source: fill + maskEnabled: true + maskSource: glyph + } +} diff --git a/config/dot/quickshell/widgets/Toggle.qml b/config/dot/quickshell/widgets/Toggle.qml new file mode 100644 index 0000000..13600b9 --- /dev/null +++ b/config/dot/quickshell/widgets/Toggle.qml @@ -0,0 +1,105 @@ +// A GNOME-style quick-settings tile: big rounded button with an icon and a +// label, filled with the accent colour when active. + +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.config + +Rectangle { + id: root + + property string icon: "" // freedesktop icon name + property string label: "" + property string sublabel: "" + property bool active: false + property bool enabled: true + + signal toggled + // Emitted on right-click / long press — used to open the relevant detail + // panel (pick a wifi network, pick an output device). + signal expanded + + implicitWidth: 168 + implicitHeight: 56 + radius: Theme.cardRadius + border.width: 0 + + opacity: enabled ? 1.0 : 0.45 + + color: { + if (root.active) + return mouse.containsMouse ? Qt.lighter(Theme.accent, 1.12) : Theme.accent; + if (mouse.pressed) + return Theme.alpha(Theme.fg, Theme.activeAlpha); + if (mouse.containsMouse) + return Theme.alpha(Theme.fg, Theme.hoverAlpha); + return Theme.alpha(Theme.fg, 0.07); + } + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } + } + + readonly property color contentColor: root.active ? Theme.bgDark : Theme.fg + + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 10 + + // ThemedIcon, not a bare IconImage: Adwaita's symbolic SVGs carry a + // hardcoded #2e3436 fill and Qt does not recolour them the way GTK + // does, so drawn directly they are all but invisible on this palette. + ThemedIcon { + anchors.verticalCenter: parent.verticalCenter + size: 22 + icon: root.icon + tint: root.contentColor + visible: root.icon !== "" + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + text: root.label + color: root.contentColor + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + elide: Text.ElideRight + width: Math.min(implicitWidth, 108) + } + + Text { + text: root.sublabel + visible: root.sublabel !== "" + color: root.active ? Theme.alpha(Theme.bgDark, 0.7) : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + width: Math.min(implicitWidth, 108) + } + } + } + + MouseArea { + id: mouse + anchors.fill: parent + enabled: root.enabled + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: event => { + if (event.button === Qt.RightButton) + root.expanded(); + else + root.toggled(); + } + } +} diff --git a/config/dot/quickshell/widgets/ValueSlider.qml b/config/dot/quickshell/widgets/ValueSlider.qml new file mode 100644 index 0000000..de2cdcf --- /dev/null +++ b/config/dot/quickshell/widgets/ValueSlider.qml @@ -0,0 +1,121 @@ +// A GNOME-style slider: a thick rounded track with an inline leading icon, +// used for volume and brightness in the quick settings panel. +// +// Deliberately not QtQuick.Controls.Slider — that pulls in a style plugin and +// fights the theme. This is ~50 lines and does exactly what's needed. + +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.config + +Item { + id: root + + property real value: 0 // 0.0 .. 1.0 + property string icon: "" + property bool enabled: true + + // Emitted continuously while dragging. + signal moved(real value) + + implicitWidth: 240 + implicitHeight: 32 + + opacity: enabled ? 1.0 : 0.45 + + // ThemedIcon, not a bare IconImage — see the note in Toggle.qml: Adwaita + // symbolic icons have a hardcoded near-black fill that Qt will not recolour. + ThemedIcon { + id: leadingIcon + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + size: 18 + icon: root.icon + tint: Theme.fg + visible: root.icon !== "" + } + + Rectangle { + id: track + anchors.left: leadingIcon.visible ? leadingIcon.right : parent.left + anchors.leftMargin: leadingIcon.visible ? 10 : 0 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 10 + radius: height / 2 + color: Theme.alpha(Theme.fg, 0.12) + border.width: 0 + + Rectangle { + id: fill + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * Math.max(0, Math.min(1, root.value)) + radius: parent.radius + border.width: 0 + + // The gradient is anchored to the full track, not to the fill, so + // the colour at a given position never moves as the value changes — + // dragging reveals the prism rather than squashing it. + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + position: 0.0 + color: Theme.accent + } + GradientStop { + position: 1.0 + color: Theme.mix(Theme.accent, Theme.accentSecondary, Math.max(0.001, Math.min(1, root.value))) + } + } + + // Only animates when the value changes from outside (e.g. a media + // key); dragging sets it directly and reads as instant. + Behavior on width { + enabled: !drag.pressed + NumberAnimation { + duration: Theme.durFast + easing.type: Easing.OutQuad + } + } + } + + Rectangle { + id: knob + width: 16 + height: 16 + radius: 8 + border.width: 0 + color: Theme.fg + anchors.verticalCenter: parent.verticalCenter + x: Math.max(0, Math.min(parent.width - width, fill.width - width / 2)) + visible: drag.containsMouse || drag.pressed + } + + MouseArea { + id: drag + anchors.fill: parent + anchors.margins: -8 // easier to grab than a 10px track + enabled: root.enabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + function apply(mouseX) { + const ratio = Math.max(0, Math.min(1, (mouseX + 8) / track.width)); + root.moved(ratio); + } + + onPressed: event => apply(event.x) + onPositionChanged: event => { + if (pressed) + apply(event.x); + } + onWheel: event => { + const step = event.angleDelta.y > 0 ? 0.05 : -0.05; + root.moved(Math.max(0, Math.min(1, root.value + step))); + } + } + } +} diff --git a/config/dot/uwsm/env b/config/dot/uwsm/env new file mode 100644 index 0000000..5dda632 --- /dev/null +++ b/config/dot/uwsm/env @@ -0,0 +1,56 @@ +# ───────────────────────────────────────────────────────────────────────────── +# uwsm environment — general (toolkit / theming / cursor) +# +# Why this file exists, and why it duplicates hypr/env.lua: +# +# The session logs in via "Hyprland (uwsm-managed)", which runs the compositor +# under the systemd user manager. Variables set with hl.env() in hyprland.lua +# are exported into the COMPOSITOR's environment -- but NOT into the systemd +# user manager. Anything started as a user unit (vicinae.service, +# hyprpolkitagent.service, hyprpaper.service, hyprsunset.service) and every app +# those units launch would therefore not see them. +# +# uwsm sources this file into the systemd activation environment before the +# session starts, which covers both. Keep the two in sync; hypr/env.lua remains +# the fallback for the plain (non-uwsm) "Hyprland" session. +# +# Format is plain shell: `export KEY=VALUE`. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Cursor ─────────────────────────────────────────────────────────────────── +# No HYPRCURSOR_THEME: oreo_blue_cursors is a plain XCursor theme with no +# hyprcursors/ directory, so hyprcursor would resolve it to nothing. +export XCURSOR_THEME=oreo_blue_cursors +export XCURSOR_SIZE=24 + +# ── Toolkits ───────────────────────────────────────────────────────────────── +export QT_QPA_PLATFORM="wayland;xcb" +# Derives the Qt palette from the live GTK3 theme (adw-gtk3-dark). Single +# value, not a list -- Qt splits on ":" and uses only the first token. +export QT_QPA_PLATFORMTHEME=gtk3 +export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 +export QT_AUTO_SCREEN_SCALE_FACTOR=1 + +export GDK_BACKEND=wayland,x11 +export SDL_VIDEODRIVER=wayland +export CLUTTER_BACKEND=wayland +export MOZ_ENABLE_WAYLAND=1 + +# Electron apps (Slack, Obsidian, Claude, Codex, BlueBubbles, Podman Desktop) +# pick Wayland from this rather than needing per-app flags. +export ELECTRON_OZONE_PLATFORM_HINT=auto + +# The systemd user manager can survive a GNOME logout, leaving this stale as +# `gnome` even after uwsm updates XDG_CURRENT_DESKTOP. Vicinae checks both and +# otherwise waits for a GNOME clipboard extension before falling back to the +# native Wayland data-control backend. +export XDG_SESSION_DESKTOP=Hyprland +export DESKTOP_SESSION=hyprland +export GDMSESSION=hyprland + +# ── Selection overlay theming ──────────────────────────────────────────────── +# slurp is used by screenshot tooling; match Tokyo Night Moon so the region +# picker doesn't flash a default-blue box. +export SLURP_ARGS="-d -c 82aaffff -b 2e2f3d99 -s 82aaff22 -B 2e2f3d55 -w 2" + +# XDG_CURRENT_DESKTOP and XDG_SESSION_TYPE are set by uwsm itself. diff --git a/config/dot/uwsm/env-hyprland b/config/dot/uwsm/env-hyprland new file mode 100644 index 0000000..ae9dada --- /dev/null +++ b/config/dot/uwsm/env-hyprland @@ -0,0 +1,28 @@ +# ───────────────────────────────────────────────────────────────────────────── +# uwsm environment — Hyprland-specific (HYPR* and AQ_*) +# +# uwsm sources this only for the Hyprland session, after ./env. Keeping the +# compositor-specific variables separate means the general env file stays +# reusable if another compositor is ever added. +# +# See ./env for why these live here instead of in hypr/env.lua. +# ───────────────────────────────────────────────────────────────────────────── + +# ── GPU selection ──────────────────────────────────────────────────────────── +# Discrete RX 7800 XT first (it drives DP-2), integrated second so anything +# plugged into its outputs still works. +# +# These are the colon-free stable symlinks created by the udev rule at +# config/copy/etc/udev/rules.d/99-panama-gpu.rules -- /dev/dri/cardN numbering +# is not stable across boots, and the /dev/dri/by-path/ names contain colons, +# which AQ_DRM_DEVICES itself uses as a separator. +# +# Guarded, because pointing this at a device that does not exist stops Hyprland +# from starting at all. +if [ -e /dev/dri/amd-dgpu ]; then + if [ -e /dev/dri/amd-igpu ]; then + export AQ_DRM_DEVICES=/dev/dri/amd-dgpu:/dev/dri/amd-igpu + else + export AQ_DRM_DEVICES=/dev/dri/amd-dgpu + fi +fi diff --git a/config/dot/vicinae/settings.json b/config/dot/vicinae/settings.json new file mode 100644 index 0000000..73e313c --- /dev/null +++ b/config/dot/vicinae/settings.json @@ -0,0 +1,14 @@ +// This configuration is merged with the default vicinae configuration file, which you can obtain by running the `vicinae config default` command. +// Every item defined in this file takes precedence over the values defined in the default config or any other imported file. +// +// You can make manual edits to this file, however you should keep in mind that this file may be written to by vicinae when a configuration change is made through the GUI. +// When that happens, any custom comments or formatting will be lost. +// +// If you want to maintain a configuration file with your own comments and formatting, you should create a separate file and add it to the 'imports' array. +// +// Learn more about configuration at https://docs.vicinae.com/config + +{ + "$schema": "https://vicinae.com/schemas/config.json", + "imports": ["vicinae.json"] +} diff --git a/config/dot/vicinae/themes/tokyonight-moon.toml b/config/dot/vicinae/themes/tokyonight-moon.toml new file mode 100644 index 0000000..0c5c7f9 --- /dev/null +++ b/config/dot/vicinae/themes/tokyonight-moon.toml @@ -0,0 +1,84 @@ +# Tokyo Night Moon — matches quickshell/config/Theme.qml and hypr/looks.lua. +# +# Vicinae ships tokyo-night.toml and tokyo-night-storm.toml but not Moon, which +# is the variant the rest of this desktop uses. Same schema as the bundled +# themes; select it with `vicinae theme set tokyonight-moon`. + +[meta] +version = 1 +name = "Tokyo Night Moon" +description = "Tokyo Night Moon, matched to the Panama Hyprland desktop." +variant = "dark" + +[colors.core] +background = "#222436" # bg +foreground = "#c8d3f5" # fg +secondary_background = "#1e2030" # bg_dark +border = "#4d5685" # a visible glass hairline +accent = "#82aaff" # blue +accent_foreground = "#1e2030" + +[colors.accents] +blue = "#82aaff" +green = "#c3e88d" +magenta = "#c099ff" +orange = "#ff966c" +purple = "#fca7ea" +red = "#ff757f" +yellow = "#ffc777" +cyan = "#86e1fc" + +[colors.main_window] +border = "#4d5685" +footer = { background = "colors.core.secondary_background" } + +[colors.settings_window] +border = "#444a73" + +[colors.shortcut] +border = "colors.core.border" + +[colors.text] +default = "colors.core.foreground" +muted = "#828bb8" # fg_dark +danger = "#ff757f" +success = "#c3e88d" +placeholder = "#636da6" # comment +selection = { background = "#82aaff", foreground = "#1e2030" } + +[colors.text.links] +default = "#82aaff" +visited = "#c099ff" + +[colors.input] +border = "#444a73" # terminal_black +border_focus = "#82aaff" +border_error = "#ff757f" + +[colors.button.primary] +background = "#2f334d" # bg_highlight +foreground = "#c8d3f5" +hover = { background = "#3b4261" } +focus = { outline = "colors.core.accent" } + +[colors.list.item.hover] +foreground = "#c8d3f5" +secondary_foreground = "#828bb8" + +[colors.list.item.selection] +background = "#354163" # blue side of the Prism selection +foreground = "#c8d3f5" +secondary_background = "#463e68" # orchid side of the Prism selection +secondary_foreground = "#c8d3f5" + +[colors.grid.item] +background = "#292d45" +hover = { outline = "#82aaff" } +selection = { outline = "#b172b0" } + +[colors.scrollbars] +background = "#3b4261" + +[colors.loading] +bar = "#82aaff" +spinner = "#c8d3f5" diff --git a/config/dot/vicinae/vicinae.json b/config/dot/vicinae/vicinae.json new file mode 100644 index 0000000..0795bfe --- /dev/null +++ b/config/dot/vicinae/vicinae.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://vicinae.com/schemas/config.json", + + // Vicinae is the launcher, opened by SUPER+A / SUPER+R / SUPER+SPACE. + // It replaces GNOME's app grid and run dialog, plus the clipboard-indicator + // and emoji-copy extensions, so its clipboard and emoji views are bound + // directly in hypr/keybinds.lua. + + // Vicinae 0.26 selects a theme per system appearance. Both point to Moon so + // the launcher never flashes or falls back to its stock palette while the + // desktop appearance is being initialized. + "theme": { + "light": { + "name": "tokyonight-moon", + "icon_theme": "auto" + }, + "dark": { + "name": "tokyonight-moon", + "icon_theme": "auto" + } + }, + + "favicon_service": "twenty", + + "font": { + "normal": { + "family": "Adwaita Sans" + } + }, + + // Spotlight in silhouette, Prism in material: at rest the launcher is a + // single centered search surface, then expands into the full command view as + // soon as a query is entered. Hyprland supplies the real background blur. + "launcher_window": { + "opacity": 0.86, + "material": "blur", + "rounding": 22, + "compact_mode": { + "enabled": true + }, + "size": { + "width": 860, + "height": 520 + }, + "client_side_decorations": { + "enabled": true, + "border_width": 1, + "shadow_size": 18 + }, + "layer_shell": { + "enabled": true, + "keyboard_interactivity": "exclusive", + "layer": "top" + } + }, + + // Escape backs out one level rather than closing outright, matching how the + // GNOME overview behaved. + "escape_key_behavior": "navigate_back", + "pop_on_backspace": true, + + // Exclusive layer-shell focus keeps the compact search field ready for the + // very first keystroke. Escape or the launcher key dismisses it reliably. + "close_on_focus_loss": false, + "pop_to_root_on_close": true, + + // Single click activates, matching GNOME's app grid. + "activate_on_single_click": true, + + // File search lives behind its own command rather than polluting root search + // results — the root search stays fast and predictable that way. + "search_files_in_root": false, + + // Needed for "paste to active window" and for snippet keyword expansion. + // Depends on the uinput module, which the package's modules-load.d entry + // loads at boot. + "input_server": { + "enabled": true + }, + + "telemetry": { + "system_info": false + }, + + // Ported from GNOME App Hider. Vicinae keeps these applications installed + // and launchable by ID, but omits them from normal launcher search. + "providers": { + "applications": { + "entrypoints": { + "org.gnome.Tour": { "enabled": false }, + "org.gnome.Yelp": { "enabled": false }, + "java-latest-openjdk-jconsole": { "enabled": false }, + "htop": { "enabled": false }, + "org.libreoffice.LibreOffice": { "enabled": false }, + "nvtop": { "enabled": false }, + "org.torproject.torbrowser-launcher.settings": { "enabled": false }, + "ranger": { "enabled": false }, + "btop": { "enabled": false }, + "io.github.benjamimgois.goverlay": { "enabled": false }, + "protontricks": { "enabled": false }, + "winetricks": { "enabled": false } + } + } + } +} diff --git a/config/dot/wofi/config b/config/dot/wofi/config new file mode 100644 index 0000000..99dd624 --- /dev/null +++ b/config/dot/wofi/config @@ -0,0 +1,21 @@ +# wofi — emergency fallback launcher only. +# +# Vicinae is the real launcher (SUPER+A / SUPER+R / SUPER+SPACE). This exists +# because vicinae runs as a systemd user service and Quickshell owns the bar: +# if either fails to start, you would otherwise have no way to launch an +# application without dropping to a TTY. Bound to SUPER+SHIFT+R. +# +# Keep it boring and dependency-free. It is insurance, not a daily driver. + +show=drun +prompt=Run +width=640 +height=420 +lines=10 +location=center +allow_images=true +image_size=32 +insensitive=true +no_actions=true +gtk_dark=true +term=kitty diff --git a/config/dot/wofi/style.css b/config/dot/wofi/style.css new file mode 100644 index 0000000..c529b90 --- /dev/null +++ b/config/dot/wofi/style.css @@ -0,0 +1,67 @@ +/* Tokyo Night Moon — matches quickshell/config/Theme.qml. + * Fallback launcher; see ./config for why this exists at all. */ + +* { + font-family: "Adwaita Sans", sans-serif; + font-size: 14px; +} + +window { + background-color: rgba(30, 32, 48, 0.96); /* bg_dark */ + border: 1px solid #444a73; /* terminal_black */ + border-radius: 18px; +} + +#input { + margin: 12px; + padding: 10px 14px; + border: 1px solid #3b4261; /* fg_gutter */ + border-radius: 12px; + background-color: #222436; /* bg */ + color: #c8d3f5; /* fg */ +} + +#input:focus { + border-color: #82aaff; /* accent */ +} + +#outer-box { + margin: 0; + padding: 0 8px 12px 8px; +} + +#scroll { + margin: 0; +} + +#inner-box { + margin: 0; +} + +#entry { + padding: 8px 10px; + border-radius: 12px; + color: #c8d3f5; +} + +#entry:selected { + background-color: #2f334d; /* bg_highlight */ + outline: none; +} + +#entry image { + margin-right: 10px; +} + +#text { + color: #c8d3f5; +} + +#entry:selected #text { + color: #ffffff; +} + +/* wofi renders no match text here; keep it muted rather than alarming. */ +#unselected { + color: #828bb8; /* fg_dark */ +} diff --git a/config/dot/xdg-desktop-portal/hyprland-portals.conf b/config/dot/xdg-desktop-portal/hyprland-portals.conf new file mode 100644 index 0000000..4657150 --- /dev/null +++ b/config/dot/xdg-desktop-portal/hyprland-portals.conf @@ -0,0 +1,27 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Portal backend routing for the Hyprland session +# +# This overrides /usr/share/xdg-desktop-portal/hyprland-portals.conf, which +# ships `default=hyprland;gtk` and no Secret mapping. +# +# Why each line: +# hyprland -> Screenshot, ScreenCast, GlobalShortcuts, InputCapture +# (this is what makes OBS / Sunshine / Zoom screen sharing work) +# gtk -> FileChooser, Settings, Notification, Print, Access +# Settings is load-bearing: xdg-desktop-portal-gtk reads +# gsettings' org.gnome.desktop.interface and serves +# org.freedesktop.appearance, which is how libadwaita apps know +# to be dark. +# Secret -> gnome-keyring. Its own portal file is UseIn=gnome, so under +# XDG_CURRENT_DESKTOP=Hyprland nothing would claim Secret and +# Flatpak apps needing credential storage would fail. +# +# xdg-desktop-portal-gnome stays installed but is inert here (its portal file +# is UseIn=gnome); it is still needed for the GNOME session. +# ───────────────────────────────────────────────────────────────────────────── + +[preferred] +default=hyprland;gtk +org.freedesktop.impl.portal.Screenshot=hyprland +org.freedesktop.impl.portal.ScreenCast=hyprland +org.freedesktop.impl.portal.Secret=gnome-keyring diff --git a/config/local/share/applications/gnome-settings-hyprland.desktop b/config/local/share/applications/gnome-settings-hyprland.desktop new file mode 100644 index 0000000..1b54fa4 --- /dev/null +++ b/config/local/share/applications/gnome-settings-hyprland.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=GNOME Settings +GenericName=Fedora System Settings +Comment=Open GNOME hardware, account, printer, and network panels +Exec=env XDG_CURRENT_DESKTOP=GNOME gnome-control-center +Icon=org.gnome.Settings +Terminal=false +StartupNotify=true +Categories=Settings; +Keywords=GNOME;Fedora;Network;Bluetooth;Printers;Users;Accounts; diff --git a/config/local/share/applications/panama-screen-intelligence.desktop b/config/local/share/applications/panama-screen-intelligence.desktop new file mode 100644 index 0000000..7d4eb0b --- /dev/null +++ b/config/local/share/applications/panama-screen-intelligence.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Screen Intelligence +GenericName=Screen Text Recognition +Comment=Read text, links, QR codes, and barcodes from the screen locally +Exec=qs ipc call screen-intelligence open +Icon=scanner-symbolic +Terminal=false +StartupNotify=false +Categories=Utility; +Keywords=OCR;Text;Screen;Capture;QR;Barcode;Translate;Panama; diff --git a/config/local/share/applications/panama-settings.desktop b/config/local/share/applications/panama-settings.desktop new file mode 100644 index 0000000..d730fbe --- /dev/null +++ b/config/local/share/applications/panama-settings.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Panama Settings +GenericName=System Settings +Comment=Configure the Panama Hyprland desktop +Exec=qs ipc call settings open +Icon=preferences-system-symbolic +Terminal=false +StartupNotify=false +Categories=Settings; +Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services; diff --git a/docs/superpowers/plans/2026-08-17-contextual-signal-glass.md b/docs/superpowers/plans/2026-08-17-contextual-signal-glass.md new file mode 100644 index 0000000..09664d6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-contextual-signal-glass.md @@ -0,0 +1,94 @@ +# Contextual Signal Glass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a curated event broker, quiet shared Signal Glass host, and persistent privacy/activity indicators. + +**Architecture:** Producers publish structured events to one bounded broker. A single per-screen host renders transient events ahead of existing focus controls, while persistent privacy state renders independently in the bar. + +**Tech Stack:** Quickshell QML, QtQuick, Quickshell PipeWire/Bluetooth/Io APIs, Hyprland, Bash contract tests + +**Spec:** `docs/superpowers/specs/2026-08-17-contextual-signal-glass-design.md` + +## Global Constraints + +- Initial discovery must not emit user-visible events. +- DND suppresses ambient events but never persistent privacy state. +- No idle visual animation or unbounded queue/process loop. +- Existing Focus behavior and tests must remain compatible. + +--- + +### Task 1: Status event broker + +**Files:** +- Create: `config/dot/quickshell/services/StatusEvents.qml` +- Modify: `config/dot/quickshell/shell.qml` +- Test: `tests/quickshell/status-events-contract.sh` + +**Interfaces:** +- Produces: `publish(event: var)`, `dismiss(): void`, `invoke(): void`, `activeEvent`, `active`, and IPC diagnostics. + +- [ ] Write an IPC contract that publishes ambient and critical fixtures, verifies replacement and DND filtering, and dismisses cleanly. +- [ ] Run the contract and verify it fails because the IPC target is absent. +- [ ] Implement broker normalization, priority, four-entry queue, stable-key replacement, expiry, and action routing. +- [ ] Wire a diagnostics-only IPC target and run the contract to green. + +### Task 2: Shared Signal Glass host + +**Files:** +- Create: `config/dot/quickshell/modules/signals/SignalGlass.qml` +- Create: `config/dot/quickshell/modules/signals/EventCard.qml` +- Create: `config/dot/quickshell/modules/signals/FocusCard.qml` +- Create: `config/dot/quickshell/modules/signals/qmldir` +- Modify: `config/dot/quickshell/shell.qml` +- Retire: `config/dot/quickshell/modules/focus/FocusCapsule.qml` + +**Interfaces:** +- Consumes: `StatusEvents.activeEvent`, `FocusSession.active`, and `FocusSession.capsuleVisible`. +- Produces: one `qs-signal-glass` layer surface per monitor. + +- [ ] Add a static source contract proving only one Signal Glass host is instantiated. +- [ ] Extract the existing focus presentation without changing its actions. +- [ ] Implement the compact event presentation and event-first/focus-second arbitration. +- [ ] Verify focus contracts and QML construction. + +### Task 3: Event producers and persistent activity + +**Files:** +- Create: `config/dot/quickshell/services/PrivacyState.qml` +- Create: `config/dot/quickshell/services/DeviceEvents.qml` +- Modify: `config/dot/quickshell/services/Capture.qml` +- Modify: `config/dot/quickshell/services/NightLight.qml` +- Modify: `config/dot/quickshell/services/FocusSession.qml` +- Test: `tests/quickshell/activity-state-contract.sh` + +**Interfaces:** +- Produces: `PrivacyState.microphoneActive`, `cameraActive`, `screenSharingActive`, `recordingActive`, `activeKinds`, and transition events. + +- [ ] Write contracts for capture events and independent persistent-state reporting. +- [ ] Instrument Panama-owned capture, Night Light, and focus completion transitions. +- [ ] Add initialized transition monitoring for output, Bluetooth, KDE Connect, and privacy activity. +- [ ] Run the contracts and verify missing optional integrations fail quietly. + +### Task 4: Bar indicator and detail panel + +**Files:** +- Create: `config/dot/quickshell/modules/bar/ActivityIndicator.qml` +- Create: `config/dot/quickshell/modules/bar/ActivityPanel.qml` +- Modify: `config/dot/quickshell/modules/bar/Bar.qml` +- Modify: `config/dot/quickshell/services/ShellState.qml` + +**Interfaces:** +- Consumes: `PrivacyState` booleans and `Capture.stopRecording()`. + +- [ ] Add the indicator beside Focus with semantic icon/color selection. +- [ ] Add a compact details popover and safe Stop action for Panama recording. +- [ ] Verify overlay exclusivity, click targets, and all active-state combinations. + +### Task 5: System verification + +- [ ] Run all Quickshell contract tests. +- [ ] Run QML/runtime diagnostics and inspect logs for warnings. +- [ ] Capture ambient, privacy, recording, and focus-restoration states. +- [ ] Restart the live Quickshell instance and repeat the smoke contract. diff --git a/docs/superpowers/plans/2026-08-17-continuum-scratchpad.md b/docs/superpowers/plans/2026-08-17-continuum-scratchpad.md new file mode 100644 index 0000000..b7332d3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-continuum-scratchpad.md @@ -0,0 +1,82 @@ +# Continuum Scratchpad and Window Actions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add address-scoped close and drag/drop workspace movement plus a global visual scratchpad shelf to Continuum. + +**Architecture:** Window components emit intent; OverviewBody owns Hyprland dispatches. Workspace cards and the shelf are drop destinations, and the special scratch workspace remains separate from the positive-workspace filmstrip. + +**Tech Stack:** Quickshell QML, QtQuick Drag APIs, Quickshell.Hyprland, Hyprland dispatchers, Bash contract tests + +**Spec:** `docs/superpowers/specs/2026-08-17-continuum-scratchpad-design.md` + +## Global Constraints + +- Every close and move command must target one normalized window address. +- Existing click-to-focus, search, and focus-workspace integration must remain intact. +- `Super+X` and `Super+Shift+X` semantics remain unchanged. +- Drag feedback is event-driven and stops immediately after drop/cancel. + +--- + +### Task 1: Address-scoped action boundary + +**Files:** +- Modify: `config/dot/quickshell/modules/overview/OverviewBody.qml` +- Test: `tests/quickshell/overview-window-actions-contract.sh` + +**Interfaces:** +- Produces: `addressOf(toplevel)`, `closeToplevel(toplevel)`, `moveToplevel(toplevel, workspaceName)`, and `restoreToplevel(toplevel)`. + +- [ ] Write a static/live contract proving move and close operations include an exact disposable-window address. +- [ ] Run it and verify the missing IPC/action surface fails. +- [ ] Implement normalized address helpers and bounded post-dispatch refresh. +- [ ] Run the contract to green without touching non-disposable windows. + +### Task 2: Draggable window chrome + +**Files:** +- Modify: `config/dot/quickshell/modules/overview/WindowThumbnail.qml` +- Modify: `config/dot/quickshell/modules/overview/WorkspaceCard.qml` + +**Interfaces:** +- Produces: activation, close, drag-start, and drag-finish signals carrying the toplevel. +- Consumes: workspace drop callback carrying toplevel and target workspace. + +- [ ] Add a source contract for hover-only close chrome and drag payload identity. +- [ ] Implement restrained hover/drag states with activation suppression after drag. +- [ ] Make workspace cards highlight and accept the exact toplevel payload. +- [ ] Verify clicking still focuses and dropping leaves Continuum open. + +### Task 3: Global scratchpad shelf + +**Files:** +- Create: `config/dot/quickshell/modules/overview/ScratchpadShelf.qml` +- Modify: `config/dot/quickshell/modules/overview/OverviewBody.qml` +- Test: `tests/quickshell/scratchpad-shelf-contract.sh` + +**Interfaces:** +- Consumes: Hyprland workspace named `special:scratch` and OverviewBody move/restore callbacks. +- Produces: always-available scratch drop target and populated live-card shelf. + +- [ ] Write a contract confirming the global special workspace and existing keybindings. +- [ ] Implement the collapsed empty target and expanded populated shelf. +- [ ] Wire drop-to-hide, click-to-restore, and address-scoped close. +- [ ] Verify the selected positive workspace remains stable while hiding windows. + +### Task 4: Keyboard navigation and visual refresh + +**Files:** +- Modify: `config/dot/quickshell/modules/overview/Overview.qml` +- Modify: `config/dot/quickshell/modules/overview/OverviewBody.qml` + +- [ ] Add workspace Left/Right, result navigation, Enter, Delete, and Escape handling. +- [ ] Refresh workspace/toplevel models and one-shot captures after successful actions. +- [ ] Verify keyboard and pointer flows do not double-activate. + +### Task 5: System verification + +- [ ] Run overview, focus, window-action, and scratchpad contracts. +- [ ] Use disposable windows to test move, hide, restore, and close end to end. +- [ ] Capture empty shelf, populated shelf, drop target, and close chrome states. +- [ ] Restart Quickshell and confirm the overview maps without warnings. diff --git a/docs/superpowers/plans/2026-08-17-focus-glass-continuum.md b/docs/superpowers/plans/2026-08-17-focus-glass-continuum.md new file mode 100644 index 0000000..aac2eb7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-focus-glass-continuum.md @@ -0,0 +1,115 @@ +# Focus Glass and Continuum Overview Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a persistent focus-session workflow with a Signal Glass control capsule and replace the current workspace-card overview with the GNOME-like Continuum filmstrip. + +**Architecture:** A singleton `FocusSession` service owns all timing and side effects, while focused QML modules render the capsule, bar indicator, Quick Settings entry point, and overview. `ShellState` carries only overlay selection and the requested overview workspace, keeping focus state independent from window state. + +**Tech Stack:** Quickshell 0.3.0, Qt 6 QML, Quickshell Hyprland IPC, Quickshell Io `FileView`/`JsonAdapter`, Hyprland 0.56 Lua configuration + +**Spec:** `docs/superpowers/specs/2026-08-17-focus-glass-continuum-design.md` + +## Global Constraints + +- Use the existing Tokyo Night Moon tokens and Prism edge; do not hardcode component colors. +- Use event-driven transitions only; no continuously repainting visual animations. +- Keep the current bar edge-to-edge and transparent. +- Preserve all existing overlay shortcuts and GNOME-derived workspace bindings. +- Add no package or background daemon. +- Persist only state transitions; do not write the remaining time every second. + +--- + +### Task 1: Focus-session state machine + +**Files:** +- Create: `config/dot/quickshell/services/FocusSession.qml` +- Modify: `config/dot/quickshell/config/Settings.qml` +- Test: temporary standalone Quickshell harness under `/tmp` + +**Interfaces:** +- Consumes: `Notifs.doNotDisturb`, `Caffeine.enabled`, `Hyprland.focusedWorkspace`, `Hyprland.dispatch()` +- Produces: `active`, `paused`, `capsuleVisible`, `workspaceId`, `workspaceLabel`, `remainingSeconds`, `remainingText`, `start(minutes)`, `startDefault()`, `pauseOrResume()`, `dismiss()`, `reveal()`, `activateWorkspace()`, `end(completed)` + +- [x] Add `focusDurationMinutes: 45` to `Settings.qml`. +- [x] Create a singleton with a wall-clock deadline and paused remaining duration. +- [x] Persist state atomically to `Quickshell.stateDir + "/focus-session.json"` with `FileView` and `JsonAdapter`. +- [x] Record and restore the pre-session DND and Caffeine values. +- [x] Resolve expiry on every one-second timer tick and immediately after state restoration. +- [x] Run executable contracts for active-to-ended state, pause stability, persisted restart restoration, and completion from an expired persisted deadline. + +### Task 2: Signal Glass capsule and compact indicator + +**Files:** +- Create: `config/dot/quickshell/modules/focus/FocusCapsule.qml` +- Create: `config/dot/quickshell/modules/focus/FocusIndicator.qml` +- Create: `config/dot/quickshell/modules/focus/FocusAction.qml` +- Modify: `config/dot/quickshell/modules/bar/Bar.qml` +- Modify: `config/dot/quickshell/shell.qml` +- Modify: `config/dot/hypr/rules.lua` + +**Interfaces:** +- Consumes: Task 1's `FocusSession` public properties and functions +- Produces: a `qs-focus-capsule` layer surface and a clickable bar indicator + +- [x] Build the compact indicator as a transparent-bar-native pill showing a focus glyph and tabular remaining time. +- [x] Build the capsule with a Prism glass surface, primary workspace target, explicit close control, and Pause/Resume, Workspace, and End actions. +- [x] Make the primary row activate the stored workspace and dismiss only the capsule. +- [x] Instantiate one capsule and add one indicator to the right-side bar row. +- [x] Add a focused Hyprland layer rule enabling blur, suppressing compositor animation, and excluding the capsule from screen sharing. +- [x] Reload Quickshell and verify dismiss/reopen and workspace activation through live state. + +### Task 3: Discoverable start paths and IPC + +**Files:** +- Modify: `config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml` +- Modify: `config/dot/quickshell/shell.qml` +- Modify: `config/dot/hypr/keybinds.lua` +- Modify: `config/dot/hypr/README.md` + +**Interfaces:** +- Consumes: Task 1's service and Task 2's capsule visibility +- Produces: Quick Settings Focus row, `focus` IPC target, `SUPER+SHIFT+F` binding + +- [x] Add a full-width Quick Settings row that starts the default session while idle and shows the capsule while active. +- [x] Register typed IPC functions `start()`, `reveal()`, `pause()`, `workspace()`, `overview()`, and `end()`. +- [x] Bind `SUPER+SHIFT+F` to `qs ipc call focus reveal`, where `reveal()` starts a default session if none exists. +- [x] Verify every function using `qs ipc call focus ...` and verify the Hyprland config. + +### Task 4: Continuum workspace filmstrip + +**Files:** +- Modify: `config/dot/quickshell/services/ShellState.qml` +- Modify: `config/dot/quickshell/modules/overview/Overview.qml` +- Modify: `config/dot/quickshell/modules/overview/OverviewBody.qml` +- Modify: `config/dot/quickshell/modules/overview/WorkspaceCard.qml` + +**Interfaces:** +- Consumes: `FocusSession.workspaceId`, `ShellState.overviewWorkspaceId`, Hyprland workspace and toplevel models +- Produces: `ShellState.openOverview(workspaceId)`, filmstrip selection, large workspace preview, cross-workspace window search + +- [x] Add requested-overview-workspace state without coupling `ShellState` to the focus service. +- [x] Replace the centred equal-card row with a top filmstrip and a large selected-workspace preview. +- [x] Keep the trailing dynamic-workspace card and one-shot thumbnail capture behavior. +- [x] Add wheel navigation over the strip and keep the overview mapped while switching workspaces. +- [x] Add a keyboard-focused search field and filtered cross-workspace window results. +- [x] Mark the focus-session workspace subtly in both filmstrip and preview. +- [x] Verify workspace selection, window activation, scrolling, new workspace creation, search, Escape, and background dismissal. + +### Task 5: Integration and visual verification + +**Files:** +- Modify: `config/dot/hypr/DESKTOP-PARITY.md` +- Modify: `README.md` + +**Interfaces:** +- Consumes: all previous tasks +- Produces: documented, live daily-driver feature + +- [x] Run `Hyprland --verify-config` and reload the Lua configuration. +- [x] Reload the live Quickshell instance and inspect `qs log` for QML errors and warnings. +- [x] Exercise a short live focus session through start, pause, resume, dismiss, reopen, workspace overview, and end. +- [x] Capture screenshots of capsule open, indicator-only, Continuum default, and Continuum search states. +- [x] Update Panama's feature documentation and keybinding table with the verified behavior. +- [x] Inspect the exact Panama diff and ensure unrelated pre-existing changes remain untouched. diff --git a/docs/superpowers/plans/2026-08-17-panama-settings.md b/docs/superpowers/plans/2026-08-17-panama-settings.md new file mode 100644 index 0000000..b237976 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-panama-settings.md @@ -0,0 +1,128 @@ +# Panama Settings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the approved Luminous System Settings control center and map it to `Super+I` while preserving GNOME Settings as a launcher-accessible fallback. + +**Architecture:** A persisted `DesktopPreferences` singleton owns user choices, `SystemSettings` owns allow-listed system reads and writes, and a normal tiled Settings window renders focused pages over those boundaries. Existing shell consumers continue through `Settings.qml`, while `ShellState` and typed IPC own window routing. + +**Tech Stack:** Quickshell 0.3.0, Qt 6 QML, Quickshell Io and PipeWire, Hyprland 0.56 Lua configuration, systemd, Bash integration contracts + +**Spec:** `docs/superpowers/specs/2026-08-17-panama-settings-design.md` + +## Global Constraints + +- Use Tokyo Night Moon tokens and the existing Prism edge; no component-local color literals. +- Apply only allow-listed Hyprland numeric settings; never execute UI-provided shell text. +- Do not expose full-time desktop HDR without tested automatic rollback. +- Do not continuously poll or animate while idle. +- Preserve GNOME Settings as an installed, searchable fallback. +- Keep unrelated pre-existing Panama changes untouched. + +--- + +### Task 1: Persisted desktop preferences + +**Files:** +- Create: `config/dot/quickshell/config/DesktopPreferences.qml` +- Modify: `config/dot/quickshell/config/Settings.qml` +- Test: `tests/quickshell/settings-preferences-contract.sh` + +**Interfaces:** +- Produces: writable `use24Hour`, `showSeconds`, `showWeekday`, `showCpu`, `showMemory`, `showGpu`, `dockAutohide`, `dockRevealDelayMs`, `dockHideDelayMs`, `focusDurationMinutes`, `autoHdr`, `vrrPolicy`, `directScanoutPolicy`, and `lastPage` properties plus `resetDesktopDefaults(): void`. +- Consumes: `Quickshell.stateDir`, `FileView`, and `JsonAdapter`. + +- [x] Write a standalone Quickshell contract that changes clock, dock, and display-policy values in a disposable state directory, restarts the harness, and asserts the literal values survive. +- [x] Run the contract and verify it fails because `DesktopPreferences` does not exist. +- [x] Implement the JSON adapter and bind dynamic `Settings.qml` values to it. +- [x] Run the persistence contract and the existing focus/dock consumers to green. + +### Task 2: Allow-listed system boundary + +**Files:** +- Create: `config/dot/quickshell/services/SystemSettings.qml` +- Test: `tests/quickshell/settings-system-contract.sh` + +**Interfaces:** +- Consumes: `DesktopPreferences`, `NightLight`, `Notifs`, `Caffeine`, `hyprctl`, `systemctl`, and `pgrep`. +- Produces: monitor identity and geometry, display-policy setters, bounded `refresh(): void`, service status, `openGnomePanel(panel: string): void`, `openApplication(id: string): void`, and an inline `lastError`. + +- [x] Write an IPC-backed contract that applies current policy values, refreshes the active monitor, and rejects an unsupported GNOME panel. +- [x] Run it and verify the `settings-system` diagnostics target is absent. +- [x] Implement fixed-command processes and strict numeric/panel allow lists. +- [x] Run the contract and confirm the live compositor returns to its original values. + +### Task 3: Luminous window and core pages + +**Files:** +- Create: `config/dot/quickshell/modules/settings/SettingsWindow.qml` +- Create: `config/dot/quickshell/modules/settings/SettingsShell.qml` +- Create: `config/dot/quickshell/modules/settings/SettingsSidebar.qml` +- Create: `config/dot/quickshell/modules/settings/SettingsCard.qml` +- Create: `config/dot/quickshell/modules/settings/SettingRow.qml` +- Create: `config/dot/quickshell/modules/settings/SettingsToggle.qml` +- Create: `config/dot/quickshell/modules/settings/HomePage.qml` +- Create: `config/dot/quickshell/modules/settings/AppearancePage.qml` +- Create: `config/dot/quickshell/modules/settings/DisplaysPage.qml` +- Create: `config/dot/quickshell/modules/settings/DesktopPage.qml` +- Create: `config/dot/quickshell/modules/settings/qmldir` +- Test: `tests/quickshell/settings-window-contract.sh` + +**Interfaces:** +- Consumes: `DesktopPreferences`, `SystemSettings`, `NightLight`, and `ShellState.settingsPage`. +- Produces: a tiled `Panama Settings` window with Home, Appearance, Displays, and Desktop pages. + +- [x] Write a live contract that opens the window to each core page and verifies one `Panama Settings` toplevel remains mapped. +- [x] Run it and verify the `settings` IPC target is absent. +- [x] Implement the two-pane Luminous shell and reusable row/card controls. +- [x] Implement real preference, Night Light, HDR policy, VRR, direct-scanout, clock, vitals, and dock controls. +- [x] Run the page contract and inspect Quickshell logs for construction warnings. + +### Task 4: System pages and fallback handoffs + +**Files:** +- Create: `config/dot/quickshell/modules/settings/SoundPage.qml` +- Create: `config/dot/quickshell/modules/settings/NotificationsPage.qml` +- Create: `config/dot/quickshell/modules/settings/ShortcutsPage.qml` +- Create: `config/dot/quickshell/modules/settings/ServicesPage.qml` +- Create: `config/dot/quickshell/modules/settings/AboutPage.qml` +- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml` +- Test: `tests/quickshell/settings-pages-contract.sh` + +**Interfaces:** +- Consumes: existing PipeWire controls, `Notifs`, `FocusSession`, service status from Task 2, and fixed GNOME panel handoffs. +- Produces: Sound, Notifications & Focus, Shortcuts, Startup & Services, and About destinations. + +- [x] Extend the page contract with search routing, live DND/focus changes, audio construction, service refresh, and GNOME fallback command validation. +- [x] Run it and verify the new page names fail before implementation. +- [x] Build the five pages using the same row/card vocabulary and no inactive fake controls. +- [x] Run contracts and verify absent optional services render as unavailable. + +### Task 5: Shell, keybinding, launcher, and compositor integration + +**Files:** +- Modify: `config/dot/quickshell/services/ShellState.qml` +- Modify: `config/dot/quickshell/shell.qml` +- Modify: `config/dot/hypr/keybinds.lua` +- Modify: `config/dot/hypr/rules.lua` +- Create: `config/local/share/applications/panama-settings.desktop` +- Modify: `setup/scripts/link-dotfiles` +- Modify: `README.md` +- Modify: `config/dot/hypr/README.md` + +**Interfaces:** +- Produces: `ShellState.settingsOpen`, `settingsPage`, typed `settings` and `settings-system` IPC targets, `Super+I`, and a searchable desktop entry. + +- [x] Extend the live contract to open, close, toggle, and route directly to a named page. +- [x] Wire the Settings window and IPC handlers into the shell. +- [x] Replace the GNOME Settings `Super+I` command with Panama IPC while retaining the GNOME desktop entry. +- [x] Let the titled window participate in the active Hyprland tiling layout. +- [x] Link the Panama desktop entry into `~/.local/share/applications` and refresh the launcher cache. + +### Task 6: Full-system verification + +- [x] Run every Quickshell contract, `Hyprland --verify-config`, and desktop-file validation. +- [x] Reload Hyprland, restart Quickshell, and inspect fresh logs for errors or warnings. +- [x] Exercise Home, Displays, Desktop, Sound, Notifications, Services, shortcut search, and a GNOME handoff. +- [x] Capture Home, Displays, Desktop, and Services pages for visual review. +- [x] Inspect the exact Panama diff and confirm unrelated pre-existing changes were preserved. diff --git a/docs/superpowers/plans/2026-08-17-screen-intelligence.md b/docs/superpowers/plans/2026-08-17-screen-intelligence.md new file mode 100644 index 0000000..fb7feb2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-screen-intelligence.md @@ -0,0 +1,105 @@ +# Screen Intelligence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add polished local OCR and code recognition to Panama's existing capture experience. + +**Architecture:** Capture keeps ownership of geometry and delegates Read captures to a focused `ScreenIntelligence` singleton. A deterministic helper emits JSON from local Tesseract and ZBar, while a transient Quickshell result surface owns presentation and explicit follow-up actions. + +**Tech Stack:** Quickshell 0.3.0, Qt 6 QML, Hyprland 0.56 Lua configuration, Bash, Tesseract 5, ZBar, grim, wl-clipboard + +**Spec:** `docs/superpowers/specs/2026-08-17-screen-intelligence-design.md` + +## Global Constraints + +- Recognition is local until Search, Translate, or Open is explicitly selected. +- Reuse the existing Screen, Window, and Selection picker. +- Use Tokyo Night Moon and Prism tokens; no component-local colors. +- Do not add idle animation or polling. +- Temporary screen captures must be removed when dismissed or replaced. +- Missing recognition packages must degrade into clear guidance, not shell errors. + +--- + +### Task 1: Recognition boundary + +**Files:** +- Create: `config/dot/quickshell/scripts/screen-intelligence` +- Create: `tests/quickshell/screen-intelligence-helper-contract.sh` +- Modify: `setup/packages/hyprland-packages` + +**Interfaces:** +- Produces: `screen-intelligence probe` and `screen-intelligence analyze-file IMAGE`, each emitting one JSON object. +- Consumes: `tesseract`, `zbarimg`, and `jq`. + +- [x] Write a contract that generates a high-contrast `PANAMA SCREEN INTELLIGENCE` fixture and expects that literal text from `analyze-file`. +- [x] Run it and verify it fails because the helper does not exist. +- [x] Implement strict argument validation, readiness probing, OCR, code recognition, JSON encoding, and user-safe errors. +- [x] Add `tesseract`, `tesseract-langpack-eng`, and `zbar` to the Hyprland package set. +- [x] Run the helper contract to green with the real engine. + +### Task 2: Recognition state and actions + +**Files:** +- Create: `config/dot/quickshell/services/ScreenIntelligence.qml` +- Test: `tests/quickshell/screen-intelligence-contract.sh` + +**Interfaces:** +- Produces: readiness properties, `phase`, `text`, detected-code data, `analyzeRegion`, `analyzeFile`, copy/search/translate/open actions, and `close`. +- Consumes: the Task 1 helper, `grim`, `wl-copy`, `xdg-open`, and `Quickshell.cachePath`. + +- [x] Write a live IPC contract that expects the target, readiness JSON, fixture analysis, and cleanup. +- [x] Run it and verify the IPC target is missing. +- [x] Implement bounded process state, JSON parsing, explicit network actions, and temporary-file cleanup. +- [x] Wire a typed diagnostics and command IPC target in `shell.qml`. +- [x] Run the live contract to green. + +### Task 3: Capture integration and result sheet + +**Files:** +- Modify: `config/dot/quickshell/services/Capture.qml` +- Modify: `config/dot/quickshell/modules/capture/CaptureBar.qml` +- Create: `config/dot/quickshell/modules/capture/IntelligenceResult.qml` +- Modify: `config/dot/quickshell/shell.qml` + +**Interfaces:** +- Produces: Screenshot, Record, and Read action selection plus the transient result overlay. +- Consumes: Task 2's `analyzeRegion` and state/action API. + +- [x] Extend the live contract to open Selection + Read mode and inspect the exposed mode state. +- [x] Run it and verify the Read state is absent. +- [x] Add mutually exclusive action selection and defer recognition until the picker unmaps. +- [x] Build processing, success, empty, code, and unavailable result states with keyboard dismissal. +- [x] Restart Quickshell and inspect fresh logs for construction errors or warnings. + +### Task 4: Settings, shortcut, launcher, and documentation + +**Files:** +- Create: `config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml` +- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml` +- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml` +- Modify: `config/dot/quickshell/modules/settings/qmldir` +- Modify: `config/dot/quickshell/services/ShellState.qml` +- Modify: `config/dot/hypr/keybinds.lua` +- Modify: `config/dot/hypr/rules.lua` +- Create: `config/local/share/applications/panama-screen-intelligence.desktop` +- Modify: `config/dot/hypr/README.md` +- Modify: `README.md` +- Modify: `tests/quickshell/settings-pages-contract.sh` + +**Interfaces:** +- Produces: Settings destination `screen-intelligence`, `Super+Shift+S`, and a searchable desktop entry. + +- [x] Extend Settings and keybinding contracts with the new page and shortcut. +- [x] Run them and verify the route and bind fail. +- [x] Build the readiness/privacy page and add it to Settings navigation. +- [x] Add the keybinding, overlay privacy rule, desktop entry, and documentation. +- [x] Link and validate the desktop entry, reload Hyprland, and run contracts to green. + +### Task 5: Full-system verification + +- [x] Run the helper and live Screen Intelligence contracts. +- [x] Run every Quickshell contract and `Hyprland --verify-config`. +- [x] Restart the live shell and inspect fresh logs. +- [ ] Exercise a real screen-region OCR capture and confirm Copy reaches clipboard history. +- [ ] Review the complete diff, commit the Panama snapshot, and push `main` to `origin`. diff --git a/docs/superpowers/specs/2026-08-17-contextual-signal-glass-design.md b/docs/superpowers/specs/2026-08-17-contextual-signal-glass-design.md new file mode 100644 index 0000000..b4ca694 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-contextual-signal-glass-design.md @@ -0,0 +1,47 @@ +# Contextual Signal Glass Design + +## Purpose + +Extend the existing focus-session Signal Glass into a quiet, shell-wide status surface. It should acknowledge meaningful desktop transitions without becoming a second notification system or a stream of routine telemetry. + +## Experience + +- Only curated events appear: capture completion, recording transitions, audio-output changes, Bluetooth/KDE Connect connection changes, Night Light changes, focus completion, and privacy-sensitive activity. +- Volume adjustments, ordinary clipboard changes, Wi-Fi signal fluctuations, and background health polling do not appear. +- Transient events use one compact glass capsule below the clock for roughly three seconds. A newer event replaces an equivalent event, while higher-priority privacy and recording events preempt lower-priority device events. +- Events are not retained as history. Actionable events may be clicked; every event may be dismissed. +- Focus controls remain the richer, persistent Signal Glass state. A transient event may briefly occupy the surface, after which the focus controls return. +- Microphone, camera, screen sharing, and recording show a brief start/stop event. While active, a tiny semantic glyph remains in the right status area. Clicking it opens a compact activity detail card with the responsible activity and a safe stop action when Panama owns that activity. + +## Architecture + +`StatusEvents.qml` is the broker. Producers call `publish(event)`, using stable keys, semantic priority, duration, and an optional action identifier. The broker deduplicates, bounds its queue, expires transient entries, and routes actions without allowing render components to spawn commands. + +`PrivacyState.qml` derives persistent activity from Panama capture state and PipeWire metadata. It publishes transition events through the broker but exposes current booleans independently so the bar never depends on transient UI state. + +`SignalGlass.qml` becomes the only per-screen host. It chooses the current transient event first, then the existing focus-session card. `EventCard.qml` and the extracted `FocusCard.qml` remain presentation-only. + +`ActivityIndicator.qml` renders persistent privacy state beside the existing focus indicator. `ActivityPanel.qml` explains active states and only offers Stop when Panama owns the recording operation. + +## Event Policy + +- `critical`: recording, screen sharing, camera, or microphone transitions; shown immediately. +- `important`: screenshots saved, focus completion, and failed user-initiated shell actions. +- `ambient`: output-device, Bluetooth, KDE Connect, and Night Light changes. +- Equivalent keys replace instead of stacking. The pending queue is capped at four entries and discards the oldest lowest-priority entry first. +- Initial service discovery never emits an event. Only transitions after initialization do. +- Do Not Disturb suppresses ambient events but not privacy transitions or Panama-owned recording state. + +## Reliability and Performance + +- No visual element animates while idle. +- PipeWire privacy discovery is event-triggered where available and falls back to a low-frequency bounded probe; only state transitions repaint UI. +- Missing optional commands or malformed service data disable that producer and log one concise warning rather than creating a respawn loop. +- A Quickshell reload reconstructs persistent privacy state but does not replay old transient events. + +## Verification + +- Contract tests exercise broker priority, replacement, dismissal, and DND filtering through IPC. +- Capture tests prove screenshot and recording transitions emit the intended events. +- Activity tests prove persistent glyph state is independent of capsule expiry. +- QML lint/runtime logs must contain no new warnings, and visual captures must cover ambient, privacy, recording, focus-restoration, and detail-panel states. diff --git a/docs/superpowers/specs/2026-08-17-continuum-scratchpad-design.md b/docs/superpowers/specs/2026-08-17-continuum-scratchpad-design.md new file mode 100644 index 0000000..fe683d5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-continuum-scratchpad-design.md @@ -0,0 +1,46 @@ +# Continuum Scratchpad and Window Actions Design + +## Purpose + +Complete Continuum as a practical GNOME-like window overview: windows can be moved directly between workspaces, closed without first focusing them, and set aside in one global scratchpad shelf. + +## Experience + +- Every window thumbnail is clickable, draggable, and gains a quiet close button on hover or keyboard focus. +- Dropping a thumbnail on a workspace card moves it there without leaving the overview. +- A global scratchpad shelf sits along the bottom of Continuum. Empty, it is a subtle drop target. Populated, it expands into a horizontal row of live cards. +- Dropping a normal window on the shelf sends it to `special:scratch`. Clicking a shelf card restores it to the selected positive workspace, focuses it, and closes Continuum. +- `Super+X` continues to toggle the global scratchpad and `Super+Shift+X` continues to send the active window there. +- Closing a thumbnail asks Hyprland to close exactly that address and leaves the overview open. +- Drag operations have restrained scale/highlight feedback, never continuous decorative animation. + +## Architecture + +`WindowThumbnail.qml` owns pointer drag initiation, hover chrome, and close intent. It emits `activated`, `closeRequested`, and `dragStarted/dragFinished` signals with its toplevel; it does not dispatch compositor commands. + +`WorkspaceCard.qml` becomes a drop destination and forwards window actions to `OverviewBody.qml`. `OverviewBody.qml` is the sole compositor-command boundary for focus, close, workspace moves, and scratchpad moves, keeping address normalization in one place. + +`ScratchpadShelf.qml` renders the special workspace model and provides the global drop target. `ScratchpadCard.qml` reuses the same thumbnail presentation at a compact size. + +The shelf reads the Hyprland workspace whose name is `special:scratch`. Positive workspaces remain the only entries in the filmstrip and search model. + +## Keyboard and Input + +- Left/Right selects workspaces when search is empty. +- Enter opens the selected workspace or focused search result. +- Delete closes the keyboard-selected window. +- Escape closes Continuum. +- Pointer dragging never activates a window on release after a successful drag. + +## Failure Handling + +- Missing or stale addresses make the command a no-op and trigger a workspace/toplevel refresh. +- If the scratch workspace does not yet exist, the first drop creates it through Hyprland's normal special-workspace move dispatcher. +- Closing or moving a window invalidates captures; Continuum refreshes models and recaptures after the compositor reports the change. + +## Verification + +- Static contracts confirm all dispatches are address-scoped and the shelf only targets `special:scratch`. +- Live tests create disposable windows, move them across workspaces and into/out of the scratchpad, close only the intended disposable window, and restore the user's prior workspace. +- Visual captures cover the empty shelf, populated shelf, workspace drop target, and hover close control. +- Existing focus and overview-search tests remain green. diff --git a/docs/superpowers/specs/2026-08-17-focus-glass-continuum-design.md b/docs/superpowers/specs/2026-08-17-focus-glass-continuum-design.md new file mode 100644 index 0000000..284a41d --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-focus-glass-continuum-design.md @@ -0,0 +1,65 @@ +# Focus Glass and Continuum Overview Design + +## Outcome + +Panama gains a polished focus-session surface and a GNOME-like workspace overview. The focus surface uses the selected Signal Glass visual language, keeps Command Deck's useful controls, and treats its associated Hyprland workspace as a first-class destination. The overview uses the selected Continuum layout: a workspace filmstrip above a large, spatially familiar active-workspace preview. + +## Focus session behavior + +- A focus session starts with Panama's configurable default duration of 45 minutes and binds to the currently focused positive-numbered Hyprland workspace. +- Starting a session enables Do Not Disturb and Caffeine. Panama remembers their prior values and restores them when the session ends. +- The session uses a wall-clock deadline rather than decrementing persistent state every second. It therefore survives a Quickshell reload without losing time. +- Pause stores the exact remaining duration. Resume creates a new deadline from that stored duration. +- The session completes automatically at zero and posts a normal desktop notification through the existing Panama notification server. +- End deliberately terminates the session and restores the prior DND and Caffeine values. +- Only one focus session exists at a time. Starting again replaces the old session with a fresh session on the current workspace. + +## Signal Glass surface + +- The main capsule is a top-centred blurred glass surface below the bar, carrying Panama's Prism edge, Tokyo Night Moon palette, and event-driven open/close motion. +- Its primary row shows the focus icon, workspace label, remaining time, and whether DND and Caffeine are being held. +- Clicking the primary row activates the bound workspace and dismisses the capsule without ending the session. +- The action row contains Pause or Resume, Workspace, and End. +- Workspace opens the Continuum overview with the session's bound workspace selected. +- A labelled close button hides the capsule while the session continues. +- While a session exists, a compact bar indicator remains visible. Clicking it reopens the capsule, so dismissal never makes the running session undiscoverable. +- No focus UI animates while idle. The only periodic update is the once-per-second remaining-time text while a session is running and not paused. + +## Discoverability and controls + +- Quick Settings includes a full-width Focus row. When idle it starts a 45-minute session on the current workspace. When active it shows the remaining time and opens the capsule. +- `SUPER+SHIFT+F` performs the same start-or-show action. +- A `focus` Quickshell IPC target exposes `start`, `reveal`, `pause`, `workspace`, `overview`, and `end` for launcher integration and diagnostics. + +## Continuum overview + +- The overview keeps the existing full-screen dimmed and blurred layer, Escape behavior, outside-click dismissal, one-shot window captures, and dynamic-workspace support. +- A horizontally scrollable workspace filmstrip occupies the top of the surface. It contains every positive-numbered workspace plus the trailing new-workspace affordance. +- The focused or requested workspace is rendered as a large preview below the filmstrip. Clicking one of its windows activates that window and closes the overview. +- Selecting a workspace in the filmstrip activates it while keeping the overview open, matching GNOME's spatial workflow. Scrolling over the filmstrip moves left or right through workspaces. +- Opening from the focus capsule initially selects the session workspace and marks it with a subtle focus-session badge. +- A search field filters windows across all positive workspaces by title or application id. Selecting a result activates its workspace and window, then closes the overview. +- The overview IPC target can open directly into a search query, enabling deterministic diagnostics and future launcher actions without synthesizing keyboard input. + +## State and boundaries + +- `services/FocusSession.qml` owns session timing, persistence, DND and Caffeine coordination, and workspace activation. Visual modules only call its public functions and render its properties. +- `services/ShellState.qml` remains the owner of mutually exclusive overlays and gains only the requested overview workspace id. +- `modules/focus/` owns the capsule and bar indicator. +- `modules/overview/` owns the Continuum presentation and window search. +- Persistent state is stored atomically through Quickshell `FileView` and `JsonAdapter` under `Quickshell.stateDir`; no daemon or new package is required. + +## Failure handling + +- If the persisted workspace no longer exists, direct activation falls back to Hyprland's workspace dispatcher using the stored positive id. +- A malformed or absent state file falls back to an inactive session with no user-visible error. +- If the shell reloads after the deadline, the session is completed during restoration and its managed DND and Caffeine state is released. +- Search and workspace lists tolerate transient null Hyprland objects while IPC state refreshes. + +## Verification + +- Validate the focus state machine with a standalone Quickshell harness using a very short session. +- Validate every QML component constructs without warnings against Quickshell 0.3.0. +- Exercise start, pause, resume, dismiss, reopen, workspace activation, overview targeting, automatic completion, and manual end through live IPC. +- Reload the daily-driver shell and inspect its logs for QML errors. +- Capture the capsule, compact indicator, Continuum overview, and search state for visual review. diff --git a/docs/superpowers/specs/2026-08-17-panama-settings-design.md b/docs/superpowers/specs/2026-08-17-panama-settings-design.md new file mode 100644 index 0000000..e1218cf --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-panama-settings-design.md @@ -0,0 +1,75 @@ +# Panama Settings Design + +## Purpose + +Panama Settings is the native control center for the Panama Hyprland desktop. It gives Gabriel one polished, understandable place to inspect the current desktop and change Panama-owned behavior without editing Lua, QML, JSON, or systemd units by hand. + +The approved visual direction is **Luminous System Settings**: an Apple-like two-pane settings window, rendered with Panama's restrained Prism glass and Tokyo Night Moon palette. It is a real application window, not a transient layer-shell popover. + +## Product boundaries + +Panama Settings owns settings whose source of truth is Panama, Quickshell, or Hyprland. The first release includes: + +- Home summary with the active display, current desktop profile, Night Light, notification/focus state, and startup-service health. +- Appearance controls for clock format and bar vitals. +- Display policy for game-aware HDR, content-aware VRR, direct scanout, and Night Light. +- Desktop controls for dock auto-hide and timing. +- A searchable, read-only shortcut reference generated from the established Panama mental model. +- Sound entry points that reuse Panama's live PipeWire controls. +- Notification, Do Not Disturb, and focus-duration controls. +- Startup/service health for Nextcloud, RustDesk, KDE Connect, Hyprpaper, Hypridle, and Vicinae, with safe open/restart actions where appropriate. +- An About page with the running Hyprland, Quickshell, and Panama identity. + +Hardware configuration, user accounts, printers, online accounts, and other services owned by Fedora or GNOME remain delegated to GNOME Settings. Panama Settings labels those handoffs and launches the relevant GNOME panel with a one-process `XDG_CURRENT_DESKTOP=GNOME` override. GNOME Settings remains installed and searchable in Vicinae. + +Screen Intelligence remains a separate subsystem with its own capture, recognition, and result lifecycle. Once that subsystem is installed, Settings exposes its real readiness and privacy page rather than duplicating the feature or presenting inactive controls. + +## Interaction model + +`Super+I` opens or raises Panama Settings. The shell also exposes a typed `settings` IPC target with `open`, `toggle`, `close`, and `page(name)` operations. A desktop entry makes Panama Settings searchable in Vicinae alongside GNOME Settings. + +The sidebar is always visible at desktop sizes and contains a search field, category groups, and a quiet system-status footer. Search filters categories and setting labels rather than becoming a command palette. + +The content pane uses a stable header, restrained cards, and rows with labels, concise explanations, and controls aligned to the trailing edge. Changes apply immediately. There is no global Save button. + +## State and architecture + +`config/DesktopPreferences.qml` persists user choices to `Quickshell.stateDir + "/panama-settings.json"` through `FileView` and `JsonAdapter`. Existing consumers continue reading `Settings.qml`; dynamic values in that singleton bind to `DesktopPreferences`, preserving one public configuration surface. + +`services/SystemSettings.qml` is the machine boundary. It reads current monitor and service state, invokes allow-listed Hyprland runtime settings, opens GNOME panels, and reports command failures. UI components never construct arbitrary shell commands. + +The display policy values are persisted and re-applied when the shell starts: + +- Game-aware HDR maps to `render:cm_auto_hdr` with values `0` or `1`. +- VRR maps to `misc:vrr` with values `0` or `3`. +- Direct scanout maps to `render:direct_scanout` with values `0` or `2`. + +Full-time desktop HDR is informational in this release because it can break screenshots, OBS, Sunshine, and lock-screen capture on the current Hyprland build. It must not be exposed as an unsafe one-click toggle without a tested automatic rollback path. + +`services/ShellState.qml` owns whether the window is open and the requested page. `modules/settings/SettingsWindow.qml` owns the normal application toplevel, which participates in Hyprland's active tiling layout. Presentation components under `modules/settings/` call only public preference or service methods. + +## Visual language + +- Tokyo Night Moon is the only palette. +- A faint blue-to-orchid Prism hairline marks the selected category and major surfaces; orchid never appears as a standalone accent. +- The window is dense enough to feel professional but leaves generous breathing room around page headers and section groups. +- Typography uses Adwaita Sans for interface text and VictorMono Nerd Font only for version strings, dimensions, and key chords. +- Motion is event-driven and short. No pulse, shimmer, animated gradient, or idle repaint loop is allowed. +- Enabled states use the primary blue, warnings use amber, and destructive/error states use red only when there is an actual problem. + +## Failure handling + +- A malformed or absent preferences file falls back to shipped defaults. +- A failed `hyprctl` write restores the previous preference value and surfaces a quiet inline error. +- Missing optional applications or services are shown as unavailable, never as a shell error or empty card. +- Service refreshes are bounded and user-triggerable; no polling loop runs continuously. +- Closing Panama Settings only hides the window. It does not terminate Quickshell or alter another overlay. + +## Verification + +- Exercise preferences through a disposable Quickshell state directory and prove values survive a shell restart. +- Verify each display policy emits only its allow-listed numeric `hyprctl keyword` command. +- Verify `Super+I`, IPC open/close/page routing, and the searchable desktop entry. +- Construct every Settings page without QML warnings. +- Exercise live monitor refresh, Night Light, DND, audio controls, service refresh, and GNOME handoffs. +- Restart the live shell, inspect logs, and capture Home, Displays, Desktop, and Services pages for visual review. diff --git a/docs/superpowers/specs/2026-08-17-screen-intelligence-design.md b/docs/superpowers/specs/2026-08-17-screen-intelligence-design.md new file mode 100644 index 0000000..5230ec3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-screen-intelligence-design.md @@ -0,0 +1,70 @@ +# Screen Intelligence Design + +## Purpose + +Screen Intelligence makes text trapped in pixels feel like normal desktop content. It extends Panama's existing screenshot picker with a local **Read** action for a screen, window, or selection, then presents the result in a focused Prism glass sheet. + +The feature is deliberately local-first. Screen pixels are captured into Panama's cache and processed by Tesseract and ZBar on the workstation. Nothing is uploaded unless the user explicitly chooses Search, Translate, or Open. + +## Interaction model + +- `Super+Shift+S` opens the existing capture picker in Selection + Read mode. +- `Print` keeps opening the complete picker, where Screenshot, Record, and Read are peers. +- Screen, Window, and Selection continue to use one shared target-selection model. +- Committing a Read capture dismisses the picker before taking the final image, then opens a result sheet while recognition runs. +- `Escape` dismisses the result and deletes its temporary image. +- The result provides selectable text and explicit Copy, Search, and Translate actions. +- A detected URL, QR code, or barcode is shown separately. Web URLs can be opened; all detected values can be copied. +- A searchable desktop entry opens Read mode without requiring the shortcut. + +## Architecture + +`services/Capture.qml` remains the owner of screen/window/selection geometry and frozen-frame behavior. It gains a third action state, `intelligenceMode`, and delegates committed Read geometry to `services/ScreenIntelligence.qml` only after the capture overlay has unmapped. + +`services/ScreenIntelligence.qml` owns recognition state, process lifecycle, result actions, and cleanup. Its public boundary is: + +- `refresh(): void` +- `analyzeRegion(geometry: string, outputName: string): void` +- `analyzeFile(path: string): void` +- `copyText(): void` +- `copyCode(): void` +- `search(): void` +- `translate(): void` +- `openDetected(): void` +- `close(): void` + +`scripts/screen-intelligence` is the deterministic machine boundary. `probe` reports engine availability. `analyze-file IMAGE` emits one JSON object containing OCR text, detected code data, and a user-safe error. It never evaluates UI-provided shell text. + +`modules/capture/IntelligenceResult.qml` is a transient overlay, not an application window. It renders a quiet processing state, the selectable result, detected-content card, actions, and actionable failure guidance. + +## Recognition behavior + +- OCR uses the installed English Tesseract language pack. +- Tesseract's automatic page-segmentation mode handles both short snippets and larger document regions. +- ZBar recognition is additive. A capture can return text, a code, or both. +- Empty recognition is not treated as a crash; the sheet explains that no readable text or code was found. +- Missing engines produce a precise install command and keep the rest of the shell healthy. +- Temporary captures live under `Quickshell.cachePath(...)` and are removed when dismissed or replaced. + +## Visual language + +- The result is a centered Prism glass sheet over a lightly dimmed desktop. +- Tokyo Night Moon tokens are the only colors. +- Processing uses a static status treatment; there is no spinner, pulse, shimmer, or idle animation. +- OCR text is readable interface typography in a selectable editor, not a terminal-like dump. +- The primary action is Copy. Network-leaving actions are visually secondary and clearly labelled. + +## Settings and discoverability + +Panama Settings gains a real **Screen Intelligence** page. It shows engine readiness, the `Super+Shift+S` shortcut, the local-processing privacy boundary, and the exact package requirement if recognition is unavailable. The page contains no fake toggles. + +The shortcut reference, Hyprland README, root README, desktop package list, and desktop entry remain in sync. + +## Verification + +- A generated image fixture must be recognized as literal text by the helper. +- The live IPC contract must expose readiness, open Read mode, and route the Settings page. +- The capture picker must construct with Screenshot, Record, and Read actions without QML warnings. +- The result sheet must construct for processing, success, empty, and unavailable states. +- Hyprland must register `Super+Shift+S` as Screen Intelligence and pass config verification. +- The complete Quickshell contract suite must remain green. diff --git a/setup/packages/desktop-packages b/setup/packages/desktop-packages index 4e5d839..072bdf1 100644 --- a/setup/packages/desktop-packages +++ b/setup/packages/desktop-packages @@ -19,7 +19,6 @@ gstreamer1-plugins-good-gtk gstreamer1-plugins-good-qt gstreamer1-plugins-good-qt6 hipblas -intel-mediasdk-devel kernel-devel lame lame-devel diff --git a/setup/packages/hyprland-packages b/setup/packages/hyprland-packages new file mode 100644 index 0000000..161e60d --- /dev/null +++ b/setup/packages/hyprland-packages @@ -0,0 +1,39 @@ +hyprland +hyprland-uwsm +uwsm +quickshell +vicinae +hyprlock +hypridle +hyprpaper +hyprpicker +hyprsunset +hyprpolkitagent +hyprshutdown +hyprpwcenter +hyprsysteminfo +hyprland-guiutils +xdg-desktop-portal-hyprland +grim +slurp +grimblast +satty +wl-clipboard +wf-recorder +gpu-screen-recorder +brightnessctl +playerctl +pamixer +udiskie +wofi +adw-gtk3-theme +adwaita-icon-theme +adwaita-sans-fonts +qt6-qtwayland +nm-connection-editor +kde-connect +qrencode +tesseract +tesseract-langpack-eng +zbar +system-config-printer diff --git a/setup/scripts/change-settings b/setup/scripts/change-settings old mode 100644 new mode 100755 index 61306d9..f4502f7 --- a/setup/scripts/change-settings +++ b/setup/scripts/change-settings @@ -10,3 +10,60 @@ PANAMA_PATH="$HOME/.local/share/Panama" echo -e "\n--- Copying System & User files ---" log "Changing DNF Settings" sudo cp -r "$PANAMA_PATH/config/copy/." "/" + +# The GPU symlink rules land via the copy above. Apply them without a reboot so +# AQ_DRM_DEVICES resolves on the first Hyprland login rather than the second. +if [[ -f /etc/udev/rules.d/99-panama-gpu.rules ]]; then + log "Reloading udev rules for stable GPU symlinks" + sudo udevadm control --reload + sudo udevadm trigger --subsystem-match=drm +fi + +echo -e "\n--- Desktop appearance ---" +# GTK and Qt apps read these directly from gsettings; there is no settings +# daemon outside GNOME, so they must be correct rather than merely overridden. +# These match config/dot/gtk-3.0/settings.ini and quickshell's Theme.qml. +if exists gsettings; then + log "Applying interface settings" + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3-dark' + gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' + gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' + gsettings set org.gnome.desktop.interface font-name 'Adwaita Sans 11' + gsettings set org.gnome.desktop.interface monospace-font-name 'VictorMono Nerd Font 10' + gsettings set org.gnome.desktop.interface cursor-theme 'oreo_blue_cursors' + # Deliberately NOT setting cursor-size. GNOME here is on 16 (smaller than + # GNOME's own default of 24) and changing it would visibly alter the working + # GNOME session. Hyprland uses XCURSOR_SIZE=24 from uwsm/env; if the cursor + # feels too large there, change that one value rather than this one. +fi + +echo -e "\n--- Hyprland session services ---" +# Deliberately NOT enabling these. +# +# hyprpaper / hypridle / hyprpolkitagent / vicinae all ship units that are +# WantedBy=graphical-session.target -- and that target is active under GNOME +# too. Enabling them would start hypridle alongside GNOME's own idle handling +# (two daemons blanking and locking the screen on different timers) and start +# hyprpaper where there is no layer shell for it to draw on. +# +# Instead hypr/autostart.lua runs `systemctl --user start ...` on +# hyprland.start, which scopes them to the Hyprland session. They still get the +# correct uwsm activation environment and still stop in order, because they are +# PartOf=graphical-session.target. +log "Hyprland services are started per-session by hypr/autostart.lua, not enabled globally" + +# Notification daemons must NOT be installed: mako, dunst and swaync all +# register Name=org.freedesktop.Notifications for D-Bus activation, which races +# Quickshell's own notification server at login. Whoever wins keeps the name. +for pkg in mako dunst SwayNotificationCenter; do + if rpm -q "$pkg" >/dev/null 2>&1; then + log "WARNING: $pkg is installed and will fight Quickshell for the notification D-Bus name." + log " Fix with: systemctl --user mask ${pkg,,}.service" + fi +done + +# Select the launcher theme once vicinae exists. +if exists vicinae; then + log "Setting vicinae theme" + vicinae theme set tokyonight-moon >/dev/null 2>&1 || true +fi diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 8a59d3f..bb2f3a0 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -73,6 +73,22 @@ else log "Package list was not in specified path: $DEV_FILE" fi +# --- Install the Hyprland desktop --- +# Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper. +HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" +if [[ -f "$HYPR_FILE" ]]; then + log "Enabling Hyprland COPR" + sudo dnf copr enable -y lionheartp/Hyprland > /dev/null 2>&1 + HYPR_PACKAGES=$(tr "\n" " " <"$HYPR_FILE") + log "Installing Hyprland desktop packages" + echo -e "Includes the following packages:" + echo -e "$(<"$HYPR_FILE")" + sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null 2>&1 + log "Hyprland packages installed!" +else + log "Package list was not in specified path: $HYPR_FILE" +fi + # --- Install Bun --- if [[ -x "$HOME/.bun/bin/bun" ]]; then log "Bun already installed at \"$HOME/.bun/bin/bun\"" diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index f91ffef..34ab909 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -29,7 +29,12 @@ fi log "Symlinking Panama .bashrc file to ~/.bashrc" ln -s "$PANAMA_BASH/.bashrc" "$HOME/.bashrc" -dirs=("espanso" "forge" "ghostty" "kitty" "nvim" "tmux") +# Each entry is symlinked as ~/.config/ -> $PANAMA_DOT/. +# "forge" is the GNOME tiling extension; "hypr"/"quickshell"/"wofi" are the +# Hyprland desktop. They coexist deliberately — the GNOME session keeps working +# while Hyprland is being used, so you can log back into either. +dirs=("espanso" "forge" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" "nvim" \ + "quickshell" "tmux" "uwsm" "vicinae" "wofi" "xdg-desktop-portal") # --- Vim vimrc --- echo -e "\n--- Setting up vim ---" @@ -64,3 +69,41 @@ for dir in "${dirs[@]}"; do ln -s "$PANAMA_DOT/$dir" "$CONFIG/$dir" log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir" done + +# Vicinae 0.26 discovers user themes from its XDG data directory rather than +# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of +# the launcher config and expose only that file at Vicinae's runtime path. +VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes" +VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml" +mkdir -p "$VICINAE_THEME_DIR" + +if [ -L "$VICINAE_THEME" ]; then + rm "$VICINAE_THEME" +fi + +if [ -e "$VICINAE_THEME" ]; then + log "Keeping existing Vicinae theme at $VICINAE_THEME" +else + ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME" + log "Linked Tokyo Night Moon theme → $VICINAE_THEME" +fi + +# Panama-native applications live in the user data directory so launchers can +# discover them alongside system desktop entries. Keep each authored file in +# the repository and expose it with a narrow per-file symlink. +PANAMA_APPLICATION_DIR="$PANAMA_PATH/config/local/share/applications" +USER_APPLICATION_DIR="$HOME/.local/share/applications" +mkdir -p "$USER_APPLICATION_DIR" +for desktop_file in "$PANAMA_APPLICATION_DIR"/*.desktop; do + [[ -e "$desktop_file" ]] || continue + desktop_name="$(basename "$desktop_file")" + desktop_target="$USER_APPLICATION_DIR/$desktop_name" + if [[ -L "$desktop_target" ]]; then + rm "$desktop_target" + elif [[ -e "$desktop_target" ]]; then + log "Keeping existing desktop entry at $desktop_target" + continue + fi + ln -s "$desktop_file" "$desktop_target" + log "Linked $desktop_name → $desktop_target" +done diff --git a/tests/quickshell/activity-indicator-contract.sh b/tests/quickshell/activity-indicator-contract.sh new file mode 100755 index 0000000..30aea09 --- /dev/null +++ b/tests/quickshell/activity-indicator-contract.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +fail() { + printf 'activity-indicator contract: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + qs ipc call activity close >/dev/null 2>&1 || true + qs ipc call activity clear >/dev/null 2>&1 || true + qs ipc call status-events reset >/dev/null 2>&1 || true +} +trap cleanup EXIT + +[[ -f "$repo_dir/config/dot/quickshell/modules/bar/ActivityIndicator.qml" ]] || fail 'bar indicator is missing' +[[ -f "$repo_dir/config/dot/quickshell/modules/bar/ActivityPanel.qml" ]] || fail 'activity detail panel is missing' +rg -q 'ActivityIndicator \{' "$repo_dir/config/dot/quickshell/modules/bar/Bar.qml" || fail 'bar does not instantiate the activity indicator' +! rg -q '^[[:space:]]*RecordingIndicator \{\}' "$repo_dir/config/dot/quickshell/shell.qml" || fail 'legacy recording popup is still instantiated' + +qs ipc call activity fixture microphone-on >/dev/null +qs ipc call activity open >/dev/null +for _ in $(seq 1 20); do + if hyprctl layers | rg -q 'namespace: qs-popover-activity'; then + printf 'activity-indicator contract: PASS\n' + exit 0 + fi + sleep 0.1 +done + +fail 'active privacy state did not map its detail panel' diff --git a/tests/quickshell/activity-state-contract.sh b/tests/quickshell/activity-state-contract.sh new file mode 100755 index 0000000..67eeb61 --- /dev/null +++ b/tests/quickshell/activity-state-contract.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +fail() { + printf 'activity-state contract: %s\n' "$1" >&2 + exit 1 +} + +activity() { + qs ipc call activity status | jq -r ".${1}" +} + +event() { + qs ipc call status-events status | jq -r ".${1}" +} + +cleanup() { + qs ipc call activity clear >/dev/null 2>&1 || true + qs ipc call status-events reset >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs ipc show | rg -q '^target activity$' || fail 'activity IPC target is missing' +rg -q 'StatusEvents.publish' "$repo_dir/config/dot/quickshell/services/Capture.qml" || fail 'capture does not publish recording transitions' +rg -q 'status-events screenshot' "$repo_dir/config/dot/quickshell/services/Capture.qml" || fail 'successful screenshot path does not publish an event' +rg -q 'StatusEvents.publish' "$repo_dir/config/dot/quickshell/services/NightLight.qml" || fail 'Night Light does not publish transitions' + +qs ipc call status-events reset >/dev/null +qs ipc call activity fixture microphone-on >/dev/null +[[ "$(activity microphoneActive)" == "true" ]] || fail 'microphone state did not become persistent' +[[ "$(event activeKey)" == "privacy-microphone" ]] || fail 'microphone transition did not publish Signal Glass event' + +qs ipc call status-events dismiss >/dev/null +[[ "$(activity microphoneActive)" == "true" ]] || fail 'capsule dismissal cleared persistent privacy state' + +qs ipc call activity fixture all-off >/dev/null +[[ "$(activity microphoneActive)" == "false" ]] || fail 'microphone state did not clear' +[[ "$(event activeKey)" == "privacy-microphone" ]] || fail 'microphone release did not publish a transition' +[[ "$(event title)" == "Microphone released" ]] || fail 'microphone release copy is incorrect' + +trap - EXIT +cleanup +printf 'activity-state contract: PASS\n' diff --git a/tests/quickshell/bluetooth-discovery-contract.sh b/tests/quickshell/bluetooth-discovery-contract.sh new file mode 100755 index 0000000..c6654c7 --- /dev/null +++ b/tests/quickshell/bluetooth-discovery-contract.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +bluetooth="$repo_dir/config/dot/quickshell/modules/quicksettings/BluetoothList.qml" + +rg -q 'const shouldDiscover = root\.active && root\.adapter\.enabled' "$bluetooth" || { + printf 'bluetooth discovery contract: desired state is not calculated idempotently\n' >&2 + exit 1 +} +rg -q 'shouldDiscover && !root\.adapter\.discovering' "$bluetooth" || { + printf 'bluetooth discovery contract: redundant BlueZ starts are not guarded\n' >&2 + exit 1 +} +rg -q '!shouldDiscover && root\.discoveryOwned && root\.adapter\.discovering' "$bluetooth" || { + printf 'bluetooth discovery contract: the picker may stop discovery it does not own\n' >&2 + exit 1 +} +! rg -q 'Component\.onCompleted: root\.syncDiscovery' "$bluetooth" || { + printf 'bluetooth discovery contract: inactive construction still writes BlueZ state\n' >&2 + exit 1 +} +rg -Uq 'onAdapterChanged:[^{\n]*\{[^}]*if \(root\.active\)' "$bluetooth" || { + printf 'bluetooth discovery contract: adapter changes are not gated by an open panel\n' >&2 + exit 1 +} +rg -Uq 'Component\.onDestruction:[^{\n]*\{[^}]*root\.discoveryOwned' "$bluetooth" || { + printf 'bluetooth discovery contract: owned discovery is not released on destruction\n' >&2 + exit 1 +} + +printf 'bluetooth discovery contract: PASS\n' diff --git a/tests/quickshell/curated-events-policy-contract.sh b/tests/quickshell/curated-events-policy-contract.sh new file mode 100755 index 0000000..e499723 --- /dev/null +++ b/tests/quickshell/curated-events-policy-contract.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +events="$repo_dir/config/dot/quickshell/services/StatusEvents.qml" +focus="$repo_dir/config/dot/quickshell/services/FocusSession.qml" +devices="$repo_dir/config/dot/quickshell/services/DeviceEvents.qml" +privacy="$repo_dir/config/dot/quickshell/services/PrivacyState.qml" + +fail() { + printf 'curated-events policy: %s\n' "$1" >&2 + exit 1 +} + +rg -q 'action === "open-workspace"' "$events" || fail 'broker cannot route a retained workspace action' +rg -q 'actionId: "open-workspace"' "$focus" || fail 'focus completion does not retain an actionable workspace destination' +rg -q 'actionData: String\(finishedWorkspaceId\)' "$focus" || fail 'focus completion omits its workspace id' +rg -q 'id: discoverySettle' "$devices" || fail 'device discovery has no silent initialization window' +! rg -Uq 'Component\.onCompleted:[^{\n]*\{[^}]*outputInitialized = true' "$devices" || fail 'output monitoring announces initial discovery' +rg -Uq 'function clearFixture\(\): void \{[^}]*initialized = false' "$privacy" || fail 'leaving a fixture can emit a false privacy transition' + +printf 'curated-events policy: PASS\n' diff --git a/tests/quickshell/focus-session-contract.sh b/tests/quickshell/focus-session-contract.sh new file mode 100755 index 0000000..7c9d6d8 --- /dev/null +++ b/tests/quickshell/focus-session-contract.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'focus-session contract: %s\n' "$1" >&2 + exit 1 +} + +read_prop() { + qs ipc call focus status | jq -r ".${1}" +} + +qs ipc show | rg -q '^target focus$' || fail 'focus IPC target is missing' + +# Keep this contract reversible even if an assertion fails halfway through. +cleanup() { + qs ipc call focus end >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs ipc call focus start >/dev/null +[[ "$(read_prop active)" == "true" ]] || fail 'start did not activate a session' +[[ "$(read_prop paused)" == "false" ]] || fail 'new session started paused' +[[ "$(read_prop workspaceId)" =~ ^[1-9][0-9]*$ ]] || fail 'session did not bind a positive workspace' + +qs ipc call focus dismiss >/dev/null +[[ "$(read_prop capsuleVisible)" == "false" ]] || fail 'dismiss did not hide the capsule' + +qs ipc call focus pause >/dev/null +[[ "$(read_prop paused)" == "true" ]] || fail 'pause did not freeze the session' + +qs ipc call focus reveal >/dev/null +[[ "$(read_prop capsuleVisible)" == "true" ]] || fail 'reveal did not show the capsule' + +qs ipc call focus overview >/dev/null +sleep 0.2 +hyprctl layers | rg -q 'namespace: qs-overview' || fail 'focus overview did not map Mission Control' +qs ipc call overview close >/dev/null + +qs ipc call focus end >/dev/null +[[ "$(read_prop active)" == "false" ]] || fail 'end did not clear the session' + +trap - EXIT +printf 'focus-session contract: PASS\n' diff --git a/tests/quickshell/focus-session-expiry.sh b/tests/quickshell/focus-session-expiry.sh new file mode 100755 index 0000000..b34acf2 --- /dev/null +++ b/tests/quickshell/focus-session-expiry.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'focus-session expiry: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + qs ipc call focus end >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs ipc call focus end >/dev/null 2>&1 || true +qs ipc call focus start >/dev/null + +state_file=$(find "${XDG_STATE_HOME:-$HOME/.local/state}/quickshell/by-shell" -name focus-session.json -print -quit) +[[ -n "$state_file" ]] || fail 'persistent state file was not created' + +expired=$(mktemp "${state_file}.expired.XXXXXX") +jq '.paused = false | .deadlineMs = 1' "$state_file" >"$expired" +chmod --reference="$state_file" "$expired" +mv "$expired" "$state_file" + +qs kill >/dev/null +qs --daemonize >/dev/null + +status="" +for _ in $(seq 1 60); do + status=$(qs ipc call focus status 2>/dev/null || true) + if jq -e '.active == false' <<<"$status" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +jq -e '.active == false and .caffeine == false and .doNotDisturb == false' <<<"$status" >/dev/null || fail 'expired session was not completed and released' + +trap - EXIT +printf 'focus-session expiry: PASS\n' diff --git a/tests/quickshell/focus-session-restart.sh b/tests/quickshell/focus-session-restart.sh new file mode 100755 index 0000000..268860d --- /dev/null +++ b/tests/quickshell/focus-session-restart.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'focus-session restart: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + qs ipc call focus end >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs ipc call focus end >/dev/null 2>&1 || true +qs ipc call focus start >/dev/null +qs ipc call focus pause >/dev/null +before=$(qs ipc call focus status) + +qs kill >/dev/null +qs --daemonize >/dev/null + +status="" +for _ in $(seq 1 60); do + status=$(qs ipc call focus status 2>/dev/null || true) + if jq -e '.active == true and .paused == true' <<<"$status" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +jq -e '.active == true' <<<"$status" >/dev/null || fail 'active session was not restored' +jq -e '.paused == true' <<<"$status" >/dev/null || fail 'paused state was not restored' +[[ "$(jq -r .remaining <<<"$status")" == "$(jq -r .remaining <<<"$before")" ]] || fail 'paused remainder changed across restart' +jq -e '.doNotDisturb == true' <<<"$status" >/dev/null || fail 'Do Not Disturb was not reclaimed' +jq -e '.caffeine == true' <<<"$status" >/dev/null || fail 'Caffeine was not reclaimed' + +cleanup +trap - EXIT +printf 'focus-session restart: PASS\n' diff --git a/tests/quickshell/overview-keyboard-contract.sh b/tests/quickshell/overview-keyboard-contract.sh new file mode 100755 index 0000000..adaf8aa --- /dev/null +++ b/tests/quickshell/overview-keyboard-contract.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +body="$repo_dir/config/dot/quickshell/modules/overview/OverviewBody.qml" +thumbnail="$repo_dir/config/dot/quickshell/modules/overview/WindowThumbnail.qml" + +fail() { + printf 'overview-keyboard contract: %s\n' "$1" >&2 + exit 1 +} + +rg -q 'Keys\.onLeftPressed' "$body" || fail 'Left does not navigate workspaces' +rg -q 'Keys\.onRightPressed' "$body" || fail 'Right does not navigate workspaces' +rg -q 'Keys\.onUpPressed' "$body" || fail 'Up does not navigate search results' +rg -q 'Keys\.onDownPressed' "$body" || fail 'Down does not navigate search results' +rg -q 'Keys\.onDeletePressed' "$body" || fail 'Delete cannot close the selected search result' +rg -q 'selectedResultIndex' "$body" || fail 'search results have no keyboard selection model' +rg -q 'property bool selected' "$thumbnail" || fail 'thumbnail cannot render keyboard selection' + +printf 'overview-keyboard contract: PASS\n' diff --git a/tests/quickshell/overview-live-actions.sh b/tests/quickshell/overview-live-actions.sh new file mode 100755 index 0000000..14e7f59 --- /dev/null +++ b/tests/quickshell/overview-live-actions.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'overview live actions: %s\n' "$1" >&2 + exit 1 +} + +original_workspace="$(hyprctl -j activeworkspace | jq -r '.id')" +address="" + +cleanup() { + qs ipc call overview close >/dev/null 2>&1 || true + if [[ -n "$address" ]] && hyprctl -j clients | jq -e --arg address "$address" '.[] | select(.address == $address)' >/dev/null; then + hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null 2>&1 || true + fi + hyprctl dispatch "hl.dsp.focus({ workspace = $original_workspace })" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +kitty --class panama-overview-contract --title "Panama Overview Contract" sh -c 'exec sleep 300' >/dev/null 2>&1 & + +for _ in $(seq 1 40); do + address="$(hyprctl -j clients | jq -r '.[] | select(.class == "panama-overview-contract") | .address' | head -1)" + [[ -n "$address" ]] && break + sleep 0.1 +done +[[ -n "$address" ]] || fail 'disposable window did not appear' + +target_workspace="$(hyprctl -j workspaces | jq -r --argjson current "$original_workspace" '[.[] | select(.id > 0 and .id != $current)][0].id // empty')" +if [[ -z "$target_workspace" ]]; then + target_workspace=$((original_workspace + 1)) +fi + +qs ipc call overview-actions move "$address" "$target_workspace" >/dev/null +sleep 0.25 +[[ "$(hyprctl -j clients | jq -r --arg address "$address" '.[] | select(.address == $address) | .workspace.name')" == "$target_workspace" ]] \ + || fail 'exact-address workspace move failed' + +qs ipc call overview-actions move "$address" special:scratch >/dev/null +sleep 0.25 +[[ "$(hyprctl -j clients | jq -r --arg address "$address" '.[] | select(.address == $address) | .workspace.name')" == "special:scratch" ]] \ + || fail 'exact-address scratchpad move failed' + +qs ipc call overview open >/dev/null +sleep 0.2 +qs ipc call overview-actions restore "$address" >/dev/null +sleep 0.3 +[[ "$(hyprctl -j clients | jq -r --arg address "$address" '.[] | select(.address == $address) | .workspace.id')" == "$original_workspace" ]] \ + || fail 'scratchpad restore did not return to the selected workspace' + +qs ipc call overview-actions close "$address" >/dev/null +for _ in $(seq 1 30); do + if ! hyprctl -j clients | jq -e --arg address "$address" '.[] | select(.address == $address)' >/dev/null; then + address="" + trap - EXIT + cleanup + printf 'overview live actions: PASS\n' + exit 0 + fi + sleep 0.1 +done + +fail 'exact-address close did not close the disposable window' diff --git a/tests/quickshell/overview-search-contract.sh b/tests/quickshell/overview-search-contract.sh new file mode 100755 index 0000000..563eddb --- /dev/null +++ b/tests/quickshell/overview-search-contract.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cleanup() { + qs ipc call overview close >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs ipc show | rg -A8 '^target overview$' | rg -q 'function search\(query: string\): void' +qs ipc call overview search ChatGPT >/dev/null + +for _ in $(seq 1 20); do + if hyprctl layers | rg -q 'namespace: qs-overview'; then + printf 'overview-search contract: PASS\n' + exit 0 + fi + sleep 0.1 +done + +printf 'overview-search contract: overview did not map\n' >&2 +exit 1 diff --git a/tests/quickshell/overview-window-actions-contract.sh b/tests/quickshell/overview-window-actions-contract.sh new file mode 100755 index 0000000..2a9c280 --- /dev/null +++ b/tests/quickshell/overview-window-actions-contract.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +body="$repo_dir/config/dot/quickshell/modules/overview/OverviewBody.qml" +thumbnail="$repo_dir/config/dot/quickshell/modules/overview/WindowThumbnail.qml" +workspace="$repo_dir/config/dot/quickshell/modules/overview/WorkspaceCard.qml" + +fail() { + printf 'overview-window-actions contract: %s\n' "$1" >&2 + exit 1 +} + +rg -q 'function addressOf\(toplevel: var\): string' "$body" || fail 'address normalization boundary is missing' +rg -q 'function closeToplevel\(toplevel: var\): void' "$body" || fail 'address-scoped close boundary is missing' +rg -q 'function moveToplevel\(toplevel: var, workspaceName: string\): void' "$body" || fail 'address-scoped move boundary is missing' +rg -q 'function restoreToplevel\(toplevel: var\): void' "$body" || fail 'scratchpad restore boundary is missing' +rg -q 'hl\.dsp\.window\.close\(\{ window = ' "$body" || fail 'close does not use the modern exact-window dispatcher' +rg -q 'hl\.dsp\.window\.move\(\{ workspace = ' "$body" || fail 'move does not use the modern exact-window dispatcher' +rg -q 'signal closeRequested' "$thumbnail" || fail 'thumbnail has no close intent' +rg -q 'Drag\.active:' "$thumbnail" || fail 'thumbnail has no drag source' +rg -q 'DropArea \{' "$workspace" || fail 'workspace card has no drop target' +qs ipc show | rg -q '^target overview-actions$' || fail 'live action contract surface is missing' + +printf 'overview-window-actions contract: PASS\n' diff --git a/tests/quickshell/scratchpad-shelf-contract.sh b/tests/quickshell/scratchpad-shelf-contract.sh new file mode 100755 index 0000000..bd9fd44 --- /dev/null +++ b/tests/quickshell/scratchpad-shelf-contract.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +shelf="$repo_dir/config/dot/quickshell/modules/overview/ScratchpadShelf.qml" +body="$repo_dir/config/dot/quickshell/modules/overview/OverviewBody.qml" +keys="$repo_dir/config/dot/hypr/keybinds.lua" + +fail() { + printf 'scratchpad-shelf contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -f "$shelf" ]] || fail 'global scratchpad shelf is missing' +rg -q 'special:scratch' "$body" || fail 'overview does not discover the global scratch workspace' +rg -q 'ScratchpadShelf \{' "$body" || fail 'overview does not instantiate the shelf' +rg -q 'DropArea \{' "$shelf" || fail 'shelf is not a drag destination' +rg -q 'onRestoreRequested' "$body" || fail 'shelf cards cannot restore to the selected workspace' +rg -q 'SUPER.*X|mod .*\+ X' "$keys" || fail 'Super+X scratchpad toggle disappeared' +rg -q 'SHIFT \+ X' "$keys" || fail 'Super+Shift+X send-to-scratchpad binding disappeared' + +qs ipc call overview open >/dev/null +for _ in $(seq 1 20); do + if hyprctl layers | rg -q 'namespace: qs-overview'; then + qs ipc call overview close >/dev/null + printf 'scratchpad-shelf contract: PASS\n' + exit 0 + fi + sleep 0.1 +done + +fail 'overview did not map with the shelf present' diff --git a/tests/quickshell/screen-intelligence-contract.sh b/tests/quickshell/screen-intelligence-contract.sh new file mode 100755 index 0000000..2780748 --- /dev/null +++ b/tests/quickshell/screen-intelligence-contract.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'screen intelligence contract: %s\n' "$1" >&2 + exit 1 +} + +fixture_dir="$(mktemp -d)" +cleanup() { + qs ipc call screen-intelligence close >/dev/null 2>&1 || true + qs ipc call capture close >/dev/null 2>&1 || true + rm -rf "$fixture_dir" +} +trap cleanup EXIT + +qs ipc show | rg -q '^target screen-intelligence$' || fail 'screen-intelligence IPC target is missing' + +status="$(qs ipc call screen-intelligence status)" +jq -e '.phase == "idle" and (.ocrReady | type == "boolean") and (.codeReady | type == "boolean")' <<<"$status" >/dev/null \ + || fail 'readiness status is malformed' + +fixture="$fixture_dir/panama-live-ocr.png" +magick -size 1600x260 xc:white \ + -gravity center -fill black -font DejaVu-Sans -pointsize 72 \ + -annotate 0 'PANAMA LIVE OCR' "$fixture" + +qs ipc call screen-intelligence analyzeFile "$fixture" >/dev/null +for _ in $(seq 1 100); do + status="$(qs ipc call screen-intelligence status)" + phase="$(jq -r .phase <<<"$status")" + [[ "$phase" == "ready" || "$phase" == "error" ]] && break + sleep 0.1 +done +[[ "$(jq -r .phase <<<"$status")" == "ready" ]] \ + || fail "fixture recognition did not finish successfully: $(jq -r .error <<<"$status")" +[[ "$(jq -r .text <<<"$status" | tr '\n' ' ')" == *'PANAMA LIVE OCR'* ]] \ + || fail 'live service did not expose recognized fixture text' + +qs ipc call screen-intelligence close >/dev/null +[[ "$(qs ipc call screen-intelligence status | jq -r .phase)" == "idle" ]] \ + || fail 'close did not reset recognition state' + +qs ipc call screen-intelligence open >/dev/null +for _ in $(seq 1 40); do + capture_status="$(qs ipc call capture status 2>/dev/null || true)" + if jq -e '.open == true' <<<"$capture_status" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +jq -e '.open == true and .mode == "selection" and .intelligenceMode == true' <<<"$capture_status" >/dev/null \ + || fail 'direct open did not select Selection + Read mode' + +printf 'screen intelligence contract: PASS\n' diff --git a/tests/quickshell/screen-intelligence-helper-contract.sh b/tests/quickshell/screen-intelligence-helper-contract.sh new file mode 100755 index 0000000..a3c906d --- /dev/null +++ b/tests/quickshell/screen-intelligence-helper-contract.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'screen intelligence helper contract: %s\n' "$1" >&2 + exit 1 +} + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$project_root/config/dot/quickshell/scripts/screen-intelligence" +fixture_dir="$(mktemp -d)" +trap 'rm -rf "$fixture_dir"' EXIT + +[[ -x "$helper" ]] || fail 'recognition helper is missing or not executable' +command -v magick >/dev/null || fail 'ImageMagick is required to build the OCR fixture' + +probe="$($helper probe)" +jq -e '.tesseract == true and .zbar == true' <<<"$probe" >/dev/null \ + || fail 'Tesseract and ZBar are not ready' + +fixture="$fixture_dir/panama-ocr.png" +magick -size 1600x260 xc:white \ + -gravity center -fill black -font DejaVu-Sans -pointsize 72 \ + -annotate 0 'PANAMA SCREEN INTELLIGENCE' "$fixture" + +result="$($helper analyze-file "$fixture")" +jq -e '.ok == true' <<<"$result" >/dev/null || fail "recognition failed: $(jq -r .error <<<"$result")" +text="$(jq -r .text <<<"$result" | tr '\n' ' ' | tr -s ' ')" +[[ "$text" == *'PANAMA SCREEN INTELLIGENCE'* ]] \ + || fail "literal fixture text was not recognized: $text" + +printf 'screen intelligence helper contract: PASS\n' diff --git a/tests/quickshell/settings-pages-contract.sh b/tests/quickshell/settings-pages-contract.sh new file mode 100755 index 0000000..d35c19d --- /dev/null +++ b/tests/quickshell/settings-pages-contract.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'settings pages contract: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + qs ipc call settings close >/dev/null 2>&1 || true +} +trap cleanup EXIT + +pages=(home appearance displays connectivity desktop sound notifications screen-intelligence shortcuts services about) +for page in "${pages[@]}"; do + qs ipc call settings page "$page" >/dev/null + for _ in $(seq 1 20); do + [[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] && break + sleep 0.1 + done + [[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route" + hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \ + || fail "$page created a missing or duplicate Settings window" +done + +qs ipc call settings page '__unsupported__' >/dev/null +[[ "$(qs ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home' + +hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \ + || fail 'Super+I is not registered as Panama Settings' +hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \ + || fail 'Super+Shift+S is not registered as Screen Intelligence' + +desktop_file="$HOME/.local/share/applications/panama-settings.desktop" +[[ -L "$desktop_file" || -f "$desktop_file" ]] || fail 'searchable desktop entry is not installed' +desktop-file-validate "$desktop_file" >/dev/null || fail 'desktop entry is invalid' +gnome_desktop_file="$HOME/.local/share/applications/gnome-settings-hyprland.desktop" +[[ -L "$gnome_desktop_file" || -f "$gnome_desktop_file" ]] || fail 'searchable GNOME Settings fallback is not installed' +desktop-file-validate "$gnome_desktop_file" >/dev/null || fail 'GNOME Settings fallback entry is invalid' +intelligence_desktop_file="$HOME/.local/share/applications/panama-screen-intelligence.desktop" +[[ -L "$intelligence_desktop_file" || -f "$intelligence_desktop_file" ]] || fail 'Screen Intelligence desktop entry is not installed' +desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen Intelligence desktop entry is invalid' + +trap - EXIT +cleanup +printf 'settings pages contract: PASS\n' diff --git a/tests/quickshell/settings-preferences-contract.sh b/tests/quickshell/settings-preferences-contract.sh new file mode 100755 index 0000000..731e641 --- /dev/null +++ b/tests/quickshell/settings-preferences-contract.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/settings-preferences-harness.qml" +state_home="$(mktemp -d /tmp/panama-settings-state.XXXXXX)" + +fail() { + printf 'settings preferences contract: %s\n' "$1" >&2 + exit 1 +} + +qs_for_harness() { + XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" +} + +cleanup() { + qs_for_harness kill >/dev/null 2>&1 || true +} +trap cleanup EXIT + +start_harness() { + XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null + for _ in $(seq 1 40); do + if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-pref-test$'; then + return + fi + sleep 0.1 + done + fail 'test IPC target did not start' +} + +stop_harness() { + qs_for_harness kill >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + if ! qs_for_harness ipc show >/dev/null 2>&1; then + return + fi + sleep 0.1 + done + fail 'test shell did not stop cleanly' +} + +start_harness +qs_for_harness ipc call settings-pref-test applyFixture >/dev/null + +expected='{"use24Hour":true,"showSeconds":false,"dockAutohide":false,"focusDurationMinutes":70,"autoHdr":false,"vrrPolicy":0,"directScanoutPolicy":0,"nightLightEnabled":true,"nightLightAutomatic":true,"nightLightTemperature":4100,"lastPage":"desktop"}' +before="$(qs_for_harness ipc call settings-pref-test status | jq -c 'del(.stateDir)')" +[[ "$before" == "$expected" ]] || fail "fixture did not apply: $before" + +state_file="" +for _ in $(seq 1 40); do + state_file="$(find "$state_home" -name panama-settings.json -print -quit)" + if [[ -n "$state_file" ]] && jq -e '.lastPage == "desktop" and .focusDurationMinutes == 70' "$state_file" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +[[ -n "$state_file" ]] || fail 'preferences file was not created' +jq -e '.use24Hour == true and .showSeconds == false and .dockAutohide == false and .autoHdr == false and .directScanoutPolicy == 0 and .nightLightEnabled == true and .nightLightAutomatic == true and .nightLightTemperature == 4100' "$state_file" >/dev/null \ + || fail 'preferences file did not contain the complete atomic update' + +# The contract is specifically restart persistence, not merely live QML state. +stop_harness +start_harness +after="" +for _ in $(seq 1 40); do + after="$(qs_for_harness ipc call settings-pref-test status | jq -c 'del(.stateDir)')" + [[ "$after" == "$expected" ]] && break + sleep 0.1 +done +[[ "$after" == "$expected" ]] || fail "values did not survive restart: $after" + +trap - EXIT +cleanup +printf 'settings preferences contract: PASS\n' diff --git a/tests/quickshell/settings-system-contract.sh b/tests/quickshell/settings-system-contract.sh new file mode 100755 index 0000000..ce50c80 --- /dev/null +++ b/tests/quickshell/settings-system-contract.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml" + +fail() { + printf 'settings system contract: %s\n' "$1" >&2 + exit 1 +} + +qs_for_harness() { + qs -p "$harness" "$@" +} + +cleanup() { + qs_for_harness kill >/dev/null 2>&1 || true +} +trap cleanup EXIT + +original_auto_hdr="$(hyprctl -j getoption render:cm_auto_hdr | jq -r .int)" +original_vrr="$(hyprctl -j getoption misc:vrr | jq -r .int)" +original_direct="$(hyprctl -j getoption render:direct_scanout | jq -r .int)" + +qs_for_harness --daemonize >/dev/null +for _ in $(seq 1 40); do + if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then + break + fi + sleep 0.1 +done +qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start' + +qs_for_harness ipc call settings-system-test refresh >/dev/null +status='{}' +for _ in $(seq 1 40); do + status="$(qs_for_harness ipc call settings-system-test status | jq -c .)" + if jq -e '.monitorName != "" and .width > 0 and .height > 0 and .busy == false' <<<"$status" >/dev/null; then + break + fi + sleep 0.1 +done +jq -e '.monitorName == "DP-2" and .width == 4500 and .height == 3000 and .scale == 1.5 and .format == "XRGB2101010"' <<<"$status" >/dev/null \ + || fail "live monitor data was not normalized: $status" + +[[ "$(qs_for_harness ipc call settings-system-test panelAllowed '__definitely_not_a_panel__' | jq -r .)" == "false" ]] \ + || fail 'unsupported GNOME panel was accepted' +[[ "$(qs_for_harness ipc call settings-system-test panelAllowed sound | jq -r .)" == "true" ]] \ + || fail 'supported GNOME sound panel was rejected' + +# Apply the exact current values: this exercises the real allow-listed command +# without changing the daily-driver compositor state. +qs_for_harness ipc call settings-system-test apply "$([[ "$original_auto_hdr" == 1 ]] && printf true || printf false)" "$original_vrr" "$original_direct" >/dev/null +sleep 0.3 + +[[ "$(hyprctl -j getoption render:cm_auto_hdr | jq -r .int)" == "$original_auto_hdr" ]] || fail 'Auto HDR changed unexpectedly' +[[ "$(hyprctl -j getoption misc:vrr | jq -r .int)" == "$original_vrr" ]] || fail 'VRR changed unexpectedly' +[[ "$(hyprctl -j getoption render:direct_scanout | jq -r .int)" == "$original_direct" ]] || fail 'direct scanout changed unexpectedly' + +trap - EXIT +cleanup +printf 'settings system contract: PASS\n' diff --git a/tests/quickshell/settings-window-contract.sh b/tests/quickshell/settings-window-contract.sh new file mode 100755 index 0000000..4afad11 --- /dev/null +++ b/tests/quickshell/settings-window-contract.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'settings window contract: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + qs ipc call settings close >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing' +qs ipc call settings open >/dev/null + +for _ in $(seq 1 40); do + if hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null; then + break + fi + sleep 0.1 +done +hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \ + || fail 'exactly one Settings window did not map' +hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings" and .floating == false)' >/dev/null \ + || fail 'Settings window is not tiled' + +qs ipc call settings page displays >/dev/null +[[ "$(qs ipc call settings status | jq -r .page)" == "displays" ]] || fail 'Displays page did not route' + +qs ipc call settings page desktop >/dev/null +[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route' + +address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Panama Settings") | .address')" +hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null +for _ in $(seq 1 40); do + [[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break + sleep 0.1 +done +[[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] || fail 'compositor close did not synchronize shell state' + +qs ipc call settings open >/dev/null +for _ in $(seq 1 40); do + hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null && break + sleep 0.1 +done + +qs ipc call settings close >/dev/null +for _ in $(seq 1 40); do + if ! hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null; then + trap - EXIT + printf 'settings window contract: PASS\n' + exit 0 + fi + sleep 0.1 +done + +fail 'Settings window did not close' diff --git a/tests/quickshell/signal-glass-contract.sh b/tests/quickshell/signal-glass-contract.sh new file mode 100755 index 0000000..472d0c1 --- /dev/null +++ b/tests/quickshell/signal-glass-contract.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +shell_file="$repo_dir/config/dot/quickshell/shell.qml" +rules_file="$repo_dir/config/dot/hypr/rules.lua" + +fail() { + printf 'signal-glass contract: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + qs ipc call status-events reset >/dev/null 2>&1 || true + qs ipc call focus end >/dev/null 2>&1 || true +} +trap cleanup EXIT + +[[ -f "$repo_dir/config/dot/quickshell/modules/signals/SignalGlass.qml" ]] || fail 'shared host is missing' +[[ -f "$repo_dir/config/dot/quickshell/modules/signals/EventCard.qml" ]] || fail 'event presentation is missing' +[[ -f "$repo_dir/config/dot/quickshell/modules/signals/FocusCard.qml" ]] || fail 'focus presentation is missing' +[[ "$(rg -c 'SignalGlass \{\}' "$shell_file")" == "1" ]] || fail 'shell does not instantiate exactly one shared host delegate' +! rg -q 'FocusCapsule \{\}' "$shell_file" || fail 'legacy focus-only host is still instantiated' +rg -q 'namespace = "\^qs-signal-glass\$"' "$rules_file" || fail 'Hyprland layer rules do not match the shared namespace' + +qs ipc call status-events reset >/dev/null +qs ipc call status-events fixture critical >/dev/null +for _ in $(seq 1 20); do + if hyprctl layers | rg -q 'namespace: qs-signal-glass'; then + printf 'signal-glass contract: PASS\n' + exit 0 + fi + sleep 0.1 +done + +fail 'event did not map the shared Signal Glass layer' diff --git a/tests/quickshell/status-events-contract.sh b/tests/quickshell/status-events-contract.sh new file mode 100755 index 0000000..1594a31 --- /dev/null +++ b/tests/quickshell/status-events-contract.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'status-events contract: %s\n' "$1" >&2 + exit 1 +} + +status() { + qs ipc call status-events status | jq -r ".${1}" +} + +cleanup() { + qs ipc call status-events reset >/dev/null 2>&1 || true + if [[ "${restore_dnd:-false}" == "true" ]]; then + qs ipc call notifications dnd >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +qs ipc show | rg -q '^target status-events$' || fail 'IPC target is missing' +qs ipc call status-events reset >/dev/null + +qs ipc call status-events fixture ambient >/dev/null +[[ "$(status activeKey)" == "device-output" ]] || fail 'ambient fixture did not become active' +[[ "$(status queueLength)" == "0" ]] || fail 'first event unexpectedly queued' + +qs ipc call status-events fixture ambient-replacement >/dev/null +[[ "$(status activeKey)" == "device-output" ]] || fail 'equivalent event changed its stable key' +[[ "$(status title)" == "Studio Display" ]] || fail 'equivalent event did not replace active content' +[[ "$(status queueLength)" == "0" ]] || fail 'equivalent event stacked instead of replacing' + +qs ipc call status-events fixture critical >/dev/null +[[ "$(status activeKey)" == "privacy-microphone" ]] || fail 'critical event did not preempt ambient event' +[[ "$(status priority)" == "90" ]] || fail 'critical event priority was not preserved' +[[ "$(status queueLength)" == "1" ]] || fail 'preempted event was not retained once' + +qs ipc call status-events dismiss >/dev/null +[[ "$(status activeKey)" == "device-output" ]] || fail 'dismiss did not reveal the queued ambient event' + +qs ipc call status-events reset >/dev/null +dnd_now="$(qs ipc call notifications dnd)" +if [[ "$dnd_now" == "true" ]]; then + restore_dnd=true +fi +qs ipc call status-events fixture ambient >/dev/null +[[ "$(status active)" == "false" ]] || fail 'DND did not suppress an ambient event' + +qs ipc call status-events fixture critical >/dev/null +[[ "$(status activeKey)" == "privacy-microphone" ]] || fail 'DND suppressed a privacy event' + +trap - EXIT +cleanup +printf 'status-events contract: PASS\n'