Build the Panama Hyprland desktop

This commit is contained in:
Gabriel Brown
2026-08-17 10:32:55 -04:00
parent 67033f2a31
commit 5248883e4b
190 changed files with 18554 additions and 34 deletions
+5
View File
@@ -24,3 +24,8 @@ else
fi
export PATH
fi
. "$HOME/.cargo/env"
# >>> Codex installer >>>
export PATH="/home/gib/.local/bin:$PATH"
# <<< Codex installer <<<
@@ -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"
+4
View File
@@ -67,6 +67,10 @@
{
"wmClass": "Spotify",
"mode": "float"
},
{
"wmClass": "bitwarden",
"mode": "float"
}
]
}
@@ -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;
@@ -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;
}
+7 -7
View File
@@ -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
+12 -4
View File
@@ -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
+10 -1
View File
@@ -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
+101
View File
@@ -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:
- <https://quickshell.org/docs/v0.3.0/types/Quickshell/DBusMenu/>
- <https://github.com/hyprwm/Hyprland/discussions/1358>
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.
+250
View File
@@ -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 <name>.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.
+60
View File
@@ -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
+84
View File
@@ -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
+39
View File
@@ -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'.
+26
View File
@@ -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")
+114
View File
@@ -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 = <span foreground="##828bb8"><i>Password</i></span>
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
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
}
+36
View File
@@ -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
+34
View File
@@ -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
+40
View File
@@ -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
+243
View File
@@ -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-<edge>-<increase|decrease>).
--
-- 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
+218
View File
@@ -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
+43
View File
@@ -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
+33
View File
@@ -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
+207
View File
@@ -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
@@ -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";
}
}
+104
View File
@@ -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<string> 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"
}
+154
View File
@@ -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<real> easeStandard: [0.05, 0.9, 0.1, 1.0]
readonly property list<real> 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
}
+4
View File
@@ -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
@@ -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
}
}
@@ -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
}
}
+134
View File
@@ -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()
}
}
}
}
@@ -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()
}
}
@@ -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
}
}
}
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
}
@@ -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
}
}
}
}
@@ -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;
}
}
}
}
}
}
}
@@ -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()
}
}
@@ -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
}
}
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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" })');
}
}
@@ -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()
}
}
}
}
}
}
@@ -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()
}
}
}
@@ -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
}
}
@@ -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()
}
}
}
@@ -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()
}
}
@@ -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()
}
}
}
}
}
@@ -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();
}
}
}
@@ -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
}
}
@@ -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();
}
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
}
@@ -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
@@ -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;
}
}
}
}
}
}
}
@@ -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
}
}
@@ -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
}
}
}
@@ -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()
}
}
}
@@ -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()
}
}
@@ -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)
}
}
}
}
}
@@ -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)
}
}
}
}
@@ -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
}
}
}
@@ -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
+152
View File
@@ -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
}
}
}
}
}
@@ -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
}
}
}
@@ -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();
}
}
}
@@ -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"])
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -0,0 +1,3 @@
module qs.modules.focus
FocusAction 1.0 FocusAction.qml
FocusIndicator 1.0 FocusIndicator.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
}
}
}
@@ -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)
}
}
}
}
}
}
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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();
}
}
}
}
@@ -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();
}
}
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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)
}
}
}
}
}
}
@@ -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)
}
}
}
}
@@ -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()
}
}
@@ -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()
}
}
}
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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")
}
}
}
}
}
@@ -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
}
}
@@ -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))
}
}
@@ -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
}
}
@@ -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()
}
}
}
}
}
}
}
@@ -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 }
}
}
}
}
@@ -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 } }
}
}
}
}
@@ -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: "WiFi"
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
SettingRow {
label: "WiFi"
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") }
}
}
}
}
}
@@ -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() }
}
}
}
}
}
@@ -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
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More