Author SHA1 Message Date
Gabriel Brown 83391e0453 Make settings restore crash-safe 2026-08-18 02:22:23 -04:00
Gabriel Brown 172b099a04 Complete settings snapshot restoration 2026-08-18 02:22:23 -04:00
Gabriel Brown cae72b5179 Make Settings sidebar content scrollable 2026-08-18 01:54:00 -04:00
Gabriel Brown b3b8e0d66d Prevent shortcut reset collisions 2026-08-18 01:47:51 -04:00
Gabriel Brown 787d2b121a Use the durable Home reset boundary 2026-08-18 01:39:34 -04:00
Gabriel Brown 1b323c2fa5 Document Settings completion plan 2026-08-18 01:36:30 -04:00
Gabriel Brown 1ca571458c Complete Panama settings controls 2026-08-18 01:36:07 -04:00
Gabriel Brown 768801dbe4 Fix application role filtering 2026-08-18 01:28:29 -04:00
Gabriel Brown 375ecfcd95 Add application and autostart settings 2026-08-18 01:28:29 -04:00
Gabriel Brown ce95b34d19 Harden Home preferences reset contract 2026-08-18 01:28:29 -04:00
Gabriel Brown 9d430a3079 Finish Home and Phone settings cohesion 2026-08-18 01:28:29 -04:00
Gabriel Brown bac68d2bfb Document the settings vocabulary and its traps
Records how to add a setting -- one schema entry -- and the failure
modes found building the pages. Every one of them fails silently rather
than loudly, which is why they are worth writing down: a wrong readAs
makes successful writes look rejected, hyprpaper accepts an all-outputs
wallpaper request and ignores it, and a copy of the Quickshell config
shares the live shell's ID so an "isolated" harness can kill the running
desktop.

Also documents the two generated configurations and why they are
generated: ~/.config/hypr is a symlink into this repository, so writing
there at runtime would commit machine state to a tracked file.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:15:53 -04:00
Gabriel Brown 634a9ebe07 Let shortcuts be rebound from Settings
Every bind in keybinds.lua now goes through a small wrapper that
substitutes the chord from a stored override. Only the chord is taken
from settings; the action is always the Lua value written in that file,
so an override can move a shortcut but can never make one do something
else. That is the property that makes reading them from a file the user
can edit safe, and it is why the alternative -- storing dispatchers --
was not considered.

Overrides are keyed by the shipped chord rather than the description.
Keying by description moved every bind that shared one: rebinding
SUPER+C also moved the XF86Calculator hardware key onto the same chord,
silently costing it. Chords are unique; descriptions are not.

Applying needs hyprctl reload rather than a live hl.bind. Hyprland
reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
so the action cannot be reconstructed from outside to re-bind it; reload
re-runs the config, which re-reads the settings file.

The capture control ignores modifier-only presses, because every chord
passes through them and holding Super would otherwise be captured the
moment the modifier went down. It refuses a bare letter, which would
swallow ordinary typing, and refuses a key with no keysym name rather
than storing something that would fail to bind. Rebinding onto a chord
already in use is refused rather than shadowing the existing shortcut.

The refactor was verified by snapshotting all 113 binds before and
after: the keymap is byte-identical, and identical again after applying
an override and resetting it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:13:36 -04:00
Gabriel Brown 150f3cdb09 Make the Dock editable and add settings snapshots
The Dock's pinned applications were a sixteen-entry literal in
Settings.qml, so changing what sits in the Dock meant editing QML. They
are now an ordered list in the shared store, with move up, move down,
unpin, and a filtered picker for adding installed applications. Keeping
them in the shared store rather than a file of their own means they are
covered by Restore defaults like everything else.

This needed a "json" schema type for values the schema stores and resets
but does not validate field by field. It exists so structured settings
can live in the one file rather than growing a fourth preference store;
the owning service validates the contents.

Snapshots make the settings app safe to experiment with. The whole
configuration is one file, so a backup is a copy and a restore is an
overwrite, and restoring snapshots what it replaces so it is itself
undoable. A snapshot is validated as JSON before it can be restored over
a working configuration, and a name that is not a plain snapshot
filename from the backup directory is refused.

Snapshot names carry milliseconds. At one-second resolution a save
followed promptly by a restore produced the same filename twice, and the
restore's own safety snapshot overwrote the file it was about to read --
found by the contract, which restores immediately after saving.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:59:08 -04:00
Gabriel Brown 6377bfb8fd Route the Applications page and add its settings
Wires the schema entries and routing the codex agent needs for default
applications, weather, vitals refresh, notification timing, and capture,
so its pages can be written against keys that already exist.

Capture directories and encoder arguments are enums rather than free
text. Both are handed to a recorder process, and a settings page has no
reason to expose an arbitrary string there.

ApplicationsPage.qml is a placeholder so the page id can be routed,
registered, and searchable before the real page lands. It is owned by
the other agent and expected to be replaced wholesale.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:51:10 -04:00
Gabriel Brown 2bc12e6022 Add wallpaper, power, date, accessibility, and real search
Continues the settings expansion toward replacing GNOME Settings for
everything Panama actually owns.

Wallpaper. A thumbnail grid rather than a path field: the value of this
setting is the picture, so typing a path to something you cannot see is
the worst version of it. Two things about hyprpaper 0.8 shaped this. Its
IPC is much smaller than older documentation suggests -- preload,
listloaded, unload, and reload all answer "invalid hyprpaper request",
so setting is a single call with no preload. And the "<empty>,<path>"
form that used to mean every output is silently ignored, so a wallpaper
set that way appears to succeed and never changes; outputs are walked
explicitly instead. hyprpaper.conf lives in the repo through the
~/.config/hypr symlink and so cannot hold machine state, which is why
the choice lives in the shared settings store and is re-applied at
startup.

Power & Lock. hypridle has no IPC for reconfiguration and its config is
hyprlang rather than the shared JSON, so scripts/panama-idle generates a
config from the settings store and restarts the daemon. The generated
file lives under XDG_STATE_HOME for the same symlink reason, with a
systemd drop-in pointing hypridle at it. Management is a real state and
the page says which one you are in rather than showing sliders that
quietly do nothing. Zero means never for all three timers, which a naive
template would render as "immediately".

Date & Time. Deliberately not stored in Panama's settings: the timezone
and network time belong to the machine and are shared with sessions that
never see this file. Storing a copy would create a second answer to a
question the system already answers. Reads and writes timedatectl
directly; a cancelled polkit prompt surfaces as an error rather than as
a value that appears to have been accepted.

Accessibility. Pointer size and text scale have to agree across three
consumers with no shared configuration -- the compositor, GTK, and the
shell -- so the store is the source of truth and the values are pushed
outward to gsettings and hyprctl setcursor.

Search now indexes the schema instead of the twelve page labels. "gaps",
"wallpaper", and "screenshot" previously found nothing on an app that
has all three, which is the clearest way a settings app feels smaller
than it is. Shortcuts are indexed by what they do. A contract asserts
every non-internal schema label is reachable, so a new setting cannot be
added in an undiscoverable state.

The GNOME delegation allow-list was widened to the panel names
gnome-control-center actually reports; the previous list contained
"users", which is not one of them and so opened nothing.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:42:13 -04:00
Gabriel Brown fe7c85e471 Build the settings vocabulary and generate the keymap
Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and
TextRow. A row names a schema key and needs nothing else: label, detail,
range, and unit come from PreferenceSchema, and writes go through
SystemSettings.commitPreference, which routes compositor-backed keys
through apply-and-verify and local keys straight to the store. The page
scaffold that was copy-pasted eleven times is now one component.

Rebuild Appearance around a live preview of the real desktop, scaled by
the ratio between the preview and the actual monitor so a 10px gap on a
4500px display looks as small as it is. Rebuild Desktop & Dock and Input
& Shortcuts on the shared rows, replacing the read-only text that stood
in for controls that were merely expensive to add.

Generate the shortcut list from hyprctl binds. The page held a
hand-typed nineteen entries against a real keymap of a hundred and
thirteen; it could not show the rest and went stale whenever a bind
changed. Every bind now carries its own description -- backfilled for
the twenty-nine that lacked one -- and keybinds-contract.sh fails if any
bind lacks one, since undescribed binds are dropped from the page.

Make Restore defaults span every store Panama owns. Resetting only the
schema store left the Home accessory arrangement customised while
claiming to restore defaults, which is worse than no reset because it is
silent. Done through HomePreferences' existing public aliases rather
than a new API.

Four defects found while building:

cursor:inactive_timeout is answered by getoption as float, not int. A
wrong readAs does not fail loudly; it makes every write to that key look
rejected, and the user saw an error for a change that worked.
schema-hypr-shape-contract.sh now checks all 23 mapped options against
the running compositor.

The Settings window is tiled, so implicitWidth is only a hint and rows
must survive roughly 400px. SliderRow stacks its control under the label
below 520px.

Binding an anchor to undefined to switch layouts does not reliably
release it. Both row layouts are positioned explicitly.

Concurrent compositor writes are queued and merged rather than refused.
The startup replay of every compositor-backed preference routinely
overlaps a UI change, and refusing left the store and the compositor
disagreeing.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:24:51 -04:00
Gabriel Brown 8a04e4f9d1 Record live Home accessory verification 2026-08-17 23:33:11 -04:00
Gabriel Brown 5347954a88 Merge branch 'feat/home-accessories-customization'
# Conflicts:
#	config/dot/quickshell/config/qmldir
#	config/dot/quickshell/services/SystemSettings.qml
#	tests/quickshell/settings-pages-contract.sh
2026-08-17 23:31:33 -04:00
Gabriel Brown 00a81edadd Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because
they had been typed to agree: looks.lua hardcoded values, DesktopPreferences
independently defaulted the same values, and SystemSettings replayed them at
startup. Nothing kept them in sync, and the Lua side read no shared state at
all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl
keyword refuses the write, prints the refusal to stdout, and still exits 0, so
the HDR, VRR, and direct-scanout toggles persisted their value and reported
success while the compositor never changed. Writes now go through hyprctl eval,
which has the same hazard on syntax and runtime errors, so success is defined
as reading the value back and finding it equal. The existing contract passed
throughout the outage because it re-applied the values already in place; the
new one flips each value to something it does not hold.

Derive preferences from a schema. Every setting used to be restated four times
-- a property alias, a JSON adapter property, a change handler, and a line in
reset -- where omitting any one failed silently. PreferenceSchema.qml is now
the single source, and persistence, validation, reset, and the Hyprland mapping
all derive from it. Unknown keys on disk survive a write so a rollback does not
discard a newer build's settings, and a corrupt file falls back to shipped
defaults. The store moved to ~/.config/panama/settings.json, migrating from the
old state directory without deleting it.

Share that file with Hyprland. prefs.lua reads it at config time with every
shipped literal kept as the fallback, so the config still stands alone. The Lua
is the default, the JSON is the truth, and Settings is the editor. The
compositor-adjustable surface goes from 3 keys to 23.

Also fixes two test-hygiene bugs found by running the suite end to end for the
first time: settings-pages-contract could see the window settings-window-contract
leaves behind, and the new write contract was persisting its deliberately-wrong
values into the user's real store.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-17 23:26:56 -04:00
Gabriel Brown 4bd3638012 Cover partial Phone capabilities 2026-08-17 19:50:27 -04:00
Gabriel Brown 7f540a58e5 Avoid visual Phone harness teardown 2026-08-17 19:42:15 -04:00
Gabriel Brown 7ac5355a77 Record focused verification completion 2026-08-17 19:26:57 -04:00
Gabriel Brown a9eb90f457 Fix Home Assistant composition contract 2026-08-17 19:21:17 -04:00
Gabriel Brown b5c9156e64 Document Home accessory customization 2026-08-17 19:18:45 -04:00
Gabriel Brown 3213a0989a Fix Phone controls runtime accessibility 2026-08-17 19:01:42 -04:00
Gabriel Brown fbf729b25c Add BlueBubbles to Phone controls 2026-08-17 18:48:28 -04:00
Gabriel Brown e37df9d838 Fix Home shelf slider interactions 2026-08-17 18:34:48 -04:00
Gabriel Brown 673aac6aba Build the Home accessory shelf 2026-08-17 18:22:27 -04:00
Gabriel Brown c8eb344a0b Fix Home and Phone settings states 2026-08-17 18:02:09 -04:00
Gabriel Brown 65966200fe Add Home and Phone settings 2026-08-17 17:48:05 -04:00
Gabriel Brown 2130d6a1a5 Block Home actions during fixture transitions 2026-08-17 17:27:43 -04:00
Gabriel Brown 9a99c6064e Fix nested fixture transition draining 2026-08-17 17:16:36 -04:00
Gabriel Brown 4f93522f7d Fix fixture process transitions 2026-08-17 16:59:15 -04:00
Gabriel Brown a325ec807e Fix Home action completion and test isolation 2026-08-17 16:49:50 -04:00
Gabriel Brown c2b19f7545 Compose Home catalog with accessory preferences 2026-08-17 16:35:41 -04:00
Gabriel Brown 925fc0276f Persist Home accessory preferences 2026-08-17 16:24:09 -04:00
Gabriel Brown 65a8197419 Fix Home Assistant catalog QML compatibility 2026-08-17 16:06:05 -04:00
Gabriel Brown ff67387c42 Add Home Assistant light catalog and dimming 2026-08-17 16:01:50 -04:00
Gabriel Brown c42794c5e2 Ignore local feature worktrees 2026-08-17 15:53:11 -04:00
Gabriel Brown 0964af7b73 Plan Home accessory customization 2026-08-17 15:49:20 -04:00
Gabriel Brown 15fb008954 Design Home accessory customization 2026-08-17 14:36:30 -04:00
Gabriel Brown f4c3ffed43 Document the Panama Control Center 2026-08-17 13:47:50 -04:00
Gabriel Brown 5f965daa6a Sanitize Control Center test fixtures 2026-08-17 13:44:23 -04:00
Gabriel Brown 5940d6b4bd Build the Panama Control Center 2026-08-17 13:44:18 -04:00
Gabriel Brown 190e3200e0 Keep offline phone capabilities visible 2026-08-17 13:42:31 -04:00
Gabriel Brown 49ec4d8f6a Add Control Center integration services 2026-08-17 13:31:27 -04:00
Gabriel Brown 173c00fca7 Add the Panama Home Assistant bridge 2026-08-17 13:27:11 -04:00
Gabriel Brown 3a903934c0 Add the Panama KDE Connect bridge 2026-08-17 13:22:31 -04:00
Gabriel Brown dc6da5af34 Plan the Panama Control Center 2026-08-17 13:18:21 -04:00
Gabriel Brown 922395d2b4 Design the Panama Control Center 2026-08-17 13:01:42 -04:00
Gabriel Brown a5450fdb6d Add Ongoing to the Panama Daybook 2026-08-17 12:18:04 -04:00
Gabriel Brown 1ce5c732b7 Document the Panama calendar agenda 2026-08-17 11:49:41 -04:00
Gabriel Brown f9cc15fb96 Fix GNOME Calendar date handoff 2026-08-17 11:48:11 -04:00
Gabriel Brown 2728fb08fb Make shell restart tests reliable 2026-08-17 11:45:03 -04:00
Gabriel Brown f27a1dca11 Add the quiet calendar capsule 2026-08-17 11:41:58 -04:00
Gabriel Brown 03914e23af Build the Prism calendar Daybook 2026-08-17 11:38:24 -04:00
Gabriel Brown 6a1bec849d Route the interactive Panama date menu 2026-08-17 11:32:57 -04:00
Gabriel Brown fb27c5b309 Add live calendar state to Quickshell 2026-08-17 11:31:15 -04:00
Gabriel Brown d37d4a87de Add the EDS calendar bridge 2026-08-17 11:28:12 -04:00
Gabriel Brown 530a5590a6 Plan the Panama calendar agenda 2026-08-17 11:15:19 -04:00
Gabriel Brown e92c2ee5f9 Design the Panama calendar agenda 2026-08-17 11:04:05 -04:00
Gabriel Brown 5248883e4b Build the Panama Hyprland desktop 2026-08-17 10:32:55 -04:00
290 changed files with 38149 additions and 34 deletions
+7
View File
@@ -6,3 +6,10 @@
/config/wg/** /config/wg/**
# Ignore Neovim lazy-lock file # Ignore Neovim lazy-lock file
/config/dot/nvim/lazy-lock.json /config/dot/nvim/lazy-lock.json
# Local visual-companion sessions and generated design mocks.
/.superpowers/
# Local linked worktrees used for reviewed feature development.
/.worktrees/
# Python helper bytecode is local runtime state.
__pycache__/
*.py[cod]
+61 -1
View File
@@ -1,2 +1,62 @@
# Panama # Panama
## Formerly Sunhat, this is a personal config for Fedora, with the intention of helping a user set up their Fedora system with one command.
Formerly Sunhat. A personal config for Fedora, with the intention of helping a
user set up their Fedora system with one command.
```sh
git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama
~/.local/share/Panama/install
```
`install` runs everything in `setup/scripts/` in order:
| Script | Does |
|---|---|
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the four package lists in `setup/packages/` |
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>` |
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
Existing configs are moved to `config/old/` rather than overwritten.
## Desktops
Panama configures **two** desktops that coexist. Both sessions stay available in
GDM, so you can switch back and forth while you settle in.
### GNOME
The original setup: Forge for tiling, Dash-to-Dock, Openbar, Vitals,
AppIndicator support. Configured through `config/dot/forge/` and gsettings.
### Hyprland
A from-scratch replacement built to reproduce the GNOME setup closely enough
that muscle memory transfers — same keybinds, same panel contents, same dock,
same Tokyo Night Moon palette.
| Piece | What it is |
|---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
| `config/dot/xdg-desktop-portal/` | Portal backend routing |
**Start here: [`config/dot/hypr/README.md`](config/dot/hypr/README.md)** — it
covers the Lua migration, the uwsm environment gotcha, the HDR decision, the
full keymap, and troubleshooting.
Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland".
## Layout
```
bin/ Small user-facing commands on PATH
config/
bash/ .bashrc, aliases, env (env is gitignored)
copy/ Files copied verbatim over / (needs sudo)
dot/ Symlinked into ~/.config
old/ Backups of whatever was replaced (gitignored)
setup/
packages/ One package per line
scripts/ Run in order by ./install
```
+5
View File
@@ -24,3 +24,8 @@ else
fi fi
export PATH export PATH
fi 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", "wmClass": "Spotify",
"mode": "float" "mode": "float"
},
{
"wmClass": "bitwarden",
"mode": "float"
} }
] ]
} }
@@ -107,7 +107,7 @@
} }
.window-floated-border { .window-floated-border {
border-width: 1px; border-width: 3px;
border-color: rgb(98,160,234); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
@@ -29,41 +29,47 @@
} }
.window-tiled-border { .window-tiled-border {
border-width: 4px; border-width: 3px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
.window-split-border { .window-split-border {
border-width: 4px; border-width: 3px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; 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 { .window-split-horizontal {
border-left-width: 0; border-left-width: 0;
border-top-width: 0; border-top-width: 0;
border-bottom-width: 0; border-bottom-width: 0;
border-radius: 0;
} }
.window-split-vertical { .window-split-vertical {
border-left-width: 0; border-left-width: 0;
border-top-width: 0; border-top-width: 0;
border-right-width: 0; border-right-width: 0;
border-radius: 0;
} }
.window-stacked-border { .window-stacked-border {
border-width: 4px; border-width: 3px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
.window-tabbed-border { .window-tabbed-border {
border-width: 4px; border-width: 3px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
@@ -74,7 +80,7 @@
.window-tabbed-tab { .window-tabbed-tab {
background-color: rgba(54, 47, 45, 1); 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-width: 1px;
border-radius: 8px; border-radius: 8px;
color: white; color: white;
@@ -83,7 +89,7 @@
} }
.window-tabbed-tab-active { .window-tabbed-tab-active {
background-color: rgb(130,170,255); background-color: rgb(98,160,234);
color: black; color: black;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
} }
@@ -101,32 +107,84 @@
} }
.window-floated-border { .window-floated-border {
border-width: 4px; border-width: 1px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
.window-tilepreview-tiled { .window-tilepreview-tiled {
border-width: 1px; border-width: 1px;
border-color: rgba(130,170,255,0.3); border-color: rgba(98,160,234,0.3);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
background-color: rgba(130,170,255,0.2); background-color: rgba(98,160,234,0.2);
} }
.window-tilepreview-stacked { .window-tilepreview-stacked {
border-width: 1px; border-width: 1px;
border-color: rgba(130,170,255,0.3); border-color: rgba(98,160,234,0.3);
border-style: solid; border-style: solid;
border-radius: 14px; 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 { .window-tilepreview-tabbed {
border-width: 1px; border-width: 1px;
border-color: rgba(130,170,255,0.3); border-color: rgba(98,160,234,0.3);
border-style: solid; border-style: solid;
border-radius: 14px; 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/Documents Documents
file:///home/gib/Downloads file:///home/gib/Documents/Code Code
file:///home/gib/Music file:///home/gib/Downloads Downloads
file:///home/gib/Pictures file:///home/gib/Music Music
file:///home/gib/Videos file:///home/gib/Pictures Pictures
file:///home/gib/Documents/Code file:///home/gib/Videos Videos
file:///home/gib/.local/share/Panama Panama 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] [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-icon-theme-name=Adwaita
gtk-font-name=Cantarell 11 gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=WhiteSur-cursors gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24 gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=1
gtk-toolbar-style=GTK_TOOLBAR_ICONS gtk-toolbar-style=GTK_TOOLBAR_ICONS
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
gtk-button-images=0 gtk-button-images=0
@@ -14,4 +23,3 @@ gtk-xft-antialias=1
gtk-xft-hinting=1 gtk-xft-hinting=1
gtk-xft-hintstyle=hintslight gtk-xft-hintstyle=hintslight
gtk-xft-rgba=rgb gtk-xft-rgba=rgb
gtk-application-prefer-dark-theme=0
+10 -1
View File
@@ -1,2 +1,11 @@
[Settings] [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
+89
View File
@@ -0,0 +1,89 @@
# 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, quiet event capsule, 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 | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | 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 |
| Printer administration | CUPS with the `system-config-printer` graphical interface | 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 | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
| 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. A real external RustDesk test while hyprlock is active. Local process state
cannot prove what a remote client sees or whether input is accepted.
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
tracked in `setup/packages/hyprland-packages`.
## 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.
+409
View File
@@ -0,0 +1,409 @@
# 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 |
| `prefs.lua` | Reads the settings file Panama Settings writes. See below |
| `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
```
## Generated configuration
Two ecosystem tools cannot read the shared settings file, and their configs live
in this directory — which is a symlink into the Panama repository, so writing to
them at runtime would put machine state into a tracked file. Both are therefore
generated elsewhere:
| Tool | Generated to | Pointed there by |
|---|---|---|
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
```sh
~/.config/quickshell/scripts/panama-idle remove
```
## Settings: one file, both sides
`~/.config/panama/settings.json` is shared with the Quickshell side. The
relationship is:
- **This config is the default.** Every adjustable value is written
`prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it.
- **Panama Settings is the editor.** It writes the file *and* applies the change
live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to
`quickshell/config/PreferenceSchema.qml` with a `hypr` block naming the
`hl.config` path, then read it here with `prefs.get`. Nothing else is needed —
persistence, validation, reset, and the live write are all derived from that
entry.
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
wrong-typed settings file costs you your customisations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states.
### Adding a keybind: use `bind`, not `hl.bind`
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
Only the **chord** is ever taken from settings — the action is always the Lua
value written here. A stored override can therefore move a shortcut but can
never make one do something else, which is what makes reading overrides from a
file the user can edit safe.
Overrides are keyed by the **shipped chord**, not the description. Descriptions
are not unique: "Calculator" is both `SUPER + C` and the `XF86Calculator`
hardware key, and keying by description moved both onto the same new chord,
silently costing the hardware key.
An override whose value is not a plausible chord is ignored in favour of the
shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
### Keybind descriptions are required
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the
Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently.
```lua
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
The page groups shortcuts by what the description says they do, so a new bind
lands in the right section with no change to the UI.
### Never use `hyprctl keyword`
On a Lua-configured Hyprland it refuses the write, prints
`keyword can't work with non-legacy parsers` to **stdout**, and still **exits 0**:
```sh
$ hyprctl getoption decoration:rounding -j # → "int": 18
$ hyprctl keyword decoration:rounding 4 # → the refusal above
$ echo $? # → 0
$ hyprctl getoption decoration:rounding -j # → "int": 18, unchanged
```
Use `hyprctl eval 'hl.config({ ... })'` instead. Note that `eval` *also* exits 0
on syntax and runtime errors, reporting them as an `error:` line on stdout — so
for either command, the only trustworthy signal that a write landed is reading
the value back with `hyprctl getoption`.
## 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.
## Calendar and notifications
Click the clock to open the two-column Daybook on **Agenda**. The month grid,
today's schedule, Up Next, weather, and conditional media controls stay visible
together. Its right pane has three first-class pages:
- **Agenda** — the selected day's calendar.
- **Ongoing** — Focus, Caffeine, recording, screen sharing, camera, and
microphone activity, visible until each state ends.
- **Notifications** — grouped history, Do Not Disturb, and clear actions.
`SUPER + B` opens Notifications directly. Switching pages preserves the
selected date and visible month. Brief completed events such as screenshots and
device changes remain in Signal Glass instead of accumulating in Ongoing.
Calendar data comes from Evolution Data Server, so the Google, iCloud,
Nextcloud, and local calendars already configured for GNOME remain the source
of truth. Panama never stores account credentials. Use GNOME Calendar or Online
Accounts to add and manage accounts, and click an event in Daybook to hand off
editing and full detail to GNOME Calendar.
The right side of the bar shows a small calendar capsule only from 15 minutes
before a timed event until 5 minutes after it starts. It displays relative time
(`12m` or `Now`); the event title is available on hover but otherwise stays out
of the bar. All-day events never trigger the capsule.
## Control Center
Click the right-side status cluster or press `SUPER + S` for Panama's Control
Center. It is attached directly beneath the bar and keeps Wi-Fi, Bluetooth,
Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
one place. Home and Phone continue the same surface rather than opening extra
dashboard windows.
Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous
level.
Credentials stay private in the gitignored `config/bash/env` file, with the
existing GNOME extension and Secret Service setup retained as a compatibility
fallback. Favourites, aliases, and order live in Quickshell state. No shell
restart is required after changing credentials; close and reopen Control Center
to refresh. If Home Assistant is offline, the last known values stay visible
with a stale-state label and Retry action.
Phone uses KDE Connect for the capabilities the paired iPhone actually
advertises: Send File, Send Clipboard, and Ring. The device remains visible
while iOS suspends KDE Connect, but those actions stay disabled until it
reconnects. Messages opens BlueBubbles independently of KDE Connect. No battery
percentage is invented when iOS reports none. Active file sends appear in
Daybook's Ongoing page; successful sends become a quiet recent exchange.
## 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'.
+31
View File
@@ -0,0 +1,31 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- 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
-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences first: looks/input/monitors read their defaults through it.
-- It never raises, so a missing or malformed settings file costs customisations
-- and nothing else.
require("prefs")
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
+42
View File
@@ -0,0 +1,42 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Input
--
-- Deliberately GNOME-like: click-to-focus, no focus-follows-mouse, no mouse
-- acceleration changes. The goal is that muscle memory transfers untouched.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
hl.config({
input = {
kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "",
kb_model = "",
kb_options = "",
kb_rules = "",
numlock_by_default = prefs.get("numlockByDefault", true),
-- GNOME's defaults are 500ms delay / 33Hz repeat.
repeat_delay = prefs.get("keyRepeatDelay", 500),
repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
follow_mouse = prefs.getInt("followMouse", 1),
-- Don't refocus on mouse move alone -- only on click.
mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming.
sensitivity = prefs.get("pointerSensitivity", 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
+281
View File
@@ -0,0 +1,281 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- 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 prefs = require("prefs")
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 ────────────────────────────────────────────────────────────
-- ── User rebinding ──────────────────────────────────────────────────────────
-- Every bind below goes through `bind` rather than `hl.bind` directly, so a
-- chord can be replaced from Panama Settings without this file changing.
--
-- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken
-- from settings -- the action is always the Lua value written here. A stored
-- override can therefore move a shortcut but can never make one do something
-- else, which is the property that makes reading them from a JSON file the
-- user can edit safe.
--
-- Keyed by chord rather than by description because chords are unique and
-- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator
-- hardware key, and keying by description moved both of them onto the same new
-- chord, silently costing the hardware key.
--
-- An override whose chord is not a plausible chord is ignored and the shipped
-- one is used, so a hand-edited settings file cannot cost you a keymap.
local overrides = prefs.get("keybindOverrides", {})
local function valid_chord(chord)
if type(chord) ~= "string" or chord == "" or #chord > 64 then
return false
end
-- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272"
return chord:match("^[%w_+%s:]+$") ~= nil
end
local function bind(chord, action, opts)
local override = overrides[chord]
if valid_chord(override) then
chord = override
end
return hl.bind(chord, action, opts)
end
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
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.
bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
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.
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.
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
{ description = "Clipboard history" })
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.
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.
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.
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
-- Notification centre.
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.
bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
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.
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.
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
-- ── Window management ───────────────────────────────────────────────────────
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
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.
bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
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.
bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
-- Forge: window-shrink / window-expand / window-reset-sizes.
bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
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.
bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
-- Move (Forge: window-move-*).
bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
-- Swap (Forge: window-swap-*).
bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
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
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
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).
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
-- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
-- Mouse: drag to move, right-drag to resize.
bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
-- ── 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.
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
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.
bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
-- Direct jump. ALT+0 is workspace 10.
for i = 1, 10 do
local key = i % 10
bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
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.)
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- 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.
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
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.
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
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.
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" })
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" })
bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" })
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" })
-- Hardware keys GNOME mapped that have obvious equivalents.
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
return true
+220
View File
@@ -0,0 +1,220 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- 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.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
hl.config({
general = {
gaps_in = prefs.get("gapsIn", 5),
gaps_out = prefs.get("gapsOut", 10),
border_size = prefs.get("borderSize", 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 = prefs.get("windowRounding", 18),
rounding_power = 2,
active_opacity = 1.0,
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = {
enabled = prefs.get("blurEnabled", true),
size = prefs.get("blurSize", 8),
passes = prefs.get("blurPasses", 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 = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 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 = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 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 = prefs.get("animationsEnabled", 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 = prefs.getInt("vrrPolicy", 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 = prefs.getInt("autoHdr", 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 = prefs.getInt("directScanoutPolicy", 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 = prefs.get("cursorInactiveTimeout", 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
+252
View File
@@ -0,0 +1,252 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences
--
-- Reads the same file Panama Settings writes:
-- $XDG_CONFIG_HOME/panama/settings.json (default ~/.config/panama/settings.json)
--
-- This is what makes the desktop one product rather than two that happen to
-- agree. The relationship is:
--
-- * the Lua config is the DEFAULT -- every prefs.get() call passes the shipped
-- value as its fallback, so this config still works standalone with no JSON
-- file at all;
-- * the JSON file is the TRUTH -- both Hyprland and Quickshell read it;
-- * Panama Settings is the EDITOR -- it writes the file and applies the change
-- live through `hyprctl eval`, so nothing needs a reload and the two sides
-- cannot drift apart.
--
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
-- file must cost the user nothing worse than their customisations; it must
-- never cost them a working compositor. Every failure path returns the caller's
-- fallback.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = {}
-- ── A small JSON reader ─────────────────────────────────────────────────────
-- Hyprland's Lua has no JSON support and pulling in a rock for a flat object of
-- scalars is not worth the dependency. Handles the whole format apart from
-- non-ASCII \u escapes, which are replaced rather than decoded -- no setting is
-- a non-ASCII string, and mangling one is preferable to failing the parse.
local function decode(text)
local pos = 1
local function skipSpace()
pos = text:find("[^ \t\r\n]", pos) or #text + 1
end
local parseValue
local function parseString()
pos = pos + 1 -- opening quote
local parts = {}
while true do
local char = text:sub(pos, pos)
if char == "" then
error("unterminated string")
elseif char == '"' then
pos = pos + 1
break
elseif char == "\\" then
local escape = text:sub(pos + 1, pos + 1)
local simple = {
n = "\n", t = "\t", r = "\r", b = "\b", f = "\f",
['"'] = '"', ["\\"] = "\\", ["/"] = "/",
}
if simple[escape] then
parts[#parts + 1] = simple[escape]
pos = pos + 2
elseif escape == "u" then
local code = tonumber(text:sub(pos + 2, pos + 5), 16)
parts[#parts + 1] = (code and code < 128) and string.char(code) or "?"
pos = pos + 6
else
error("invalid escape")
end
else
parts[#parts + 1] = char
pos = pos + 1
end
end
return table.concat(parts)
end
local function parseNumber()
local literal = text:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos)
if not literal or literal == "" then
error("invalid number")
end
pos = pos + #literal
local value = tonumber(literal)
if not value then
error("invalid number")
end
return value
end
local function parseObject()
pos = pos + 1 -- opening brace
local out = {}
skipSpace()
if text:sub(pos, pos) == "}" then
pos = pos + 1
return out
end
while true do
skipSpace()
if text:sub(pos, pos) ~= '"' then
error("expected key")
end
local key = parseString()
skipSpace()
if text:sub(pos, pos) ~= ":" then
error("expected colon")
end
pos = pos + 1
out[key] = parseValue()
skipSpace()
local char = text:sub(pos, pos)
pos = pos + 1
if char == "}" then
return out
elseif char ~= "," then
error("expected comma or closing brace")
end
end
end
local function parseArray()
pos = pos + 1 -- opening bracket
local out = {}
skipSpace()
if text:sub(pos, pos) == "]" then
pos = pos + 1
return out
end
while true do
out[#out + 1] = parseValue()
skipSpace()
local char = text:sub(pos, pos)
pos = pos + 1
if char == "]" then
return out
elseif char ~= "," then
error("expected comma or closing bracket")
end
end
end
parseValue = function()
skipSpace()
local char = text:sub(pos, pos)
if char == "{" then
return parseObject()
elseif char == "[" then
return parseArray()
elseif char == '"' then
return parseString()
elseif text:sub(pos, pos + 3) == "true" then
pos = pos + 4
return true
elseif text:sub(pos, pos + 4) == "false" then
pos = pos + 5
return false
elseif text:sub(pos, pos + 3) == "null" then
pos = pos + 4
return nil
elseif char == "" then
error("unexpected end of input")
else
return parseNumber()
end
end
local value = parseValue()
if type(value) ~= "table" then
error("top level value is not an object")
end
return value
end
-- ── Loading ─────────────────────────────────────────────────────────────────
local function settingsPath()
local configHome = os.getenv("XDG_CONFIG_HOME")
if configHome == nil or configHome == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
configHome = home .. "/.config"
end
return configHome .. "/panama/settings.json"
end
local function read()
local path = settingsPath()
if not path then
return {}
end
local file = io.open(path, "r")
if not file then
return {} -- no file yet is the normal first-run case, not an error
end
local text = file:read("*a")
file:close()
if not text or text:match("^%s*$") then
return {}
end
local ok, parsed = pcall(decode, text)
if ok and type(parsed) == "table" then
return parsed
end
return {}
end
-- Loaded once at config time. A pcall around the whole thing so that even an
-- unanticipated failure in the reader degrades to shipped defaults.
local values = {}
do
local ok, parsed = pcall(read)
if ok and type(parsed) == "table" then
values = parsed
end
end
-- ── Public interface ────────────────────────────────────────────────────────
-- Returns the stored value for `key`, or `fallback` when it is absent or is not
-- the same type as the fallback. The type guard matters: a stale or hand-edited
-- file that puts a string where Hyprland needs a number would otherwise abort
-- the config, taking down far more than the one setting that was wrong.
function prefs.get(key, fallback)
local value = values[key]
if value == nil then
return fallback
end
if type(value) ~= type(fallback) then
return fallback
end
return value
end
-- Hyprland has no boolean-to-integer coercion for options that take 0/1, and
-- several of them read more naturally as a toggle in the settings UI.
function prefs.getInt(key, fallback)
local value = values[key]
if type(value) == "boolean" then
return value and 1 or 0
end
if type(value) ~= "number" then
return fallback
end
return math.floor(value + 0.5)
end
-- True when a settings file was actually read. Useful from overrides.lua.
function prefs.loaded()
return next(values) ~= nil
end
return prefs
+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,158 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
//
// This object owns reading, validating, and writing. It knows nothing about
// which settings exist -- that is the schema's job -- so adding a setting never
// requires touching this file. That is the point: the previous implementation
// restated every key four times (a property alias, a JSON adapter property, a
// change handler, and a line in reset), and each omission failed silently.
//
// Reads go through get(), writes through set(). Typed accessors for the values
// the shell reads on every frame live in Settings.qml, which remains the stable
// public surface.
//
// The store lives at ~/.config/panama/settings.json rather than inside
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
// able to back it up, diff it, or keep it in a dotfiles repo.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
// Bumped on every accepted change. get() reads it so bindings built on get()
// have something to invalidate; a bare function call would otherwise capture
// no dependency and every reader would silently go stale.
property int revision: 0
// Everything currently on disk, including keys this build does not know
// about. Unknown keys are carried through untouched so that rolling back to
// an older Panama does not discard a newer version's settings.
property var values: ({})
property bool loaded: false
function get(key: string): var {
root.revision;
const stored = root.values[key];
if (stored === undefined)
return PreferenceSchema.defaultFor(key);
const coerced = PreferenceSchema.coerce(key, stored);
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
}
// Returns false when the key is unknown or the value cannot be represented,
// so a caller can surface the rejection instead of assuming it took.
function set(key: string, value: var): bool {
if (!PreferenceSchema.has(key))
return false;
const coerced = PreferenceSchema.coerce(key, value);
if (coerced === undefined)
return false;
if (root.values[key] === coerced)
return true;
// Reassign rather than mutate: QML does not notify on in-place changes
// to a var property's contents.
const next = Object.assign({}, root.values);
next[key] = coerced;
root.values = next;
root.revision++;
persistTimer.restart();
return true;
}
// Restores every schema default in one write. Complete by construction --
// there is no hand-maintained list to fall out of sync with the schema.
function resetDesktopDefaults(): void {
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
root.values = next;
root.revision++;
persistTimer.restart();
}
// Re-read the file from disk. Used after something outside the shell has
// rewritten it -- restoring a snapshot, or a hand edit -- so the running
// desktop reflects the new contents without waiting for the next change.
function reload(): void {
preferencesFile.reload();
root.load();
}
function load(): void {
let parsed = {};
try {
const text = preferencesFile.text();
if (text && text.trim().length > 0)
parsed = JSON.parse(text);
} catch (error) {
// A corrupt file must not cost the user a working desktop. Fall
// back to shipped defaults and let the next write replace it.
parsed = {};
}
root.values = (parsed && typeof parsed === "object") ? parsed : {};
root.revision++;
root.loaded = true;
}
FileView {
id: preferencesFile
path: root.path
blockLoading: true
printErrors: false
atomicWrites: true
onLoaded: root.load()
// No file yet is the normal first-run case, not an error.
onLoadFailed: root.load()
}
Component.onCompleted: {
migration.adopt();
root.load();
}
// Coalesce a burst of changes into one atomic write. Writing on every
// assignment races a second assignment and can reload the older value.
Timer {
id: persistTimer
interval: 0
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
}
// One-time move from the pre-Stage-1 location inside Quickshell's state
// directory. Reads the old file only when the new one does not exist yet, so
// it can never overwrite newer settings, and never deletes the original.
QtObject {
id: migration
function adopt(): void {
if (preferencesFile.text())
return;
try {
const legacy = legacyFile.text();
if (legacy && legacy.trim().length > 0)
preferencesFile.setText(legacy);
} catch (error) {
// Nothing to migrate.
}
}
}
FileView {
id: legacyFile
path: Quickshell.stateDir + "/panama-settings.json"
blockLoading: true
printErrors: false
}
}
@@ -0,0 +1,176 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property alias initialized: values.initialized
property alias favorites: values.favorites
property string saveError: ""
FileView {
id: preferencesFile
path: Quickshell.stateDir + "/panama-home.json"
blockLoading: true
printErrors: false
atomicWrites: true
onSaved: root.saveError = ""
onSaveFailed: error => root.saveError = "Could not save Home favourites."
JsonAdapter {
id: values
property bool initialized: false
property var favorites: []
}
}
Connections {
target: values
function onInitializedChanged(): void { persistTimer.restart(); }
function onFavoritesChanged(): void { persistTimer.restart(); }
}
Component.onCompleted: preferencesFile.reload()
Timer {
id: persistTimer
interval: 180
repeat: false
onTriggered: preferencesFile.writeAdapter()
}
function cloneFavorites(): var {
var clone = [];
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
clone.push({
id: favorite.id,
alias: favorite.alias
});
}
return clone;
}
function isValidEntityId(entityId: var): bool {
return typeof entityId === "string" && /^light\.[a-z0-9_]+$/.test(entityId);
}
function initialize(legacyIds: var): void {
if (values.initialized) {
return;
}
var seededFavorites = [];
var seen = {};
if (legacyIds && typeof legacyIds.length === "number") {
for (var index = 0; index < legacyIds.length; index++) {
var entityId = legacyIds[index];
if (!isValidEntityId(entityId) || seen[entityId]) {
continue;
}
seen[entityId] = true;
seededFavorites.push({ id: entityId, alias: "" });
}
}
values.favorites = seededFavorites;
values.initialized = true;
}
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) {
return true;
}
}
return false;
}
function aliasFor(entityId: string, sourceName: string): string {
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId && favorite.alias !== "") {
return favorite.alias;
}
}
return sourceName;
}
function add(entityId: string): void {
if (!isValidEntityId(entityId) || isSelected(entityId)) {
return;
}
var nextFavorites = cloneFavorites();
nextFavorites.push({ id: entityId, alias: "" });
values.favorites = nextFavorites;
}
function remove(entityId: string): void {
var nextFavorites = [];
var removed = false;
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId) {
removed = true;
continue;
}
nextFavorites.push({ id: favorite.id, alias: favorite.alias });
}
if (removed) {
values.favorites = nextFavorites;
}
}
function setAlias(entityId: string, alias: string): void {
var nextFavorites = cloneFavorites();
var updated = false;
var trimmedAlias = String(alias).trim();
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
nextFavorites[index] = { id: entityId, alias: trimmedAlias };
updated = true;
break;
}
}
if (updated) {
values.favorites = nextFavorites;
}
}
function move(entityId: string, targetIndex: int): void {
var nextFavorites = cloneFavorites();
var currentIndex = -1;
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
currentIndex = index;
break;
}
}
if (currentIndex === -1) {
return;
}
var favorite = nextFavorites.splice(currentIndex, 1)[0];
var clampedIndex = Math.max(0, Math.min(targetIndex, nextFavorites.length));
nextFavorites.splice(clampedIndex, 0, favorite);
values.favorites = nextFavorites;
}
function retrySave(): void {
preferencesFile.writeAdapter();
}
}
@@ -0,0 +1,608 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// The schema is the single source of truth for every user-changeable setting.
//
// One entry describes a setting completely: its name, type, default, valid
// range, which group it belongs to, and how to label it. Persistence,
// validation, reset, and (from Stage 3) the settings UI itself are all derived
// from these entries rather than restated.
//
// Adding a setting means adding one entry here. It does not mean editing a
// property alias, a JSON adapter, a change handler, and a reset function --
// which is what it used to mean, and why the settings app stalled at sixteen
// knobs while forty comparable values stayed hardcoded one file away.
//
// Entry fields
// key unique identifier; also the JSON key on disk
// type "bool" | "int" | "real" | "string" | "enum" | "json"
// def shipped default, used when the file is absent or a value is invalid
// min/max inclusive bounds for int and real; values outside are clamped
// step UI increment for int and real
// options for "enum": [{ value, label }], the only accepted values
// group grouping id, used to build settings pages
// label short UI name
// detail one line explaining what changing it does
// internal true for state the shell keeps but the user never edits directly
// pattern for "string": a regular expression the value must match in full
//
// "json" holds a structured value -- a list or an object -- that the schema
// stores and resets but does not validate field by field. It exists so that
// settings like the dock's pinned applications live in the same file, and are
// covered by the same reset, as everything else rather than growing a fourth
// preference store. The service that owns such a value is responsible for
// validating it; see services/Dock-related consumers.
// hypr present when the setting maps onto an Hyprland option:
// path the hl.config table path, e.g. ["decoration","blur","size"]
// option the getoption path used to read the value back
// readAs which field getoption returns it in -- "int", "bool",
// "float", "str", or "css" (gaps, returned as a box)
//
// Anything with a `hypr` block is applied live by services/SystemSettings.qml
// and read at startup by config/dot/hypr/prefs.lua, using the same key. The Lua
// keeps the shipped value as its fallback, so the config still works with no
// settings file at all.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import QtQuick
Singleton {
id: root
readonly property var entries: [
// ── Clock ───────────────────────────────────────────────────────────
{
key: "use24Hour", type: "bool", def: false, group: "clock",
label: "24-hour time",
detail: "Use 18:30 instead of 6:30 PM"
},
{
key: "showSeconds", type: "bool", def: true, group: "clock",
label: "Show seconds",
detail: "Keep a precise clock in the center of the bar"
},
{
key: "showWeekday", type: "bool", def: true, group: "clock",
label: "Show weekday",
detail: "Include the abbreviated weekday before the date"
},
// ── Vitals ──────────────────────────────────────────────────────────
{
key: "showCpu", type: "bool", def: true, group: "vitals",
label: "Processor",
detail: "Show processor usage beside the workspace indicator"
},
{
key: "showMemory", type: "bool", def: true, group: "vitals",
label: "Memory",
detail: "Show memory usage beside the workspace indicator"
},
{
key: "showGpu", type: "bool", def: true, group: "vitals",
label: "Graphics",
detail: "Show graphics usage beside the workspace indicator"
},
// ── Dock ────────────────────────────────────────────────────────────
{
key: "dockAutohide", type: "bool", def: true, group: "dock",
label: "Automatically hide the Dock",
detail: "Reveal it at the bottom edge when a workspace is occupied"
},
{
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
unit: "ms",
group: "dock",
label: "Reveal delay",
detail: "Zero reveals the Dock the instant the pointer reaches the edge"
},
{
key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
unit: "ms",
group: "dock",
label: "Hide delay",
detail: "Prevents flicker when crossing between icons"
},
// ── Focus ───────────────────────────────────────────────────────────
{
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min",
group: "focus",
label: "Focus session length",
detail: "How long a focus session runs before it ends itself"
},
// ── Display policy ──────────────────────────────────────────────────
// These three are written to the compositor and verified by read-back.
// See services/SystemSettings.qml for why the exit code cannot be
// trusted for either hyprctl keyword or hyprctl eval.
{
key: "autoHdr", type: "bool", def: true, group: "display",
label: "Game-aware HDR",
detail: "Hand HDR to fullscreen games while the desktop stays SDR",
hypr: { path: ["render", "cm_auto_hdr"], option: "render:cm_auto_hdr", readAs: "int" }
},
{
key: "vrrPolicy", type: "enum", def: 3, group: "display",
label: "Variable refresh rate",
detail: "Content-aware matches the display to what is on screen",
options: [
{ value: 0, label: "Off" },
{ value: 3, label: "Content-aware" }
],
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
},
{
key: "directScanoutPolicy", type: "enum", def: 2, group: "display",
label: "Direct scanout",
detail: "Lets fullscreen content bypass compositing",
options: [
{ value: 0, label: "Off" },
{ value: 2, label: "Automatic" }
],
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
},
// ── Window appearance ───────────────────────────────────────────────
// These adjust the parameters of the Prism identity -- how much space,
// how soft, how much motion -- rather than replacing it. Shipped values
// are duplicated as the fallbacks in config/dot/hypr/looks.lua so that
// the Hyprland config still stands on its own.
{
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
unit: "px",
group: "windows",
label: "Inner gaps",
detail: "Space between neighbouring tiled windows",
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
},
{
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
unit: "px",
group: "windows",
label: "Outer gaps",
detail: "Space between the tiled area and the screen edge",
hypr: { path: ["general", "gaps_out"], option: "general:gaps_out", readAs: "css" }
},
{
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
unit: "px",
group: "windows",
label: "Border width",
detail: "Thickness of the gradient border on the focused window",
hypr: { path: ["general", "border_size"], option: "general:border_size", readAs: "int" }
},
{
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
unit: "px",
group: "windows",
label: "Corner radius",
detail: "Matches the shell's popover radius so windows and panels agree",
hypr: { path: ["decoration", "rounding"], option: "decoration:rounding", readAs: "int" }
},
{
key: "inactiveOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Unfocused window opacity",
detail: "Fade windows that do not have focus",
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
},
// ── Effects ─────────────────────────────────────────────────────────
{
key: "blurEnabled", type: "bool", def: true, group: "effects",
label: "Blur",
detail: "Blur the desktop behind translucent surfaces",
hypr: { path: ["decoration", "blur", "enabled"], option: "decoration:blur:enabled", readAs: "bool" }
},
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
group: "effects",
label: "Blur radius",
detail: "Larger is softer and costs more frame time",
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
},
{
key: "blurPasses", type: "int", def: 3, min: 1, max: 5, step: 1,
group: "effects",
label: "Blur passes",
detail: "More passes look smoother and cost more frame time",
hypr: { path: ["decoration", "blur", "passes"], option: "decoration:blur:passes", readAs: "int" }
},
{
key: "shadowEnabled", type: "bool", def: true, group: "effects",
label: "Window shadows",
detail: "Lift windows off the wallpaper with a soft shadow",
hypr: { path: ["decoration", "shadow", "enabled"], option: "decoration:shadow:enabled", readAs: "bool" }
},
{
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
unit: "px",
group: "effects",
label: "Shadow size",
detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
},
{
key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow",
detail: "A faint halo behind the focused window",
hypr: { path: ["decoration", "glow", "enabled"], option: "decoration:glow:enabled", readAs: "bool" }
},
{
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
unit: "px",
group: "effects",
label: "Glow size",
detail: "Kept small deliberately: the gradient border is the signature",
hypr: { path: ["decoration", "glow", "range"], option: "decoration:glow:range", readAs: "int" }
},
{
key: "animationsEnabled", type: "bool", def: true, group: "effects",
label: "Animations",
detail: "Window, workspace, and panel motion",
hypr: { path: ["animations", "enabled"], option: "animations:enabled", readAs: "bool" }
},
// ── Input ───────────────────────────────────────────────────────────
{
key: "keyboardLayout", type: "string", def: "us", group: "input",
// Reaches an hl.config string, so it is constrained to the shape of
// an XKB layout list and nothing else.
pattern: "^[a-z]{2,8}(,[a-z]{2,8})*$",
label: "Keyboard layout",
detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
},
{
key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login",
detail: "Turn Num Lock on when the session starts",
hypr: { path: ["input", "numlock_by_default"], option: "input:numlock_by_default", readAs: "bool" }
},
{
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
unit: "ms",
group: "input",
label: "Repeat delay",
detail: "How long a key is held before it starts repeating",
hypr: { path: ["input", "repeat_delay"], option: "input:repeat_delay", readAs: "int" }
},
{
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
unit: "/s",
group: "input",
label: "Repeat rate",
detail: "How many characters a second a held key produces",
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
},
{
key: "followMouse", type: "enum", def: 1, group: "input",
label: "Focus follows pointer",
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
options: [
{ value: 0, label: "Never" },
{ value: 1, label: "Click to focus" },
{ value: 2, label: "Sloppy focus" }
],
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
},
{
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input",
label: "Pointer speed",
detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
},
{
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s",
group: "input",
label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
// Reported as a float even though it is only ever set to whole
// seconds. `readAs` describes what getoption answers with, not what
// the setting means -- getting this wrong makes every write to it
// look rejected.
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
},
// ── Night light ─────────────────────────────────────────────────────
{
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
label: "Night Light",
detail: "Shift the display warmer to reduce blue light"
},
{
key: "nightLightAutomatic", type: "bool", def: false, group: "nightLight",
label: "Schedule automatically",
detail: "Turn Night Light on and off at the scheduled hours"
},
{
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
unit: "K",
group: "nightLight",
label: "Color temperature",
detail: "Lower is warmer"
},
// ── Desktop background ──────────────────────────────────────────────
// Applied through hyprpaper's IPC. Not an hl.config option, so it has
// no `hypr` block; services/Wallpaper.qml owns applying it.
{
key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
// Reaches hyprpaper as the "<output>,<path>" argument form, so a
// comma would split it into a different request. Absolute paths
// only, no commas, no newlines.
pattern: "^(|/[^,\\n]+)$",
label: "Wallpaper",
detail: "Shown on every output"
},
// ── Idle, lock, and sleep ───────────────────────────────────────────
// Written into a generated hypridle config; see scripts/panama-idle.
// Zero means never for all three.
{
key: "screenBlankMinutes", type: "int", def: 5, min: 0, max: 120, step: 1,
unit: "min", group: "idle",
label: "Turn the screen off after",
detail: "Blanks the display; nothing is locked yet"
},
{
key: "lockMinutes", type: "int", def: 10, min: 0, max: 240, step: 1,
unit: "min", group: "idle",
label: "Lock the screen after",
detail: "Counted from when the session went idle, not from blanking"
},
{
key: "suspendMinutes", type: "int", def: 0, min: 0, max: 480, step: 5,
unit: "min", group: "idle",
label: "Suspend after",
detail: "This is a desktop, so Panama ships with automatic suspend off"
},
{
key: "lockOnSleep", type: "bool", def: true, group: "idle",
label: "Lock before sleeping",
detail: "Requires your password when the machine wakes"
},
// ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and
// pushed to the compositor as well where it has its own notion.
{
key: "cursorSize", type: "int", def: 24, min: 16, max: 64, step: 4,
unit: "px", group: "accessibility",
label: "Pointer size",
detail: "Applies to the compositor and to applications"
},
{
key: "textScale", type: "real", def: 1.0, min: 0.75, max: 2.0, step: 0.05,
group: "accessibility",
label: "Text size",
detail: "Scales interface text everywhere; 1.00 is the design size"
},
// ── Weather ─────────────────────────────────────────────────────────
{
key: "temperatureUnit", type: "enum", def: "fahrenheit", group: "weather",
label: "Temperature unit",
detail: "Choose Fahrenheit or Celsius for the weather card",
options: [
{ value: "fahrenheit", label: "Fahrenheit" },
{ value: "celsius", label: "Celsius" }
]
},
{
key: "weatherRefreshMinutes", type: "int", def: 20, min: 5, max: 120, step: 5,
unit: "min", group: "weather",
label: "Weather refresh",
detail: "How often Panama updates the current conditions"
},
// ── Vitals refresh ──────────────────────────────────────────────────
{
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
unit: "ms", group: "vitals",
label: "Vitals refresh",
detail: "How often processor, memory, and graphics usage update"
},
// ── Notifications ───────────────────────────────────────────────────
{
key: "notificationTimeoutMs", type: "int", def: 5000, min: 1000, max: 30000, step: 500,
unit: "ms", group: "notifications",
label: "Notification duration",
detail: "How long ordinary notification banners remain visible"
},
{
key: "notificationTimeoutCriticalMs", type: "int", def: 0, min: 0, max: 60000, step: 1000,
unit: "ms", group: "notifications",
label: "Critical notification duration",
detail: "Zero keeps critical notification banners visible until dismissed"
},
{
key: "notificationHistoryLimit", type: "int", def: 100, min: 10, max: 500, step: 10,
group: "notifications",
label: "Notification history",
detail: "Maximum notifications retained in the notification center"
},
{
key: "maxVisibleToasts", type: "int", def: 4, min: 1, max: 8, step: 1,
group: "notifications",
label: "Visible banners",
detail: "Maximum notification banners shown at once"
},
// ── Capture ─────────────────────────────────────────────────────────
// Directories and encoder arguments are enums rather than free text:
// both are handed to a recorder process, and an arbitrary string there
// is a much larger surface than a settings page needs to expose.
{
key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
label: "Screenshot folder",
detail: "Folder under your home directory for screenshots",
options: [
{ value: "Pictures/Screenshots", label: "Pictures / Screenshots" },
{ value: "Pictures", label: "Pictures" },
{ value: "Desktop", label: "Desktop" }
]
},
{
key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
label: "Recording folder",
detail: "Folder under your home directory for screen recordings",
options: [
{ value: "Videos/Recordings", label: "Videos / Recordings" },
{ value: "Videos", label: "Videos" },
{ value: "Desktop", label: "Desktop" }
]
},
{
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
group: "capture",
label: "Recording encoder",
detail: "Hardware encoding keeps recording off the processor while gaming",
options: [
{ value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
{ value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
{ value: "-c libx264", label: "CPU x264" }
]
},
// ── Dock contents ───────────────────────────────────────────────────
// A "json" value: the ordered list of desktop entry ids pinned to the
// dock. Kept in the shared store so that reordering the dock is covered
// by Restore defaults like everything else, rather than living in its
// own file. The shipped order is the GNOME dash it replaced.
{
key: "dockPinned", type: "json", group: "dock",
label: "Pinned applications",
detail: "Applications that stay in the Dock whether or not they are running",
def: [
"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"
]
},
// ── Keyboard shortcut overrides ─────────────────────────────────────
// { "<bind description>": "<chord>" }. Only the chord is stored: the
// action always comes from hypr/keybinds.lua, so an override can move a
// shortcut but can never make one do something else. The Lua validates
// each chord and falls back to the shipped one, so a hand-edited file
// cannot cost you a keymap.
//
// Edited through the Input & Shortcuts page rather than as a row, hence
// internal.
{
key: "keybindOverrides", type: "json", def: ({}), group: "input",
internal: true,
label: "Keyboard shortcut overrides",
detail: "Shortcuts you have moved from their shipped chord"
},
// ── Internal ────────────────────────────────────────────────────────
{
key: "lastPage", type: "string", def: "home", group: "internal",
internal: true,
label: "Last settings page",
detail: "Restores the page Settings was left on"
}
]
// key -> entry, built once. Every lookup below goes through this rather than
// scanning `entries`, since get/set are called from bindings.
readonly property var byKey: {
const index = {};
for (const entry of root.entries)
index[entry.key] = entry;
return index;
}
readonly property var userKeys: root.entries.filter(entry => !entry.internal).map(entry => entry.key)
function spec(key: string): var {
return root.byKey[key] ?? null;
}
function has(key: string): bool {
return root.byKey[key] !== undefined;
}
function defaultFor(key: string): var {
const entry = root.byKey[key];
return entry ? entry.def : undefined;
}
function defaults(): var {
const out = {};
for (const entry of root.entries)
out[entry.key] = entry.def;
return out;
}
function inGroup(group: string): var {
return root.entries.filter(entry => entry.group === group && !entry.internal);
}
// Entries the compositor owns, used to build one hl.config{} payload.
function hyprEntries(): var {
return root.entries.filter(entry => entry.hypr !== undefined);
}
// Returns the value coerced into the entry's type and range, or `undefined`
// if it cannot be represented at all. Out-of-range numbers are clamped
// rather than rejected: a stale file with a since-narrowed bound should
// still yield a usable desktop.
function coerce(key: string, value: var): var {
const entry = root.byKey[key];
if (!entry || value === undefined || value === null)
return undefined;
switch (entry.type) {
case "bool":
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return undefined;
case "int":
case "real": {
const numeric = Number(value);
if (!isFinite(numeric)) return undefined;
const rounded = entry.type === "int" ? Math.round(numeric) : numeric;
const lower = entry.min !== undefined ? Math.max(rounded, entry.min) : rounded;
return entry.max !== undefined ? Math.min(lower, entry.max) : lower;
}
case "enum":
return entry.options.some(option => option.value === value) ? value : undefined;
case "json":
// Accepted as-is. Anything JSON.parse produced is representable,
// and per-field meaning belongs to the owning service rather than
// here. A scalar is rejected so a corrupt file falls back to the
// default instead of handing a list-shaped consumer a number.
return (typeof value === "object") ? value : undefined;
case "string": {
const text = typeof value === "string" ? value : String(value);
// A constrained string is rejected rather than sanitised. Several
// of these are serialised into an hl.config payload, and quietly
// stripping characters would turn a typo into a different setting
// instead of an error the user can see.
if (entry.pattern && !new RegExp(entry.pattern).test(text))
return undefined;
return text;
}
}
return undefined;
}
}
+87
View File
@@ -0,0 +1,87 @@
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.get("use24Hour")
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
readonly property bool showWeekday: DesktopPreferences.get("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: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
// ── Vitals ──────────────────────────────────────────────────────────────
// The GNOME Vitals extension showed processor usage, memory usage and GPU
// usage, in that order. Same here.
readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("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.get("nightLightTemperature")
readonly property real nightLightFrom: 17.0
readonly property real nightLightTo: 10.0
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ───────────────────────────────────────────────────────
readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// ── 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.get("focusDurationMinutes")
// ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites.
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
// 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.get("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.get("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.get("dockHideDelayMs")
// ── Capture ─────────────────────────────────────────────────────────────
readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
readonly property string recordingDir: DesktopPreferences.get("recordingDir")
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming.
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
}
+156
View File
@@ -0,0 +1,156 @@
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 controlCenterWidth: 430
readonly property int controlCenterTopGap: 2
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
}
+6
View File
@@ -0,0 +1,6 @@
module qs.config
singleton DesktopPreferences 1.0 DesktopPreferences.qml
singleton PreferenceSchema 1.0 PreferenceSchema.qml
singleton HomePreferences 1.0 HomePreferences.qml
singleton Settings 1.0 Settings.qml
singleton Theme 1.0 Theme.qml
@@ -0,0 +1,54 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.quicksettings
ShellRoot {
id: root
property int confirmedValue: 30
property int commitCount: 0
property int lastCommit: -1
HomeBrightnessSlider {
id: slider
width: 200
value: root.confirmedValue
accessibleName: "Desk lamp brightness"
onCommitted: value => {
root.commitCount += 1;
root.lastCommit = value;
}
}
IpcHandler {
target: "home-brightness-slider-test"
function reset(value: int): void {
root.confirmedValue = value;
root.commitCount = 0;
root.lastCommit = -1;
slider.cancelPointerInteraction();
}
function external(value: int): void { root.confirmedValue = value; }
function press(position: int): void { slider.beginPointerInteraction(position); }
function move(position: int): void { slider.movePointerInteraction(position); }
function release(): void { slider.releasePointerInteraction(); }
function cancel(): void { slider.cancelPointerInteraction(); }
function wheel(delta: int): void { slider.commitWheel(delta); }
function status(): string {
return JSON.stringify({
confirmedValue: root.confirmedValue,
previewValue: slider.previewValue,
interactionActive: slider.interactionActive,
commitCount: root.commitCount,
lastCommit: root.lastCommit,
accessibleRoleIsSlider: slider.Accessible.role === Accessible.Slider,
accessibleName: slider.Accessible.name,
accessibleDescription: slider.Accessible.description,
accessibleFocusable: slider.Accessible.focusable
});
}
}
}
@@ -0,0 +1,26 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
IpcHandler {
target: "home-pref-test"
function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
function add(id: string): void { HomePreferences.add(id); }
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); }
function reset(): void { HomePreferences.resetHomeDefaults(); }
function status(): string {
return JSON.stringify({
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites,
saveError: HomePreferences.saveError,
stateDir: Quickshell.stateDir
});
}
}
}
@@ -0,0 +1,58 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "keybinds-test"
function rebind(current: string, next: string): bool {
return Keybinds.rebind(current, next);
}
function resetBind(current: string): bool { return Keybinds.resetBind(current); }
function resetAll(): void { Keybinds.resetAll(); }
function seedResetCollision(shipped: string, current: string, occupantShipped: string): void {
const overrides = {};
overrides[shipped] = current;
overrides[occupantShipped] = shipped;
DesktopPreferences.set("keybindOverrides", overrides);
}
function chordFor(description: string): string {
const found = Keybinds.binds.find(bind => bind.description === description);
return found ? found.luaChord : "";
}
function overrideState(): string {
return JSON.stringify({
overrides: Keybinds.overrides,
count: Object.keys(Keybinds.overrides).length,
lastError: Keybinds.lastError
});
}
function status(): string {
const grouped = Keybinds.grouped();
let groupedCount = 0;
for (const group of grouped)
groupedCount += group.binds.length;
const superT = Keybinds.binds.find(bind => bind.description === "Terminal");
return JSON.stringify({
loaded: Keybinds.loaded,
count: Keybinds.binds.length,
groupedCount: groupedCount,
groups: grouped.map(group => group.name),
sample: superT ? superT.chord : "",
emptyDescriptions: Keybinds.binds.filter(bind => !bind.description).length,
lastError: Keybinds.lastError
});
}
}
}
@@ -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
}
}
+138
View File
@@ -0,0 +1,138 @@
// 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
CalendarIndicator {
anchors.verticalCenter: parent.verticalCenter
}
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,53 @@
// A deliberately quiet appointment reminder. It only enters the bar during
// the narrow window where an upcoming event is immediately useful.
import QtQuick
import QtQuick.Controls
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: CalendarAgenda.capsuleVisible
opacity: visible ? 1 : 0
horizontalPadding: 8
onActivated: ShellState.openDateMenu("agenda")
ToolTip.visible: root.hovered && root.visible
ToolTip.delay: 500
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
Behavior on opacity {
NumberAnimation {
duration: 120
easing.type: Easing.OutCubic
}
}
Behavior on implicitWidth {
NumberAnimation {
duration: 120
easing.type: Easing.OutCubic
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\u{F00ED}"
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 13
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: CalendarAgenda.capsuleText
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
@@ -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.toggleDateMenu("agenda")
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,116 @@
// 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.services
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
}
StatusGlyph {
visible: KdeConnect.phoneReachable
glyph: "\u{F03F2}" // md-cellphone-link
color: Theme.cyan
}
}
@@ -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,128 @@
import QtQuick
import qs.config
import qs.services
Item {
id: root
required property var eventData
property bool past: false
signal activated
signal joinRequested
readonly property var sourceData: CalendarAgenda.sourceForId(eventData.sourceId)
readonly property color eventColor: sourceData?.color ?? Theme.accent
readonly property string timeText: eventData.allDay
? "All day"
: Qt.formatTime(new Date(Number(eventData.start) * 1000), Settings.use24Hour ? "HH:mm" : "h:mm AP")
readonly property string detailText: {
const source = root.sourceData?.name ?? "Calendar";
const location = String(root.eventData.location ?? "").trim();
return location === "" ? source : source + " · " + location;
}
implicitHeight: 58
opacity: past ? 0.52 : 1
Rectangle {
anchors.fill: parent
radius: 9
border.width: 0
color: rowMouse.containsMouse ? Theme.alpha(Theme.fg, 0.07) : "transparent"
Behavior on color { ColorAnimation { duration: Theme.durFast } }
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
Text {
id: timeLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 56
text: root.timeText
color: Theme.fgDim
horizontalAlignment: Text.AlignRight
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
id: sourceRail
anchors.left: timeLabel.right
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 3
height: 34
radius: 2
border.width: 0
color: root.eventColor
}
Column {
anchors.left: sourceRail.right
anchors.leftMargin: 10
anchors.right: joinPill.visible ? joinPill.left : parent.right
anchors.rightMargin: joinPill.visible ? 10 : 4
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.eventData.summary || "Untitled event"
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.detailText
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
id: joinPill
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
visible: String(root.eventData.joinUrl ?? "").startsWith("https://")
width: joinLabel.implicitWidth + 18
height: 26
radius: Theme.pillRadius
border.width: 0
color: joinMouse.containsMouse ? Theme.alpha(Theme.accent, 0.28) : Theme.alpha(Theme.accent, 0.14)
Text {
id: joinLabel
anchors.centerIn: parent
text: "Join"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: joinMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.joinRequested()
}
}
}
@@ -0,0 +1,292 @@
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
id: root
readonly property bool isToday: CalendarAgenda.selectedDateKey === CalendarAgenda.dateKey(clock.date)
readonly property var upNext: CalendarAgenda.nextEventForSelectedDate
readonly property var listedEvents: CalendarAgenda.selectedEvents.filter(event => !root.upNext || event.id !== root.upNext.id)
readonly property string selectedLabel: root.isToday
? "Today"
: Qt.formatDate(CalendarAgenda.selectedDate, "dddd, MMMM d")
implicitHeight: content.implicitHeight
SystemClock {
id: clock
precision: SystemClock.Minutes
}
Column {
id: content
width: parent.width
spacing: 12
Column {
width: parent.width
spacing: 3
visible: CalendarAgenda.phase === "loading"
Text {
text: "CALENDAR"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
Text {
text: "Loading calendars…"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
}
Column {
width: parent.width
spacing: 10
visible: CalendarAgenda.phase === "unavailable"
Text {
text: "Calendar data is unavailable"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
wrapMode: Text.WordWrap
text: "Notifications, weather, and the month grid are still available. GNOME Calendar may have more detail."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
ActionPill {
text: "Open Calendar"
onClicked: CalendarAgenda.openCalendar(CalendarAgenda.selectedDate)
}
}
Rectangle {
id: nextCard
width: parent.width
height: visible ? 88 : 0
visible: CalendarAgenda.available && root.isToday && root.upNext !== null
radius: Theme.cardRadius
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.18)
color: Theme.alpha(Theme.accent, 0.105)
clip: true
Rectangle {
anchors.right: parent.right
anchors.rightMargin: -24
anchors.bottom: parent.bottom
anchors.bottomMargin: -34
width: 110
height: 110
radius: 55
border.width: 0
color: Theme.alpha(Theme.accentSecondary, 0.08)
}
Column {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.right: nextJoin.visible ? nextJoin.left : parent.right
anchors.rightMargin: nextJoin.visible ? 10 : 14
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Text {
text: "UP NEXT · " + root.relativeStart(root.upNext)
color: Theme.accent
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
font.letterSpacing: 0.8
}
Text {
width: parent.width
text: root.upNext?.summary ?? ""
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.eventDetail(root.upNext)
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
ActionPill {
id: nextJoin
anchors.right: parent.right
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 12
visible: String(root.upNext?.joinUrl ?? "").startsWith("https://")
text: "Join"
accent: true
onClicked: CalendarAgenda.join(root.upNext)
}
MouseArea {
anchors.fill: parent
z: -1
cursorShape: Qt.PointingHandCursor
onClicked: CalendarAgenda.openEvent(root.upNext)
}
}
Item {
width: parent.width
height: 34
visible: CalendarAgenda.available
Column {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: root.selectedLabel.toUpperCase()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.9
}
Text {
text: "Your schedule"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
}
Text {
anchors.right: parent.right
anchors.bottom: parent.bottom
text: CalendarAgenda.selectedEvents.length + (CalendarAgenda.selectedEvents.length === 1 ? " event" : " events")
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
Item {
width: parent.width
height: visible ? 92 : 0
visible: CalendarAgenda.available && CalendarAgenda.selectedEvents.length === 0
Column {
anchors.centerIn: parent
spacing: 8
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Nothing scheduled"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
ActionPill {
anchors.horizontalCenter: parent.horizontalCenter
text: "Open Calendar"
onClicked: CalendarAgenda.openCalendar(CalendarAgenda.selectedDate)
}
}
}
ScrollColumn {
width: parent.width
maxHeight: 286
spacing: 0
visible: CalendarAgenda.available && root.listedEvents.length > 0
Repeater {
model: root.listedEvents
AgendaEvent {
required property var modelData
width: parent.width
eventData: modelData
past: Number(modelData.end) <= CalendarAgenda.nowEpoch
onActivated: CalendarAgenda.openEvent(modelData)
onJoinRequested: CalendarAgenda.join(modelData)
}
}
}
}
function relativeStart(event: var): string {
if (!event)
return "";
const seconds = Number(event.start) - CalendarAgenda.nowEpoch;
if (seconds <= 0)
return "NOW";
if (seconds < 3600)
return "IN " + Math.ceil(seconds / 60) + " MINUTES";
return Qt.formatTime(new Date(Number(event.start) * 1000), Settings.use24Hour ? "HH:mm" : "h:mm AP");
}
function eventDetail(event: var): string {
if (!event)
return "";
const source = CalendarAgenda.sourceForId(event.sourceId)?.name ?? "Calendar";
const location = String(event.location ?? "").trim();
return location === "" ? source : source + " · " + location;
}
component ActionPill: Rectangle {
id: action
required property string text
property bool accent: false
signal clicked
width: actionLabel.implicitWidth + 20
height: 28
radius: Theme.pillRadius
border.width: 0
color: actionMouse.containsMouse
? Theme.alpha(action.accent ? Theme.accent : Theme.fg, 0.24)
: Theme.alpha(action.accent ? Theme.accent : Theme.fg, action.accent ? 0.14 : 0.07)
Text {
id: actionLabel
anchors.centerIn: parent
text: action.text
color: action.accent ? Theme.accent : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: actionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: action.clicked()
}
}
}
@@ -0,0 +1,281 @@
// 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.
//
// Days become real navigation targets now that Evolution Data Server backs the
// panel. Up to three source-coloured markers show whether a date has events.
import Quickshell
import QtQuick
import qs.config
import qs.services
Item {
id: root
signal dateActivated(date value)
signal visibleMonthChanged(int year, int month)
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
property date selectedDate: CalendarAgenda.selectedDate
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();
}
function activateCell(model: var): void {
if (!model.current) {
root.viewYear = model.date.getFullYear();
root.viewMonth = model.date.getMonth();
}
root.dateActivated(model.date);
}
onViewYearChanged: root.visibleMonthChanged(root.viewYear, root.viewMonth)
onViewMonthChanged: root.visibleMonthChanged(root.viewYear, root.viewMonth)
readonly property var cells: {
const first = new Date(root.viewYear, root.viewMonth, 1);
const offset = first.getDay(); // 0 = Sunday, matching the header row
const out = [];
for (let i = 0; i < 42; i++) {
const date = new Date(root.viewYear, root.viewMonth, i - offset + 1);
out.push({
date: date,
day: date.getDate(),
current: date.getMonth() === root.viewMonth && date.getFullYear() === root.viewYear
});
}
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
readonly property bool isSelected: CalendarAgenda.dateKey(cell.modelData.date) === CalendarAgenda.dateKey(root.selectedDate)
readonly property var markers: CalendarAgenda.markersForDate(cell.modelData.date).slice(0, 3)
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 || cell.isSelected || cellMouse.containsMouse
color: {
if (cell.isToday)
return Theme.accent;
if (cell.isSelected)
return Theme.alpha(Theme.accent, 0.20);
return Theme.alpha(Theme.fg, 0.08);
}
Behavior on color {
ColorAnimation { duration: Theme.durFast }
}
}
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;
if (cell.isSelected)
return Theme.accent;
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
}
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
spacing: 2
Repeater {
model: cell.markers
Rectangle {
required property var modelData
width: 3
height: 3
radius: 2
border.width: 0
color: cell.isToday ? Theme.bgDark : modelData.color
}
}
}
MouseArea {
id: cellMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activateCell(cell.modelData)
}
}
}
}
}
Component.onCompleted: root.visibleMonthChanged(root.viewYear, root.viewMonth)
}
@@ -0,0 +1,122 @@
// GNOME's date/time menu: calendar, ongoing activity and messages 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
readonly property bool ongoingContentFits: panel.ongoingContentFits
// "notifications" remains the overlay key so existing keybinds keep their
// contract; the surface itself now has Agenda, Ongoing and Notifications.
visible: ShellState.notificationsOpen
color: "transparent"
anchors.top: true
margins.top: Theme.barHeight + Theme.barGap * 2
exclusiveZone: 0
implicitWidth: 760
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) {
CalendarAgenda.refresh();
if (ShellState.dateMenuPage === "notifications")
Notifs.markAllRead();
panel.prepare(ShellState.dateMenuPage);
openAnim.restart();
} else {
panel.reset();
}
}
Connections {
target: ShellState
function onDateMenuPageChanged(): void {
if (root.visible && ShellState.dateMenuPage === "notifications")
Notifs.markAllRead();
}
}
// 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,319 @@
// The approved A · Daybook: month context on the left, the selected day's
// schedule, persistent activity or grouped notification history on the right.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.widgets
Item {
id: root
implicitWidth: 760
implicitHeight: frame.implicitHeight + Theme.popoverPadding * 2
function prepare(page: string): void {
calendar.showToday();
CalendarAgenda.selectDate(clock.date);
CalendarAgenda.setVisibleMonth(clock.date.getFullYear(), clock.date.getMonth());
if (page === "notifications")
Notifs.markAllRead();
}
function reset(): void {
notifications.reset();
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
Column {
id: frame
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.popoverPadding
spacing: 0
Item {
id: header
width: parent.width
height: 42
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.right: dateMenuTabs.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
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Row {
id: dateMenuTabs
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
HeaderPill {
text: "Agenda"
active: ShellState.dateMenuPage === "agenda"
onClicked: ShellState.dateMenuPage = "agenda"
}
HeaderPill {
text: "Ongoing"
active: ShellState.dateMenuPage === "ongoing"
badge: Ongoing.count
onClicked: ShellState.dateMenuPage = "ongoing"
}
HeaderPill {
text: "Notifications"
active: ShellState.dateMenuPage === "notifications"
badge: Notifs.history.length
onClicked: ShellState.dateMenuPage = "notifications"
}
HeaderPill {
visible: ShellState.dateMenuPage === "agenda"
text: "Calendar ↗"
onClicked: CalendarAgenda.openCalendar(CalendarAgenda.selectedDate)
}
}
}
Rectangle {
width: parent.width
height: 1
border.width: 0
color: Theme.alpha(Theme.fg, 0.10)
}
Row {
id: body
width: parent.width
height: Math.min(620, Math.max(438, leftContent.implicitHeight + 28, rightContentHeight + 28))
Item {
id: leftColumn
width: 310
height: body.height
Column {
id: leftContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
spacing: 12
CalendarGrid {
id: calendar
width: parent.width
selectedDate: CalendarAgenda.selectedDate
onDateActivated: date => CalendarAgenda.selectDate(date)
onVisibleMonthChanged: (year, month) => CalendarAgenda.setVisibleMonth(year, month)
}
Rectangle {
width: parent.width
height: 1
border.width: 0
visible: media.visible || Weather.available
color: Theme.alpha(Theme.fg, 0.08)
}
MediaCard {
id: media
width: parent.width
}
WeatherCard {
width: parent.width
visible: Weather.available
}
}
}
Rectangle {
width: 1
height: body.height
border.width: 0
color: Theme.alpha(Theme.fg, 0.10)
}
Item {
id: rightColumn
width: body.width - leftColumn.width - 1
height: body.height
readonly property real availableWidth: width - 28
AgendaPane {
id: agenda
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
visible: ShellState.dateMenuPage === "agenda"
}
OngoingPane {
id: ongoingPane
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
maxHeight: body.height - 28
visible: ShellState.dateMenuPage === "ongoing"
}
Column {
id: notificationPane
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
spacing: 10
visible: ShellState.dateMenuPage === "notifications"
Item {
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.right: notificationActions.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Row {
id: notificationActions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
HeaderPill {
text: "Do Not Disturb"
active: Notifs.doNotDisturb
icon: "notifications-disabled-symbolic"
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
HeaderPill {
visible: Notifs.hasNotifications
text: "Clear all"
onClicked: Notifs.dismissAll()
}
}
}
NotificationList {
id: notifications
width: parent.width
maxHeight: body.height - 76
}
}
}
}
}
readonly property real rightContentHeight: {
if (ShellState.dateMenuPage === "notifications")
return notificationPane.implicitHeight;
if (ShellState.dateMenuPage === "ongoing")
return ongoingPane.implicitHeight;
return agenda.implicitHeight;
}
readonly property bool ongoingContentFits: ongoingPane.implicitHeight <= body.height - 28
component HeaderPill: Rectangle {
id: pill
required property string text
property string icon: ""
property bool active: false
property int badge: -1
signal clicked
width: pillRow.implicitWidth + 20
height: 28
radius: Theme.pillRadius
border.width: 0
color: {
if (pill.active)
return pillMouse.containsMouse ? Theme.alpha(Theme.accent, 0.38) : Theme.alpha(Theme.accent, 0.24);
return pillMouse.containsMouse ? Theme.alpha(Theme.fg, 0.15) : Theme.alpha(Theme.fg, 0.07);
}
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Row {
id: pillRow
anchors.centerIn: parent
spacing: 6
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
visible: pill.icon !== ""
size: visible ? 14 : 0
icon: pill.icon
tint: pill.active ? Theme.accent : Theme.fgDim
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: pill.text
color: pill.active ? Theme.accent : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: pill.badge > 0
width: visible ? badgeText.implicitWidth + 9 : 0
height: 17
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(pill.active ? Theme.accent : Theme.fg, 0.12)
Text {
id: badgeText
anchors.centerIn: parent
text: String(pill.badge)
color: pill.active ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: 9
font.weight: Font.DemiBold
}
}
}
MouseArea {
id: pillMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: pill.clicked()
}
}
}
@@ -0,0 +1,152 @@
// 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 bool compact: width < 360
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 ? (compact ? 64 : 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: root.compact ? 8 : 10
anchors.verticalCenter: parent.verticalCenter
width: root.compact ? 46 : 56
height: width
radius: root.compact ? 9 : 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: root.compact ? 9 : 12
anchors.right: controls.left
anchors.rightMargin: root.compact ? 6 : 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: root.compact ? 7 : 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: root.compact ? (emphasized ? 30 : 27) : (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,58 @@
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
signal activated
visible: Notifs.hasNotifications
implicitHeight: visible ? 62 : 0
radius: Theme.cardRadius
border.width: 0
color: summaryMouse.containsMouse ? Theme.alpha(Theme.fg, 0.10) : Theme.alpha(Theme.fg, 0.055)
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Column {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
text: Notifs.history.length + (Notifs.history.length === 1 ? " notification" : " notifications")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
text: "Grouped quietly until you open them"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "View →"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: summaryMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
}
@@ -0,0 +1,263 @@
// Persistent work and privacy activity inside the Daybook.
import QtQuick
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
id: root
property real maxHeight: 500
implicitHeight: content.implicitHeight
Column {
id: content
width: parent.width
spacing: 12
Item {
width: parent.width
height: 38
Column {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: "PERSISTENT ACTIVITY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.9
}
Text {
text: "Ongoing"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
}
Text {
anchors.right: parent.right
anchors.bottom: parent.bottom
text: Ongoing.count === 0 ? "Nothing active" : Ongoing.count + (Ongoing.count === 1 ? " activity" : " activities")
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
Item {
width: parent.width
height: visible ? 190 : 0
visible: Ongoing.count === 0
Column {
anchors.centerIn: parent
spacing: 8
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "\u{F04A5}"
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: 24
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Nothing ongoing"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Focus, Caffeine, recording and privacy activity appear here."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
ScrollColumn {
width: parent.width
// Header + footer + the two 12px Column gaps consume 116px.
// Keeping that outside the scroll budget prevents the pane from
// growing beyond the Daybook when every producer is active.
maxHeight: Math.max(0, root.maxHeight - 116)
spacing: 8
visible: Ongoing.count > 0
Repeater {
model: Ongoing.activities
Rectangle {
id: activityRow
required property var modelData
width: parent.width
height: 62
radius: Theme.cardRadius
border.width: 0
color: rowMouse.containsMouse
? Theme.alpha(Theme.fg, 0.10)
: Theme.alpha(Theme.fg, 0.055)
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Text {
id: activityIcon
anchors.left: parent.left
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
text: activityRow.modelData.glyph
color: root.toneColor(activityRow.modelData.tone)
font.family: Theme.fontMono
font.pixelSize: 17
}
Column {
anchors.left: activityIcon.right
anchors.leftMargin: 11
anchors.right: activityAction.visible ? activityAction.left : activityState.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
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: activityState
anchors.right: activityAction.visible ? activityAction.left : parent.right
anchors.rightMargin: activityAction.visible ? 7 : 12
anchors.verticalCenter: parent.verticalCenter
width: stateLabel.implicitWidth + 16
height: 25
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(root.toneColor(activityRow.modelData.tone), 0.12)
Text {
id: stateLabel
anchors.centerIn: parent
text: activityRow.modelData.state
color: root.toneColor(activityRow.modelData.tone)
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
Rectangle {
id: activityAction
z: 2
anchors.right: parent.right
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
visible: activityRow.modelData.action !== ""
width: visible ? actionLabel.implicitWidth + 18 : 0
height: 28
radius: Theme.pillRadius
border.width: 0
color: actionMouse.containsMouse
? Theme.alpha(activityRow.modelData.tone === "danger" ? Theme.danger : Theme.fg, 0.20)
: Theme.alpha(activityRow.modelData.tone === "danger" ? Theme.danger : Theme.fg, 0.08)
Text {
id: actionLabel
anchors.centerIn: parent
text: activityRow.modelData.action
color: activityRow.modelData.tone === "danger" ? Theme.danger : 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: Ongoing.invoke(activityRow.modelData.kind)
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
z: 1
enabled: activityRow.modelData.kind !== "caffeine"
hoverEnabled: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: Ongoing.activate(activityRow.modelData.kind)
}
}
}
}
Rectangle {
width: parent.width
height: 54
radius: Theme.cardRadius
border.width: 0
visible: Ongoing.count > 0
color: Theme.alpha(Theme.fg, 0.035)
Column {
anchors.left: parent.left
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
text: "Visible until it ends"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
text: "Completed screenshots, devices and other brief events stay in Signal Glass."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
function toneColor(tone: string): color {
if (tone === "danger")
return Theme.danger;
if (tone === "warn")
return Theme.warn;
if (tone === "ok")
return Theme.ok;
return Theme.accent;
}
}
@@ -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,13 @@
module qs.modules.datemenu
AgendaEvent 1.0 AgendaEvent.qml
AgendaPane 1.0 AgendaPane.qml
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
NotificationSummary 1.0 NotificationSummary.qml
OngoingPane 1.0 OngoingPane.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()
}
}
}
}
}

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