Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09e5f1ad6b | ||
|
|
7541a45e77 | ||
|
|
1a91d00f2c | ||
|
|
19063a3e02 | ||
|
|
5e6dc9e533 | ||
|
|
ca64d4b9b6 | ||
|
|
b28edcd01f | ||
|
|
9c574f9eb7 | ||
|
|
d1b2cd2083 | ||
|
|
8c4dee0ca8 | ||
|
|
71ee18048e | ||
|
|
668983e16d | ||
|
|
17e23a6cf4 | ||
|
|
d18fe51553 | ||
|
|
9f9515ffe0 | ||
|
|
94353aa0bd | ||
|
|
3a2295ff53 | ||
|
|
de1b8a7673 | ||
|
|
39a26adcc2 | ||
|
|
eff0bc44ff | ||
|
|
c038c57278 | ||
|
|
420b7aa1a6 | ||
|
|
f22e405b50 | ||
|
|
3cfa592db9 | ||
|
|
81096e2d95 | ||
|
|
c415c4f176 | ||
|
|
86742da63d | ||
|
|
3c521cf5fa | ||
|
|
c087998710 | ||
|
|
b9800bfbab | ||
|
|
7b8270fdd6 | ||
|
|
6d3f888784 | ||
|
|
c2cd79b547 | ||
|
|
0a18290137 | ||
|
|
efc53435a2 | ||
|
|
67674d297e | ||
|
|
2d4b126f14 | ||
|
|
5671324eb2 | ||
|
|
83391e0453 | ||
|
|
172b099a04 | ||
|
|
cae72b5179 | ||
|
|
b3b8e0d66d | ||
|
|
787d2b121a | ||
|
|
1b323c2fa5 | ||
|
|
1ca571458c | ||
|
|
768801dbe4 | ||
|
|
375ecfcd95 | ||
|
|
ce95b34d19 | ||
|
|
9d430a3079 | ||
|
|
bac68d2bfb | ||
|
|
634a9ebe07 | ||
|
|
150f3cdb09 | ||
|
|
6377bfb8fd | ||
|
|
2bc12e6022 | ||
|
|
fe7c85e471 | ||
|
|
8a04e4f9d1 | ||
|
|
5347954a88 | ||
|
|
00a81edadd | ||
|
|
4bd3638012 | ||
|
|
7f540a58e5 | ||
|
|
7ac5355a77 | ||
|
|
a9eb90f457 | ||
|
|
b5c9156e64 | ||
|
|
3213a0989a | ||
|
|
fbf729b25c | ||
|
|
e37df9d838 | ||
|
|
673aac6aba | ||
|
|
c8eb344a0b | ||
|
|
65966200fe | ||
|
|
2130d6a1a5 | ||
|
|
9a99c6064e | ||
|
|
4f93522f7d | ||
|
|
a325ec807e | ||
|
|
c2b19f7545 | ||
|
|
925fc0276f | ||
|
|
65a8197419 | ||
|
|
ff67387c42 | ||
|
|
c42794c5e2 | ||
|
|
0964af7b73 | ||
|
|
15fb008954 | ||
|
|
f4c3ffed43 | ||
|
|
5f965daa6a | ||
|
|
5940d6b4bd | ||
|
|
190e3200e0 | ||
|
|
49ec4d8f6a | ||
|
|
173c00fca7 | ||
|
|
3a903934c0 | ||
|
|
dc6da5af34 | ||
|
|
922395d2b4 | ||
|
|
a5450fdb6d | ||
|
|
1ce5c732b7 | ||
|
|
f9cc15fb96 | ||
|
|
2728fb08fb | ||
|
|
f27a1dca11 | ||
|
|
03914e23af | ||
|
|
6a1bec849d | ||
|
|
fb27c5b309 | ||
|
|
d37d4a87de | ||
|
|
530a5590a6 | ||
|
|
e92c2ee5f9 | ||
|
|
5248883e4b |
+10
@@ -6,3 +6,13 @@
|
|||||||
/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]
|
||||||
|
|
||||||
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
|
/config/dot/kitty/current-theme.conf
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'.
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 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"
|
||||||
|
local osd = function(action)
|
||||||
|
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 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(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
|
||||||
|
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
|
||||||
|
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
|
||||||
|
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
|
||||||
|
|
||||||
|
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
||||||
|
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
|
||||||
|
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
|
||||||
|
|
||||||
|
bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
|
||||||
|
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
|
||||||
|
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
|
||||||
|
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
|
||||||
|
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
|
||||||
|
|
||||||
|
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
|
||||||
|
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 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
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 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.
|
||||||
|
-- Follows the colour scheme: a dark neutral is invisible against a
|
||||||
|
-- light desktop. services/ColorScheme.qml applies changes live;
|
||||||
|
-- this is the value a fresh session starts from.
|
||||||
|
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||||
|
and "rgba(a8aecb99)" or "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,
|
||||||
|
|
||||||
|
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
||||||
|
-- followed the preference, the compositor and the shell would disagree
|
||||||
|
-- about the interface font and nobody would notice until a tooltip
|
||||||
|
-- rendered in a different typeface.
|
||||||
|
font_family = prefs.get("interfaceFont", "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
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 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.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
|
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||||
|
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||||
|
--
|
||||||
|
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||||
|
-- stay here, because those are the settings with a documented reason attached
|
||||||
|
-- (see the header) rather than preferences, and a settings page has no way to
|
||||||
|
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||||
|
local displays = prefs.get("displays", {})
|
||||||
|
if type(displays) ~= "table" then
|
||||||
|
displays = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mode_dimensions(mode)
|
||||||
|
if type(mode) ~= "string" then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
|
||||||
|
if width == nil then
|
||||||
|
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
|
||||||
|
end
|
||||||
|
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
|
||||||
|
if width == nil or height == nil or refresh == nil
|
||||||
|
or width <= 0 or height <= 0 or refresh <= 0 then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_mode(mode)
|
||||||
|
local width = mode_dimensions(mode)
|
||||||
|
return width ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_scale(mode, scale)
|
||||||
|
local width, height = mode_dimensions(mode)
|
||||||
|
if width == nil or type(scale) ~= "number" or scale ~= scale
|
||||||
|
or scale <= 0 or scale > 4 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local logical_width = width / scale
|
||||||
|
local logical_height = height / scale
|
||||||
|
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
|
||||||
|
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_transform(transform)
|
||||||
|
return type(transform) == "number"
|
||||||
|
and transform == math.floor(transform)
|
||||||
|
and transform >= 0
|
||||||
|
and transform <= 3
|
||||||
|
end
|
||||||
|
|
||||||
|
local function display_entry(output)
|
||||||
|
if type(output) ~= "string" or output == ""
|
||||||
|
or output:match("^[%w_.-]+$") == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local entry = displays[output]
|
||||||
|
if type(entry) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if not valid_mode(entry.mode)
|
||||||
|
or not valid_scale(entry.mode, entry.scale)
|
||||||
|
or not valid_transform(entry.transform) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
local shipped_mode = "4500x3000@60"
|
||||||
|
local shipped_scale = 1.5
|
||||||
|
local shipped_transform = 0
|
||||||
|
local dp2 = display_entry("DP-2")
|
||||||
|
|
||||||
|
hl.monitor({
|
||||||
|
output = "DP-2",
|
||||||
|
mode = dp2 and dp2.mode or shipped_mode,
|
||||||
|
position = "0x0",
|
||||||
|
scale = dp2 and dp2.scale or shipped_scale,
|
||||||
|
transform = dp2 and dp2.transform or shipped_transform,
|
||||||
|
|
||||||
|
-- 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",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Other connected outputs use the same validated per-output store. They keep
|
||||||
|
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||||
|
-- carries the panel-specific 10-bit policy documented above.
|
||||||
|
for output, _ in pairs(displays) do
|
||||||
|
if output ~= "DP-2" then
|
||||||
|
local entry = display_entry(output)
|
||||||
|
if entry ~= nil then
|
||||||
|
hl.monitor({
|
||||||
|
output = output,
|
||||||
|
mode = entry.mode,
|
||||||
|
position = "auto",
|
||||||
|
scale = entry.scale,
|
||||||
|
transform = entry.transform,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Any monitor not named above: sane defaults rather than nothing.
|
||||||
|
hl.monitor({
|
||||||
|
output = "",
|
||||||
|
mode = "preferred",
|
||||||
|
position = "auto",
|
||||||
|
scale = "auto",
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -183,7 +183,6 @@ font_size 14.0
|
|||||||
|
|
||||||
#: Cursor customization {{{
|
#: Cursor customization {{{
|
||||||
|
|
||||||
cursor #c8d3f5
|
|
||||||
|
|
||||||
#: Default cursor color. If set to the special value none the cursor
|
#: Default cursor color. If set to the special value none the cursor
|
||||||
#: will be rendered with a "reverse video" effect. It's color will be
|
#: will be rendered with a "reverse video" effect. It's color will be
|
||||||
@@ -193,7 +192,6 @@ cursor #c8d3f5
|
|||||||
#: precedence. Also, the cursor colors are modified if the cell
|
#: precedence. Also, the cursor colors are modified if the cell
|
||||||
#: background and foreground colors have very low contrast.
|
#: background and foreground colors have very low contrast.
|
||||||
|
|
||||||
cursor_text_color #111325
|
|
||||||
|
|
||||||
#: The color of text under the cursor. If you want it rendered with
|
#: The color of text under the cursor. If you want it rendered with
|
||||||
#: the background color of the cell underneath instead, use the
|
#: the background color of the cell underneath instead, use the
|
||||||
@@ -312,7 +310,6 @@ cursor_shape block
|
|||||||
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
||||||
#: much effort.
|
#: much effort.
|
||||||
|
|
||||||
url_color #4fd6be
|
|
||||||
# url_style curly
|
# url_style curly
|
||||||
|
|
||||||
#: The color and style for highlighting URLs on mouse-over. url_style
|
#: The color and style for highlighting URLs on mouse-over. url_style
|
||||||
@@ -734,12 +731,10 @@ window_border_width 1pt
|
|||||||
#: placed centrally. A value of top-left means the padding will be
|
#: placed centrally. A value of top-left means the padding will be
|
||||||
#: only at the bottom and right edges.
|
#: only at the bottom and right edges.
|
||||||
|
|
||||||
active_border_color #82aaff
|
|
||||||
|
|
||||||
#: The color for the border of the active window. Set this to none to
|
#: The color for the border of the active window. Set this to none to
|
||||||
#: not draw borders around the active window.
|
#: not draw borders around the active window.
|
||||||
|
|
||||||
inactive_border_color #2f334d
|
|
||||||
|
|
||||||
#: The color for the border of inactive windows.
|
#: The color for the border of inactive windows.
|
||||||
|
|
||||||
@@ -960,16 +955,11 @@ tab_powerline_style round
|
|||||||
#: Template to use for active tabs. If not specified falls back to
|
#: Template to use for active tabs. If not specified falls back to
|
||||||
#: tab_title_template.
|
#: tab_title_template.
|
||||||
|
|
||||||
active_tab_foreground #1e2030
|
|
||||||
active_tab_background #82aaff
|
|
||||||
# active_tab_font_style bold-italic
|
# active_tab_font_style bold-italic
|
||||||
inactive_tab_foreground #c8d3f5
|
|
||||||
inactive_tab_background #42465a
|
|
||||||
# inactive_tab_font_style normal
|
# inactive_tab_font_style normal
|
||||||
|
|
||||||
#: Tab bar colors and styles.
|
#: Tab bar colors and styles.
|
||||||
|
|
||||||
tab_bar_background #222436
|
|
||||||
|
|
||||||
#: Background color for the tab bar. Defaults to using the terminal
|
#: Background color for the tab bar. Defaults to using the terminal
|
||||||
#: background color.
|
#: background color.
|
||||||
@@ -983,8 +973,8 @@ tab_bar_margin_color none
|
|||||||
|
|
||||||
#: Color scheme {{{
|
#: Color scheme {{{
|
||||||
|
|
||||||
foreground #c8d3f5
|
|
||||||
background #222436
|
include current-theme.conf
|
||||||
|
|
||||||
#: The foreground and background colors.
|
#: The foreground and background colors.
|
||||||
|
|
||||||
@@ -1043,8 +1033,6 @@ dim_opacity 1
|
|||||||
#: How much to dim text that has the DIM/FAINT attribute set. One
|
#: How much to dim text that has the DIM/FAINT attribute set. One
|
||||||
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
||||||
|
|
||||||
selection_foreground #2d3f76
|
|
||||||
selection_background #c8d3f5
|
|
||||||
|
|
||||||
#: The foreground and background colors for text selected with the
|
#: The foreground and background colors for text selected with the
|
||||||
#: mouse. Setting both of these to none will cause a "reverse video"
|
#: mouse. Setting both of these to none will cause a "reverse video"
|
||||||
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
|
|||||||
#: dull and bright version, for the first 16 colors. You can set the
|
#: dull and bright version, for the first 16 colors. You can set the
|
||||||
#: remaining 240 colors as color16 to color255.
|
#: remaining 240 colors as color16 to color255.
|
||||||
|
|
||||||
color0 #1b1d2b
|
|
||||||
color8 #444a73
|
|
||||||
|
|
||||||
#: black
|
#: black
|
||||||
|
|
||||||
color1 #ff757f
|
|
||||||
color9 #ff757f
|
|
||||||
|
|
||||||
#: red
|
#: red
|
||||||
|
|
||||||
color2 #a5e8b5
|
|
||||||
color10 #a5e8b5
|
|
||||||
|
|
||||||
#: green
|
#: green
|
||||||
color3 #ffc777
|
|
||||||
color11 #ffc777
|
|
||||||
|
|
||||||
#: yellow
|
#: yellow
|
||||||
|
|
||||||
color4 #82aaff
|
|
||||||
color12 #82aaff
|
|
||||||
|
|
||||||
#: blue
|
#: blue
|
||||||
|
|
||||||
color5 #c099ff
|
|
||||||
color13 #c099ff
|
|
||||||
|
|
||||||
#: magenta
|
#: magenta
|
||||||
|
|
||||||
color6 #86e1fc
|
|
||||||
color14 #86e1fc
|
|
||||||
|
|
||||||
#: cyan
|
#: cyan
|
||||||
|
|
||||||
color7 #828bb8
|
|
||||||
color15 #c8d3f5
|
|
||||||
|
|
||||||
#: white
|
#: white
|
||||||
|
|
||||||
color16 #ff966c
|
|
||||||
color17 #c53b53
|
|
||||||
|
|
||||||
# mark1_foreground black
|
# mark1_foreground black
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Tokyo Night Day — the palette's own light variant.
|
||||||
|
#
|
||||||
|
# The same hues as Moon at a different lightness, so a terminal in light mode
|
||||||
|
# still belongs to this desktop rather than looking like a different machine.
|
||||||
|
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
|
||||||
|
|
||||||
|
cursor #3760bf
|
||||||
|
cursor_text_color #e1e2e7
|
||||||
|
url_color #118c74
|
||||||
|
active_border_color #2e7de9
|
||||||
|
inactive_border_color #c4c8da
|
||||||
|
active_tab_foreground #e1e2e7
|
||||||
|
active_tab_background #2e7de9
|
||||||
|
inactive_tab_foreground #3760bf
|
||||||
|
inactive_tab_background #c4c8da
|
||||||
|
tab_bar_background #e1e2e7
|
||||||
|
foreground #3760bf
|
||||||
|
background #e1e2e7
|
||||||
|
selection_foreground #e1e2e7
|
||||||
|
selection_background #99a7df
|
||||||
|
color0 #e9e9ed
|
||||||
|
color8 #a1a6c5
|
||||||
|
color1 #f52a65
|
||||||
|
color9 #f52a65
|
||||||
|
color2 #587539
|
||||||
|
color10 #587539
|
||||||
|
color3 #8c6c3e
|
||||||
|
color11 #8c6c3e
|
||||||
|
color4 #2e7de9
|
||||||
|
color12 #2e7de9
|
||||||
|
color5 #9854f1
|
||||||
|
color13 #9854f1
|
||||||
|
color6 #007197
|
||||||
|
color14 #007197
|
||||||
|
color7 #6172b0
|
||||||
|
color15 #3760bf
|
||||||
|
color16 #b15c00
|
||||||
|
color17 #c64343
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Tokyo Night Moon — the dark theme Panama ships.
|
||||||
|
#
|
||||||
|
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||||
|
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||||
|
# setting; that generated file is gitignored because it is machine state.
|
||||||
|
#
|
||||||
|
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||||
|
# remote control. Without it a scheme change would only reach terminals opened
|
||||||
|
# afterwards.
|
||||||
|
|
||||||
|
cursor #c8d3f5
|
||||||
|
cursor_text_color #111325
|
||||||
|
url_color #4fd6be
|
||||||
|
active_border_color #82aaff
|
||||||
|
inactive_border_color #2f334d
|
||||||
|
active_tab_foreground #1e2030
|
||||||
|
active_tab_background #82aaff
|
||||||
|
inactive_tab_foreground #c8d3f5
|
||||||
|
inactive_tab_background #42465a
|
||||||
|
tab_bar_background #222436
|
||||||
|
foreground #c8d3f5
|
||||||
|
background #222436
|
||||||
|
selection_foreground #2d3f76
|
||||||
|
selection_background #c8d3f5
|
||||||
|
color0 #1b1d2b
|
||||||
|
color8 #444a73
|
||||||
|
color1 #ff757f
|
||||||
|
color9 #ff757f
|
||||||
|
color2 #a5e8b5
|
||||||
|
color10 #a5e8b5
|
||||||
|
color3 #ffc777
|
||||||
|
color11 #ffc777
|
||||||
|
color4 #82aaff
|
||||||
|
color12 #82aaff
|
||||||
|
color5 #c099ff
|
||||||
|
color13 #c099ff
|
||||||
|
color6 #86e1fc
|
||||||
|
color14 #86e1fc
|
||||||
|
color7 #828bb8
|
||||||
|
color15 #c8d3f5
|
||||||
|
color16 #ff966c
|
||||||
|
color17 #c53b53
|
||||||
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
|
|||||||
vim.fn.winrestview(view)
|
vim.fn.winrestview(view)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Follow the desktop's light/dark setting while running, not only at startup.
|
||||||
|
require("config.panama").watch()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- Panama desktop integration.
|
||||||
|
--
|
||||||
|
-- Neovim is the one application here that neither reads the desktop portal nor
|
||||||
|
-- has a control socket open by default, so it reads the shared settings file
|
||||||
|
-- directly -- the same ~/.config/panama/settings.json that the shell and the
|
||||||
|
-- Hyprland config read.
|
||||||
|
--
|
||||||
|
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||||
|
-- user their colour scheme preference and nothing else; editing text is more
|
||||||
|
-- important than matching the desktop.
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local function settings_path()
|
||||||
|
local config_home = os.getenv("XDG_CONFIG_HOME")
|
||||||
|
if config_home == nil or config_home == "" then
|
||||||
|
local home = os.getenv("HOME")
|
||||||
|
if home == nil or home == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
config_home = home .. "/.config"
|
||||||
|
end
|
||||||
|
return config_home .. "/panama/settings.json"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "dark" or "light". Defaults to dark, which is what Panama ships.
|
||||||
|
function M.color_scheme()
|
||||||
|
local path = settings_path()
|
||||||
|
if not path then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, contents = pcall(function()
|
||||||
|
local file = io.open(path, "r")
|
||||||
|
if not file then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local text = file:read("*a")
|
||||||
|
file:close()
|
||||||
|
return text
|
||||||
|
end)
|
||||||
|
if not ok or not contents or contents == "" then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
local decoded_ok, decoded = pcall(vim.json.decode, contents)
|
||||||
|
if not decoded_ok or type(decoded) ~= "table" then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
return decoded.colorScheme == "light" and "light" or "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.is_light()
|
||||||
|
return M.color_scheme() == "light"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- tokyonight ships a light variant in the same family, so light mode stays the
|
||||||
|
-- same identity rather than becoming a different editor theme.
|
||||||
|
function M.tokyonight_style()
|
||||||
|
return M.is_light() and "day" or "moon"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Re-apply the scheme when the window regains focus.
|
||||||
|
--
|
||||||
|
-- Neovim reads the setting once at startup and has no control socket open by
|
||||||
|
-- default, so an editor already running when the desktop scheme flips would
|
||||||
|
-- otherwise stay on the old palette until it was restarted. FocusGained is
|
||||||
|
-- cheap, happens exactly when you would notice the mismatch, and does nothing
|
||||||
|
-- at all unless the scheme actually changed.
|
||||||
|
function M.watch()
|
||||||
|
local applied = M.color_scheme()
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd("FocusGained", {
|
||||||
|
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
|
||||||
|
callback = function()
|
||||||
|
local current = M.color_scheme()
|
||||||
|
if current == applied then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
applied = current
|
||||||
|
vim.o.background = current
|
||||||
|
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -1,31 +1,68 @@
|
|||||||
|
-- Tokyo Night, following the desktop's colour scheme.
|
||||||
|
--
|
||||||
|
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||||
|
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||||
|
-- becoming a different-looking application when the scheme flips.
|
||||||
|
--
|
||||||
|
-- The readability fixes below are deliberately dark-only. They were written
|
||||||
|
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||||
|
-- applying them to Day would put light grey text on a light background, which
|
||||||
|
-- is exactly the legibility problem they exist to solve, inverted.
|
||||||
|
|
||||||
|
local panama = require("config.panama")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
{
|
{
|
||||||
"folke/tokyonight.nvim",
|
"folke/tokyonight.nvim",
|
||||||
opts = {
|
opts = function()
|
||||||
style = "moon",
|
local light = panama.is_light()
|
||||||
transparent = true,
|
|
||||||
on_colors = function(colors)
|
return {
|
||||||
colors.comment = "#a0a7c5"
|
style = panama.tokyonight_style(),
|
||||||
colors.fg_gutter = "#787f93"
|
light_style = "day",
|
||||||
colors.terminal_black = "#828bb8"
|
transparent = true,
|
||||||
end,
|
|
||||||
on_highlights = function(highlights, colors)
|
on_colors = function(colors)
|
||||||
-- Fix inline code visibility in markdown
|
if light then
|
||||||
highlights["@markup.raw.markdown_inline"] = {
|
-- Day's defaults are tuned for a light ground and mostly need no
|
||||||
bg = colors.terminal_black,
|
-- help. Comments are the exception: the shipped #848cb5 measures
|
||||||
fg = colors.fg,
|
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
|
||||||
}
|
-- for secondary text. This is 3.25:1 -- readable, and still clearly
|
||||||
highlights["RenderMarkdownCodeInline"] = {
|
-- dimmer than Normal's 4.52:1 so it does not compete with code.
|
||||||
bg = colors.terminal_black,
|
colors.comment = "#7079a8"
|
||||||
fg = colors.fg,
|
return
|
||||||
}
|
end
|
||||||
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
|
||||||
-- imports, making text nearly invisible against LspReferenceText's background
|
colors.comment = "#a0a7c5"
|
||||||
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
|
colors.fg_gutter = "#787f93"
|
||||||
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
|
colors.terminal_black = "#828bb8"
|
||||||
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
|
end,
|
||||||
end,
|
|
||||||
},
|
on_highlights = function(highlights, colors)
|
||||||
|
-- Inline code in markdown is invisible at both lightnesses without
|
||||||
|
-- an explicit background, because the theme leaves it unset.
|
||||||
|
highlights["@markup.raw.markdown_inline"] = {
|
||||||
|
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||||
|
fg = colors.fg,
|
||||||
|
}
|
||||||
|
highlights["RenderMarkdownCodeInline"] = {
|
||||||
|
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||||
|
fg = colors.fg,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||||
|
-- imports, making text nearly invisible against LspReferenceText's
|
||||||
|
-- background.
|
||||||
|
local reference = {
|
||||||
|
bg = light and colors.bg_visual or colors.fg_gutter,
|
||||||
|
fg = colors.fg,
|
||||||
|
}
|
||||||
|
highlights["LspReferenceText"] = reference
|
||||||
|
highlights["LspReferenceRead"] = reference
|
||||||
|
highlights["LspReferenceWrite"] = reference
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"LazyVim/LazyVim",
|
"LazyVim/LazyVim",
|
||||||
|
|||||||
@@ -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,749 @@
|
|||||||
|
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"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Night light schedule ────────────────────────────────────────────
|
||||||
|
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
|
||||||
|
// is normal here and is what the shipped values do: on at 17:00, off at
|
||||||
|
// 10:00 the following morning.
|
||||||
|
{
|
||||||
|
key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
|
||||||
|
group: "nightLight",
|
||||||
|
label: "Turns on at",
|
||||||
|
detail: "Only used when Night Light follows a schedule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
|
||||||
|
group: "nightLight",
|
||||||
|
label: "Turns off at",
|
||||||
|
detail: "A time earlier than the start simply means the next morning"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Colour scheme ───────────────────────────────────────────────────
|
||||||
|
// Light is Tokyo Night Day, the official light variant, rather than a
|
||||||
|
// palette invented to merely not be dark. Both share the same hues at
|
||||||
|
// different lightness, which is what keeps the Prism identity intact
|
||||||
|
// across the switch.
|
||||||
|
//
|
||||||
|
// services/ColorScheme.qml pushes the choice to GTK and to the
|
||||||
|
// compositor's border colours, because an application toolbar or a
|
||||||
|
// window border still wearing the other scheme is more jarring than
|
||||||
|
// either scheme on its own.
|
||||||
|
{
|
||||||
|
key: "colorScheme", type: "enum", def: "dark", group: "appearance",
|
||||||
|
label: "Appearance",
|
||||||
|
detail: "Light and dark share one identity, not two themes",
|
||||||
|
options: [
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
{ value: "light", label: "Light" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Typography ──────────────────────────────────────────────────────
|
||||||
|
// The single largest thing in this desktop that used to be changeable
|
||||||
|
// only by editing Theme.qml.
|
||||||
|
//
|
||||||
|
// interfaceFont is every piece of text a person reads. iconFont is used
|
||||||
|
// ONLY to draw glyphs -- workspace pills, the status cluster, search
|
||||||
|
// icons -- so it must carry Nerd Font glyphs; a plain monospace family
|
||||||
|
// there replaces every icon in the shell with tofu, which is why the
|
||||||
|
// picker offers them as a separate, filtered list.
|
||||||
|
{
|
||||||
|
key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||||
|
// A family name, matched by fontconfig. Constrained because it also
|
||||||
|
// reaches hl.config as a string in hypr/looks.lua.
|
||||||
|
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
|
||||||
|
label: "Interface font",
|
||||||
|
detail: "Used for every piece of text in the shell"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
|
||||||
|
label: "Icon font",
|
||||||
|
detail: "Draws the shell's glyphs, so it must be a Nerd Font"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1,
|
||||||
|
unit: "px", group: "typography",
|
||||||
|
label: "Interface text size",
|
||||||
|
detail: "The base size the rest of the shell's type scales from"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 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"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Which GPU the vitals readout tracks ─────────────────────────────
|
||||||
|
// A sysfs path rather than a card number, because the number is neither
|
||||||
|
// stable across machines nor meaningful. Constrained to the one shape
|
||||||
|
// that can be read for utilisation; VitalsWidget hides itself when the
|
||||||
|
// path is unreadable, so a stale value degrades to no readout rather
|
||||||
|
// than a wrong one.
|
||||||
|
{
|
||||||
|
key: "gpuBusyPath", type: "string",
|
||||||
|
def: "/sys/class/drm/card1/device/gpu_busy_percent",
|
||||||
|
group: "vitals", internal: true,
|
||||||
|
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
|
||||||
|
label: "Graphics device",
|
||||||
|
detail: "Which GPU the graphics readout in the bar measures"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Weather location ────────────────────────────────────────────────
|
||||||
|
// Coordinates rather than a place name, because that is what Open-Meteo
|
||||||
|
// takes and it needs no API key. weatherLocation is only the label shown
|
||||||
|
// in the UI; it is never sent anywhere, so it can say whatever makes the
|
||||||
|
// reading recognisable.
|
||||||
|
{
|
||||||
|
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
||||||
|
group: "weather", internal: true,
|
||||||
|
label: "Latitude",
|
||||||
|
detail: "Set by choosing a location"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
|
||||||
|
group: "weather", internal: true,
|
||||||
|
label: "Longitude",
|
||||||
|
detail: "Set by choosing a location"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
|
||||||
|
internal: true,
|
||||||
|
// Display only -- never sent to the weather service.
|
||||||
|
pattern: "^[^\\n]{1,64}$",
|
||||||
|
label: "Weather location",
|
||||||
|
detail: "The place the weather reading is for"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 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, with one
|
||||||
|
// deliberate substitution: Panama Settings takes the first slot rather
|
||||||
|
// than GNOME Settings. Panama now covers what GNOME Settings did for
|
||||||
|
// this desktop and delegates the remainder to it by name, so pinning
|
||||||
|
// the thing it delegates TO put the fallback in front of the real one.
|
||||||
|
// GNOME Settings stays installed and searchable in the launcher.
|
||||||
|
{
|
||||||
|
key: "dockPinned", type: "json", group: "dock",
|
||||||
|
label: "Pinned applications",
|
||||||
|
detail: "Applications that stay in the Dock whether or not they are running",
|
||||||
|
def: [
|
||||||
|
"panama-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"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
|
// { "<output>": { mode, scale, transform } }, applied by
|
||||||
|
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||||
|
// bit depth are deliberately not here: those carry a documented
|
||||||
|
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||||
|
// you would be changing it.
|
||||||
|
{
|
||||||
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
|
internal: true,
|
||||||
|
label: "Display configuration",
|
||||||
|
detail: "Resolution, scale, and rotation per connected display"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Per-application notification rules ──────────────────────────────
|
||||||
|
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
|
||||||
|
//
|
||||||
|
// Absent means "no rule", which is not the same as a rule that allows
|
||||||
|
// everything: a new application must be able to notify without needing
|
||||||
|
// an entry written for it first. services/Notifs.qml treats a missing
|
||||||
|
// entry as permissive and Do Not Disturb remains an override on top,
|
||||||
|
// rather than being duplicated per application.
|
||||||
|
{
|
||||||
|
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
||||||
|
internal: true,
|
||||||
|
label: "Application notification rules",
|
||||||
|
detail: "Per-application notification and lock-screen visibility preferences"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: DesktopPreferences.get("weatherLatitude")
|
||||||
|
readonly property real longitude: DesktopPreferences.get("weatherLongitude")
|
||||||
|
// 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: DesktopPreferences.get("weatherLocation")
|
||||||
|
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: DesktopPreferences.get("gpuBusyPath")
|
||||||
|
|
||||||
|
// ── 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: DesktopPreferences.get("nightLightFrom")
|
||||||
|
readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// ── Colour scheme ───────────────────────────────────────────────────────
|
||||||
|
// Tokyo Night ships an official light variant (Day), so light mode is that
|
||||||
|
// rather than a palette invented to merely not be dark. The two share the
|
||||||
|
// same hues at different lightness, which is what lets the Prism identity
|
||||||
|
// survive the switch: blue still leads into orchid, it is simply a darker
|
||||||
|
// blue on a lighter ground.
|
||||||
|
//
|
||||||
|
// Every token below is a binding on this, so flipping it repaints the whole
|
||||||
|
// shell without anything needing to know it happened.
|
||||||
|
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||||
|
|
||||||
|
// ── Palette ─────────────────────────────────────────────────────────────
|
||||||
|
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
|
||||||
|
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
|
||||||
|
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
|
||||||
|
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
|
||||||
|
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
|
||||||
|
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
|
||||||
|
|
||||||
|
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
|
||||||
|
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
|
||||||
|
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||||
|
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||||
|
|
||||||
|
// 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: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||||
|
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||||
|
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||||
|
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||||
|
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||||
|
readonly property color green: root.dark ? "#c3e88d" : "#587539"
|
||||||
|
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
|
||||||
|
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
|
||||||
|
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
|
||||||
|
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
|
||||||
|
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
|
||||||
|
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Light surfaces need more opacity for the same sense of a solid panel: the
|
||||||
|
// same 0.34 that reads as glass over a dark desktop reads as haze over a
|
||||||
|
// light one, and text on it stops being legible.
|
||||||
|
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||||
|
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
|
||||||
|
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
|
||||||
|
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
||||||
|
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
||||||
|
|
||||||
|
// ── 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: DesktopPreferences.get("interfaceFont")
|
||||||
|
|
||||||
|
// 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: DesktopPreferences.get("iconFont")
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
// The four sizes are a scale, not four independent numbers: they move
|
||||||
|
// together so the relationship between body, caption, heading and title
|
||||||
|
// survives a change to the base rather than drifting apart.
|
||||||
|
readonly property int fontSize: DesktopPreferences.get("interfaceFontSize")
|
||||||
|
readonly property int fontSizeSmall: Math.max(9, root.fontSize - 2)
|
||||||
|
readonly property int fontSizeLarge: root.fontSize + 3
|
||||||
|
readonly property int fontSizeTitle: root.fontSize + 7
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
@@ -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,28 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "connectivity-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
|
||||||
|
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
|
||||||
|
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
|
||||||
|
networks: Connectivity.networks.length,
|
||||||
|
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
|
||||||
|
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
|
||||||
|
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
|
||||||
|
adapter: Connectivity.adapter ? true : false,
|
||||||
|
btDevices: Connectivity.bluetoothDevices.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
|
||||||
|
function setActive(on: bool): void { Connectivity.active = on; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "displays-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
|
||||||
|
return JSON.stringify({
|
||||||
|
count: Displays.monitors.length,
|
||||||
|
name: monitor ? monitor.name : "",
|
||||||
|
width: monitor ? monitor.width : 0,
|
||||||
|
height: monitor ? monitor.height : 0,
|
||||||
|
refresh: monitor ? monitor.refreshRate : 0,
|
||||||
|
mode: monitor ? monitor.mode : "",
|
||||||
|
scale: monitor ? monitor.scale : 0,
|
||||||
|
transform: monitor ? monitor.transform : -1,
|
||||||
|
modes: monitor ? monitor.modes.length : 0,
|
||||||
|
awaiting: Displays.awaitingConfirmation,
|
||||||
|
canConfirm: Displays.canConfirm,
|
||||||
|
secondsLeft: Displays.secondsLeft,
|
||||||
|
lastError: Displays.lastError,
|
||||||
|
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScale(scale: real): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.mode;
|
||||||
|
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshIdentityFixture(): string {
|
||||||
|
const modes = Displays.normaliseModes([
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]"
|
||||||
|
]);
|
||||||
|
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
|
||||||
|
return JSON.stringify({
|
||||||
|
count: modes.length,
|
||||||
|
modes: modes.map(mode => mode.mode),
|
||||||
|
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBad(kind: string): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.mode;
|
||||||
|
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
|
||||||
|
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
|
||||||
|
if (kind === "dirtyScale") {
|
||||||
|
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
|
||||||
|
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
|
||||||
|
}
|
||||||
|
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
|
||||||
|
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmChange(): bool { return Displays.confirm(); }
|
||||||
|
function revertChange(): void { Displays.revert(); }
|
||||||
|
function forget(): void {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (monitor) Displays.forget(monitor.name);
|
||||||
|
}
|
||||||
|
function refresh(): void { Displays.refresh(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
// DesktopEntries populates asynchronously. Reading `applications.values`
|
||||||
|
// here rather than calling byId() blind is the documented way to wait for
|
||||||
|
// it -- and is exactly the trap noted in modules/settings/README.md.
|
||||||
|
readonly property var ids: {
|
||||||
|
const out = {};
|
||||||
|
for (const entry of DesktopEntries.applications.values)
|
||||||
|
out[entry.id] = entry;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "dock-pin-test"
|
||||||
|
|
||||||
|
function count(): int { return DesktopEntries.applications.values.length; }
|
||||||
|
|
||||||
|
function resolve(): string {
|
||||||
|
const pinned = DesktopPreferences.get("dockPinned");
|
||||||
|
const missing = [];
|
||||||
|
let first = null;
|
||||||
|
for (const id of pinned) {
|
||||||
|
const entry = DesktopEntries.byId(id);
|
||||||
|
if (!entry) missing.push(id);
|
||||||
|
if (first === null)
|
||||||
|
first = { id: id, found: !!entry, name: entry ? entry.name : "", icon: entry ? entry.icon : "" };
|
||||||
|
}
|
||||||
|
return JSON.stringify({ total: pinned.length, first: first, missing: missing });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,177 @@
|
|||||||
|
// Compact, click-through system feedback. Repeated hardware-key presses update
|
||||||
|
// this one surface in place, avoiding a stack of notification cards.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var modelData: null
|
||||||
|
readonly property var presentation: OsdState.state
|
||||||
|
readonly property bool belongsHere: !OsdState.monitorName || !root.modelData
|
||||||
|
|| root.modelData.name === OsdState.monitorName
|
||||||
|
readonly property bool requestedVisible: OsdState.active && root.belongsHere
|
||||||
|
readonly property int horizontalPadding: 18
|
||||||
|
readonly property int contentGap: 14
|
||||||
|
readonly property int progressWidth: 156
|
||||||
|
readonly property int progressLabelWidth: 48
|
||||||
|
readonly property int messageWidth: Math.min(300, Math.max(52, Math.ceil(labelMetrics.advanceWidth)))
|
||||||
|
readonly property int desiredWidth: root.horizontalPadding * 2 + 24 + root.contentGap
|
||||||
|
+ (root.presentation.progress
|
||||||
|
? root.progressWidth + root.contentGap + root.progressLabelWidth
|
||||||
|
: root.messageWidth)
|
||||||
|
|
||||||
|
screen: root.modelData
|
||||||
|
anchors.bottom: true
|
||||||
|
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
||||||
|
exclusiveZone: 0
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
implicitWidth: root.desiredWidth
|
||||||
|
implicitHeight: 64
|
||||||
|
color: "transparent"
|
||||||
|
mask: Region {}
|
||||||
|
|
||||||
|
WlrLayershell.namespace: "qs-popover-osd"
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||||
|
|
||||||
|
property bool mapped: false
|
||||||
|
visible: root.mapped
|
||||||
|
|
||||||
|
onRequestedVisibleChanged: {
|
||||||
|
if (root.requestedVisible) {
|
||||||
|
unmapTimer.stop();
|
||||||
|
root.mapped = true;
|
||||||
|
entrance.restart();
|
||||||
|
} else if (root.mapped) {
|
||||||
|
unmapTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (root.requestedVisible)
|
||||||
|
root.mapped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: unmapTimer
|
||||||
|
interval: Theme.durNormal
|
||||||
|
onTriggered: root.mapped = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: surface
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.popoverRadius
|
||||||
|
color: Theme.alpha(Theme.bgPopover, 0.9)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||||
|
opacity: root.requestedVisible ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
transform: Translate {
|
||||||
|
id: slide
|
||||||
|
y: root.requestedVisible ? 0 : 8
|
||||||
|
Behavior on y {
|
||||||
|
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PrismEdge {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 1
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
inset: parent.radius
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: root.contentGap
|
||||||
|
|
||||||
|
ThemedIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
size: 24
|
||||||
|
icon: root.presentation.icon
|
||||||
|
iconFallback: "dialog-information-symbolic"
|
||||||
|
tint: Theme.fg
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
visible: root.presentation.progress
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: visible ? root.progressWidth : 0
|
||||||
|
height: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: height / 2
|
||||||
|
color: Theme.alpha(Theme.fg, 0.14)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width * root.presentation.ratio
|
||||||
|
radius: height / 2
|
||||||
|
color: Theme.accent
|
||||||
|
|
||||||
|
Behavior on width {
|
||||||
|
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: root.presentation.progress ? root.progressLabelWidth : root.messageWidth
|
||||||
|
text: root.presentation.label
|
||||||
|
color: Theme.fg
|
||||||
|
elide: Text.ElideRight
|
||||||
|
maximumLineCount: 1
|
||||||
|
horizontalAlignment: root.presentation.progress ? Text.AlignRight : Text.AlignLeft
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextMetrics {
|
||||||
|
id: labelMetrics
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
text: root.presentation.label
|
||||||
|
}
|
||||||
|
|
||||||
|
ParallelAnimation {
|
||||||
|
id: entrance
|
||||||
|
NumberAnimation {
|
||||||
|
target: surface
|
||||||
|
property: "opacity"
|
||||||
|
from: 0
|
||||||
|
to: 1
|
||||||
|
duration: Theme.durNormal
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
NumberAnimation {
|
||||||
|
target: slide
|
||||||
|
property: "y"
|
||||||
|
from: 8
|
||||||
|
to: 0
|
||||||
|
duration: Theme.durNormal
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user