Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfc0c49877 | ||
|
|
c2eddc2c5b | ||
|
|
6ad2e017e8 | ||
|
|
12f6b2a310 | ||
|
|
6510fdda0f | ||
|
|
36fdd4e076 | ||
|
|
d865cb74a1 | ||
|
|
a95c480c6a | ||
|
|
78db2d5689 | ||
|
|
669a228286 | ||
|
|
89761a7da3 | ||
|
|
8b96d907a1 | ||
|
|
7a5e990439 | ||
|
|
41dd91eb75 | ||
|
|
4910bd659c | ||
|
|
8d66247b7c | ||
|
|
68cbf892e9 | ||
|
|
05fd5346db | ||
|
|
333ab9f6b7 | ||
|
|
e1a04d2d70 | ||
|
|
202b5b89ac | ||
|
|
9202697734 | ||
|
|
9fbbdd902b | ||
|
|
6ae8265730 | ||
|
|
317b7a0962 | ||
|
|
50a99a5ad0 | ||
|
|
bc6d63b70f | ||
|
|
3c359f3f7e | ||
|
|
e446a1072c | ||
|
|
3c45d63085 | ||
|
|
32bebc2b07 | ||
|
|
12d538c4af | ||
|
|
c02329ac3c | ||
|
|
4e978bf3b7 | ||
|
|
1e430881e5 | ||
|
|
f42b3cfe0e | ||
|
|
51ceb19480 | ||
|
|
25e2328658 | ||
|
|
9f563c8f94 | ||
|
|
7d633eb06e | ||
|
|
6d1f3f3763 | ||
|
|
de923cb4d5 | ||
|
|
bd9a55c8eb | ||
|
|
cab7699711 | ||
|
|
86825e7327 | ||
|
|
89417cd6d4 | ||
|
|
41b86c7a9e | ||
|
|
185d7edaa5 | ||
|
|
538c0a887c | ||
|
|
033d5b21f8 | ||
|
|
7cd4131327 | ||
|
|
b280bd02d7 | ||
|
|
4c77bc2f61 | ||
|
|
9092a80f66 | ||
|
|
62dce86b4e | ||
|
|
725e274ef4 | ||
|
|
f09763ef5d | ||
|
|
215da285f3 | ||
|
|
48f7c1e962 | ||
|
|
f457c1eb9f | ||
|
|
d61792433c | ||
|
|
6016efa436 | ||
|
|
ed428e87c4 | ||
|
|
69ecec7ecf | ||
|
|
a90f6eb357 | ||
|
|
c4642919f7 | ||
|
|
c4733e0624 | ||
|
|
e1faaf7a76 | ||
|
|
47f29f9fa9 | ||
|
|
88497826ec | ||
|
|
b319d1a5e1 | ||
|
|
13f3648e4d | ||
|
|
359fb922aa | ||
|
|
b6448c9876 | ||
|
|
15d54b16f6 | ||
|
|
96e4085919 | ||
|
|
6bcc351e07 | ||
|
|
3b01f1e020 | ||
|
|
23141673a2 | ||
|
|
495fb9b41b | ||
|
|
3b657e0a56 | ||
|
|
c148bae4ac | ||
|
|
4cbab01ae2 | ||
|
|
de45f205ad | ||
|
|
52e2a83a78 | ||
|
|
79b3d5cb85 | ||
|
|
1b845c0126 | ||
|
|
f53ca16392 | ||
|
|
6dc606b872 | ||
|
|
32fab59d24 | ||
|
|
f6b970da21 | ||
|
|
7e1c85b094 | ||
|
|
0c364f38e6 | ||
|
|
1aa1324083 | ||
|
|
ac5e6e2130 | ||
|
|
536958430f | ||
|
|
fd99569666 | ||
|
|
a412e3d894 | ||
|
|
4cbe3b882a | ||
|
|
edc504af2e | ||
|
|
a15f019c17 | ||
|
|
116510caa8 | ||
|
|
b10f8e2593 | ||
|
|
8f0fe23377 | ||
|
|
89cf0f8c29 | ||
|
|
e290a262f3 | ||
|
|
6997dd535f | ||
|
|
1f40f8e136 | ||
|
|
e9d567aa72 | ||
|
|
914d58f52b | ||
|
|
a23b42841a | ||
|
|
e0c0e53ae0 | ||
|
|
180308135a | ||
|
|
b743f44c5b | ||
|
|
2528edfddc | ||
|
|
dbd1472e6b | ||
|
|
eeb49c5aff | ||
|
|
8e93f08977 | ||
|
|
99433c0e8e | ||
|
|
a68e4f6dcd | ||
|
|
2b3b762793 | ||
|
|
91306ce810 | ||
|
|
1b94af1163 | ||
|
|
b4ce148caf | ||
|
|
2fcaada7e8 | ||
|
|
588dec4adc | ||
|
|
d96863b687 | ||
|
|
8156fc47ca | ||
|
|
9bc771bcca | ||
|
|
fe74a82a46 | ||
|
|
ac231eeb54 | ||
|
|
8cf03d4529 | ||
|
|
2a716dac9e | ||
|
|
719ef2f38e | ||
|
|
9ba224d776 | ||
|
|
8b59b78d9f | ||
|
|
e6b4d3c1a1 | ||
|
|
70d8d32ee2 | ||
|
|
c37ca3baee | ||
|
|
bd5d030d91 |
+12
-2
@@ -1,5 +1,7 @@
|
||||
# Ignore bash environment variables.
|
||||
/config/bash/env
|
||||
# Personal espanso triggers (name, email), seeded per-machine by setup-identity.
|
||||
/config/dot/espanso/match/identity.yml
|
||||
# Ignore backups of old config files
|
||||
/config/old
|
||||
# Ignore Wireguard config of course!
|
||||
@@ -14,11 +16,19 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
# Generated from the color scheme; machine state, not configuration.
|
||||
/config/dot/kitty/current-theme.conf
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
# Generated from the color scheme; machine state, not configuration.
|
||||
/config/dot/gtk-3.0/settings.ini
|
||||
/config/dot/gtk-4.0/settings.ini
|
||||
/config/dot/tmux/current-theme.conf
|
||||
/config/dot/hypr/hyprlock.conf
|
||||
|
||||
# Build products of the Vicinae extension. The source is the repository's; the
|
||||
# dependency tree and the bundle it produces are machine state, rebuilt by
|
||||
# `panama apps`.
|
||||
/config/local/share/vicinae/extensions/*/node_modules/
|
||||
/config/local/share/vicinae/extensions/*/dist/
|
||||
/config/local/share/vicinae/extensions/*/build/
|
||||
/config/local/share/vicinae/extensions/*/package-lock.json
|
||||
|
||||
@@ -3,44 +3,106 @@
|
||||
Formerly Sunhat. A personal config for Fedora, with the intention of helping a
|
||||
user set up their Fedora system with one command.
|
||||
|
||||
```sh
|
||||
bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot)
|
||||
```
|
||||
|
||||
`boot` installs git if the machine lacks it, clones this repository to
|
||||
`~/.local/share/Panama` (or `$PANAMA_PATH`), and hands off to `install`. It is
|
||||
deliberately small enough to read first, and the same two steps by hand work
|
||||
identically:
|
||||
|
||||
```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:
|
||||
Both are safe to run again: an existing clone is fast-forwarded rather than
|
||||
replaced, and `install` is the upgrade path.
|
||||
|
||||
`install` asks its questions first and then runs the stages in `setup/scripts/`
|
||||
in order, without stopping again:
|
||||
|
||||
| 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>` |
|
||||
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
||||
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
||||
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
||||
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
|
||||
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
||||
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||
| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for |
|
||||
| `install-hardware` | NVIDIA, Secure Boot enrollment, Fedora's extras, firmware — each only if it was asked for. Last, because enrollment and firmware are consumed at the next boot |
|
||||
|
||||
Then `panama migrate` applies any repairs this machine has not had yet. That is
|
||||
the half of an upgrade installing cannot do: the stages above only ever add, so
|
||||
nothing there can remove a file this repository stopped shipping or repair a
|
||||
symlink that now points nowhere. A fresh machine is marked as already caught up
|
||||
rather than having those repairs run against it, and a machine with work waiting
|
||||
is told at the next login rather than left to find out.
|
||||
|
||||
The run ends with a health summary from `panama-doctor`, which reports what is
|
||||
actually running rather than what was attempted. It never fails the install: on a
|
||||
fresh machine it legitimately reports things as not yet configured.
|
||||
|
||||
### Optional applications
|
||||
|
||||
Every machine gets the lists in `setup/packages/`. The interview also offers the
|
||||
categories in `setup/packages/extras/` as a checklist, so a work laptop need not
|
||||
acquire emulators and a desktop need not skip Steam. Nothing is preselected.
|
||||
|
||||
A category is one file. A bare line is a dnf package and a `flatpak:` line is a
|
||||
Flathub ID, because the applications in a category do not all come from one
|
||||
place. Adding a category is adding a file — the menu is read from the directory,
|
||||
not written down anywhere.
|
||||
|
||||
Existing configs are moved to `config/old/` rather than overwritten.
|
||||
|
||||
## Desktops
|
||||
## The desktop
|
||||
|
||||
Panama configures **two** desktops that coexist. Both sessions stay available in
|
||||
GDM, so you can switch back and forth while you settle in.
|
||||
Hyprland, with a shell written from scratch. It began as a replacement for a
|
||||
GNOME session — Forge for tiling, Dash-to-Dock, Openbar, Vitals — and was built
|
||||
to reproduce it closely enough that muscle memory transferred: same keybinds,
|
||||
same panel contents, same dock, same Tokyo Night Moon palette.
|
||||
|
||||
### GNOME
|
||||
The original setup: Forge for tiling, Dash-to-Dock, Openbar, Vitals,
|
||||
AppIndicator support. Configured through `config/dot/forge/` and gsettings.
|
||||
That is history now rather than a second option. Panama installs and configures
|
||||
one desktop, and the GNOME session it grew out of is neither installed nor
|
||||
configured here. What each piece replaced is recorded in
|
||||
[`config/dot/hypr/DESKTOP-PARITY.md`](config/dot/hypr/DESKTOP-PARITY.md) and in
|
||||
the comments of the components themselves, because knowing what a thing was
|
||||
modelled on explains why it behaves the way it does.
|
||||
|
||||
### 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.
|
||||
GNOME is not gone from the machine: `gnome-control-center` is a declared
|
||||
dependency, and Panama's own Settings hands off to it for the panels it
|
||||
deliberately does not own — Online Accounts, Color, Sound, Network, Keyboard,
|
||||
Privacy, Wellbeing, Accessibility, and System for users, date and time, region
|
||||
and remote desktop. The allow-list in
|
||||
[`services/SystemSettings.qml`](config/dot/quickshell/services/SystemSettings.qml)
|
||||
is what decides; anything not on it is a panel Panama owns itself.
|
||||
|
||||
| Piece | What it is |
|
||||
|---|---|
|
||||
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
||||
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
||||
| `config/dot/vicinae/` | Raycast-style launcher, themed |
|
||||
| `config/containers/` | Container definitions systemd runs as units — currently the speech-to-text server behind dictation |
|
||||
| `config/dot/vicinae/` | Raycast-style launcher, themed. Its commands live in `config/local/share/vicinae/` — script commands (settings deep links, power menu, reminders, window switcher, kill process, SSH hosts, recent files, color picker), and one compiled extension that adds web search with live suggestions. File search, calculator, clipboard, and emoji are Vicinae's own |
|
||||
| `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 |
|
||||
|
||||
New machines get a welcome card on first start, teaching the handful of keys
|
||||
that matter and reachable afterwards from the launcher. `SUPER + /` shows every
|
||||
shortcut the machine has, read from the live keymap so a rebind appears there
|
||||
without anything being kept in sync. Settings carries a
|
||||
[manual](config/dot/quickshell/manual/) written for the person using the
|
||||
desktop rather than the person building it, opening with a chapter for people
|
||||
arriving from GNOME, macOS or Windows.
|
||||
|
||||
Laptops work: battery, charge limits, idle timings that differ on battery, and
|
||||
a closed lid that suspends when you are leaving and keeps working when you are
|
||||
docked. A desktop sees none of it, because every one of those surfaces hides
|
||||
when the hardware is absent.
|
||||
|
||||
**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.
|
||||
@@ -50,13 +112,115 @@ Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland".
|
||||
## Layout
|
||||
|
||||
```
|
||||
bin/ Small user-facing commands on PATH
|
||||
bin/ Small user-facing commands on PATH; `panama` is the entry point
|
||||
migrations/ One repair per file, for machines installed before a change
|
||||
config/
|
||||
bash/ .bashrc, aliases, env (env is gitignored)
|
||||
copy/ Files copied verbatim over / (needs sudo)
|
||||
dot/ Symlinked into ~/.config
|
||||
firefox/ Vendored Firefox chrome, linked into the browser profile
|
||||
containers/ Quadlets, linked into ~/.config/containers/systemd
|
||||
local/ Icons, the cursor theme, and the launcher's commands and
|
||||
extensions, linked into ~/.local/share
|
||||
old/ Backups of whatever was replaced (gitignored)
|
||||
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
|
||||
setup/
|
||||
packages/ One package per line
|
||||
apps/ Applications built from source, one file each
|
||||
lib/ Shared by more than one stage; the extras catalog reader
|
||||
packages/ One package per line; extras/ holds the optional categories
|
||||
scripts/ Run in order by ./install
|
||||
tests/ Contracts. See below
|
||||
manual/ The user manual, rendered in Settings
|
||||
docs/ Settings reference, and the design specs behind the work
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
156 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||
|
||||
```sh
|
||||
panama test # everything
|
||||
panama test dock # just the ones matching "dock"
|
||||
tests/setup/interview-contract # or one directly; they are plain executables
|
||||
```
|
||||
|
||||
They are called contracts rather than unit tests because that is what they are:
|
||||
each one pins a decision that was expensive to get right and is cheap to undo by
|
||||
accident. Most read or measure the real thing — launching a shell to measure a
|
||||
surface's geometry, standing stub commands on `PATH` to see what a stage would
|
||||
have installed, running a script against a throwaway `HOME` — rather than
|
||||
asserting things about source text, because the bugs worth catching here have all
|
||||
been ones that source text looked fine for.
|
||||
|
||||
```
|
||||
tests/setup/ The installer: the interview, package lists, hardware, extras
|
||||
tests/quickshell/ The shell and its settings pages
|
||||
tests/hypr/ The compositor config
|
||||
```
|
||||
|
||||
## Projects
|
||||
|
||||
A project is the set of windows you open together — which applications, which
|
||||
workspace each was on, and for a terminal, which directory it was sitting in.
|
||||
Arrange the desktop, then run **Save Layout as Project** from the launcher and
|
||||
name it; **Open Project** lays it out again.
|
||||
|
||||
Workspaces are recorded as positions rather than numbers, and opening a project
|
||||
claims free ones, so it never lands on top of what you are already doing. An
|
||||
application that refuses to open twice — Slack, Thunderbird, the browser — is
|
||||
moved into place rather than launched again. Saved layouts are listed on the
|
||||
Desktop settings page, which is also where they are removed.
|
||||
|
||||
## The `panama` command
|
||||
|
||||
```sh
|
||||
panama update # review, commit and sync this repo
|
||||
panama edit # open it in Neovim
|
||||
panama doctor # what is actually running, not what was installed
|
||||
panama test # every contract, or a subset by pattern
|
||||
panama upgrade # re-run ./install from anywhere
|
||||
panama apps # choose applications to install, by category
|
||||
panama app # applications no repository carries; build one by name
|
||||
```
|
||||
|
||||
`panama apps` is the optional-application catalog, opened after the fact. The
|
||||
interview offers the same categories during `./install`, whole; this picks a
|
||||
category and then the applications inside it, so a machine can acquire Slack in
|
||||
March without having wanted Discord in January. Both read
|
||||
`setup/lib/extras-catalog`, so the two cannot describe different catalogues.
|
||||
|
||||
A category is one file under `setup/packages/extras/`. A bare line is a dnf
|
||||
package, a `flatpak:` line is a Flathub id, `| Name` gives the menu something
|
||||
readable, and an indented line belongs to the entry above it — which is how OBS
|
||||
carries its sixteen plugin extensions as one thing to tick.
|
||||
|
||||
Hooks are the extension point: drop a script at `~/.config/panama/hooks/theme-set`
|
||||
and it runs whenever the colour scheme changes, with the scheme and accent as
|
||||
arguments. Same for `post-upgrade` and `post-migrate`, and a `<name>.d/`
|
||||
directory beside each so several things can react without fighting over one
|
||||
file. A broken hook is reported and stepped over, never fatal. Samples are
|
||||
copied into place on install.
|
||||
|
||||
`panama migrate` applies repairs an installed machine has not had yet. Safe to
|
||||
re-run: nothing is applied twice, and a machine with nothing waiting says so.
|
||||
|
||||
`panama-sudo` is pkexec with a stated reason: `panama-sudo --reason "why" --
|
||||
command` shows the reason on Panama's password prompt, clearly labeled as an
|
||||
unverified claim beside polkitd's own action text — meant for agents and
|
||||
scripts, so the person typing the password learns why before they do. Without
|
||||
a reason, a running shell, or `qs` it behaves exactly like pkexec.
|
||||
|
||||
`panama app` is deliberately not part of `./install`. Everything else Panama
|
||||
installs comes from dnf or Flathub; these are built from source because no
|
||||
packaged form exists, and a source build is slow, wants the network throughout,
|
||||
and depends on an upstream that moves. That is the failure the interview exists
|
||||
to prevent, so asking for one is something you do on purpose — and it is also
|
||||
how you rebuild when a new version ships. Nothing is pinned: each build takes
|
||||
the current upstream and reports a failure rather than working around it.
|
||||
|
||||
Adding one is adding a file to `setup/apps/`, and the file has to say why the
|
||||
exception exists.
|
||||
|
||||
None of the scripts in this repository carry a `.sh` extension. A shebang and
|
||||
the executable bit already select the interpreter, and the extension only
|
||||
becomes something to keep in sync — which it did not stay.
|
||||
|
||||
@@ -152,11 +152,14 @@ case "$cmd" in
|
||||
|
||||
# 2.4) Run flatpak updates (user then system)
|
||||
flatpak update -y
|
||||
sudo flatpak update
|
||||
sudo flatpak update -y
|
||||
|
||||
# 2.5) Optional firmware via fwupd
|
||||
if $firmware; then
|
||||
sudo fwupdmgr refresh
|
||||
# fwupdmgr exits non-zero when metadata is already current -- that is
|
||||
# not an error, but under 'set -e' it would abort the script before
|
||||
# 'fwupdmgr update' ever runs.
|
||||
sudo fwupdmgr refresh || true
|
||||
sudo fwupdmgr update
|
||||
fi
|
||||
|
||||
|
||||
+333
@@ -7,6 +7,11 @@
|
||||
# Commands:
|
||||
# update Commit & sync local changes (or just pull if clean)
|
||||
# edit Open the Panama repo in Neovim
|
||||
# doctor Report what is actually running on this machine
|
||||
# test Run every contract under tests/
|
||||
# upgrade Re-run the installer from anywhere
|
||||
# apps Choose applications to install, by category
|
||||
# app Build and install an application that no repository packages
|
||||
# help Show this help
|
||||
#
|
||||
# Designed to grow: add new subcommands as cmd_<name> functions and
|
||||
@@ -62,6 +67,20 @@ ${BOLD}Commands:${RESET}
|
||||
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
|
||||
clean it simply runs 'git pull'.
|
||||
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
||||
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
||||
than what was installed. Takes --summary for one line per check.
|
||||
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
||||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||||
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
|
||||
idempotent and this is the documented upgrade path.
|
||||
${GREEN}migrate${RESET} Apply repairs this machine has not had yet. The half of an
|
||||
upgrade that ./install cannot do, because installing only ever
|
||||
adds. Safe to re-run; nothing is applied twice.
|
||||
${GREEN}apps${RESET} Choose applications to install: pick a category, then tick
|
||||
what you want. The same catalog ./install offers, minus the
|
||||
install.
|
||||
${GREEN}app${RESET} Build and install an application that neither dnf nor
|
||||
Flathub carries. With no name, lists what is available.
|
||||
${GREEN}help${RESET} Show this help (also -h, --help).
|
||||
|
||||
${BOLD}Options:${RESET}
|
||||
@@ -71,6 +90,12 @@ ${BOLD}Options:${RESET}
|
||||
${BOLD}Examples:${RESET}
|
||||
$PROGRAM update
|
||||
$PROGRAM edit
|
||||
$PROGRAM doctor --summary
|
||||
$PROGRAM test dock
|
||||
$PROGRAM upgrade
|
||||
$PROGRAM apps
|
||||
$PROGRAM app
|
||||
$PROGRAM app chatgpt-desktop
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -205,6 +230,308 @@ cmd_edit() {
|
||||
exec nvim .
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Command: doctor
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# The health check already exists and the installer already runs it; what it did
|
||||
# not have was a way to reach it from a terminal. Everything is passed straight
|
||||
# through, so --summary and anything added later work without this knowing about
|
||||
# them.
|
||||
cmd_doctor() {
|
||||
local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
|
||||
if [[ ! -x "$doctor" ]]; then
|
||||
err "panama-doctor is missing from $doctor"
|
||||
exit 1
|
||||
fi
|
||||
exec "$doctor" "$@"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Command: test
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# The contracts are the main safety net in this repository and had no entry
|
||||
# point: 121 executables with no runner and no mention in the README, which is
|
||||
# most of the way to not having them.
|
||||
#
|
||||
# Each runs in its own process and a failure does not stop the rest, because the
|
||||
# useful output is the whole list of what is broken rather than the first thing
|
||||
# that broke. The exit code is what a caller can act on.
|
||||
cmd_test() {
|
||||
local pattern="${1:-}"
|
||||
local -a suite=()
|
||||
|
||||
# Executables, plus the Python suites. Those are unittest files rather than
|
||||
# executables, and collecting only what has the executable bit would skip them
|
||||
# without saying so -- which is how all three came to be run by nothing at all.
|
||||
# A runner with a blind spot is worse than no runner, because it reports PASS.
|
||||
while IFS= read -r path; do
|
||||
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
||||
[[ -z "$pattern" || "$path" == *"$pattern"* ]] && suite+=("$path")
|
||||
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||
|
||||
if (( ${#suite[@]} == 0 )); then
|
||||
err "No contracts match '${pattern}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Running ${#suite[@]} contract(s)"
|
||||
local -a failed=()
|
||||
local path name
|
||||
local -a runner
|
||||
for path in "${suite[@]}"; do
|
||||
name="${path#"$PANAMA_DIR"/tests/}"
|
||||
if [[ "$path" == *_test.py ]]; then
|
||||
runner=(python3 "$path")
|
||||
else
|
||||
runner=("$path")
|
||||
fi
|
||||
if "${runner[@]}" >/dev/null 2>&1; then
|
||||
ok "$name"
|
||||
else
|
||||
err "$name"
|
||||
failed+=("$name")
|
||||
fi
|
||||
done
|
||||
|
||||
header "Result"
|
||||
if (( ${#failed[@]} == 0 )); then
|
||||
ok "${#suite[@]} contract(s) passed"
|
||||
return 0
|
||||
fi
|
||||
err "${#failed[@]} of ${#suite[@]} failed:"
|
||||
printf ' %s\n' "${failed[@]}" >&2
|
||||
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Command: upgrade
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# ./install is the upgrade path -- every stage is idempotent and re-running is
|
||||
# the documented way to repair a machine. This only saves remembering where the
|
||||
# repository lives.
|
||||
cmd_upgrade() {
|
||||
local installer="$PANAMA_DIR/install"
|
||||
if [[ ! -x "$installer" ]]; then
|
||||
err "The installer is missing from $installer"
|
||||
exit 1
|
||||
fi
|
||||
info "Re-running ${BOLD}${installer}${RESET}"
|
||||
cd "$PANAMA_DIR"
|
||||
exec "$installer" "$@"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Command: migrate
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# What ./install cannot do. The installer only ever adds -- it copies over /,
|
||||
# links dotfiles, installs packages -- so a machine set up months ago keeps
|
||||
# whatever this repository has since decided was wrong. Migrations are the one
|
||||
# mechanism that can remove a file, disable a unit, or repair a symlink on a
|
||||
# machine that already exists. See bin/panama-migrate.
|
||||
cmd_migrate() {
|
||||
local runner="$PANAMA_DIR/bin/panama-migrate"
|
||||
if [[ ! -x "$runner" ]]; then
|
||||
err "The migration runner is missing from $runner"
|
||||
exit 1
|
||||
fi
|
||||
exec "$runner" "$@"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Command: app
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# The applications that neither dnf nor Flathub carries, built from source into
|
||||
# a package dnf can still own and remove.
|
||||
#
|
||||
# Deliberately NOT part of ./install. A source build is slow, wants the network
|
||||
# for the whole of it, and depends on an upstream that moves -- which is exactly
|
||||
# the failure the interview exists to prevent: twenty minutes in, a prompt or an
|
||||
# error, with nobody at the keyboard. Asking for one of these is a thing you do
|
||||
# on purpose, and it is also the rebuild path when a new version ships.
|
||||
#
|
||||
# Nothing is pinned. Each build takes the current default branch and the current
|
||||
# upstream release, and says so when it fails. A recorded version is a 404
|
||||
# waiting to happen -- sunhat proved that three times over.
|
||||
APPS_DIR="$PANAMA_DIR/setup/apps"
|
||||
APPS_WORK="${XDG_CACHE_HOME:-$HOME/.cache}/panama/apps"
|
||||
|
||||
cmd_app() {
|
||||
local name="${1:-}"
|
||||
|
||||
if [[ -z "$name" ]]; then
|
||||
header "Applications"
|
||||
printf 'Built from source, because no repository carries them.\n\n'
|
||||
local file
|
||||
for file in "$APPS_DIR"/*; do
|
||||
[[ -f "$file" ]] || continue
|
||||
local description=""
|
||||
# shellcheck source=/dev/null
|
||||
source "$file"
|
||||
printf ' %s%-18s%s %s\n' "$GREEN" "$(basename "$file")" "$RESET" "$description"
|
||||
done
|
||||
printf '\nBuild one with: %s%s app <name>%s\n' "$BOLD" "$PROGRAM" "$RESET"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local definition="$APPS_DIR/$name"
|
||||
if [[ ! -f "$definition" ]]; then
|
||||
err "No such application: '$name'"
|
||||
printf "Run '%s app' to see what is available.\n" "$PROGRAM" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local description="" repo=""
|
||||
# shellcheck source=/dev/null
|
||||
source "$definition"
|
||||
[[ -n "$repo" ]] || { err "$name declares no repository"; exit 1; }
|
||||
|
||||
# The checkout lives in the cache because it is entirely rebuildable and
|
||||
# should never be mistaken for something to keep. Existing checkouts are
|
||||
# reset to upstream rather than merged: a local edit in a build tree is not
|
||||
# something to preserve silently.
|
||||
local tree="$APPS_WORK/$name"
|
||||
if [[ -d "$tree/.git" ]]; then
|
||||
info "Updating $name"
|
||||
git -C "$tree" fetch --depth 1 origin HEAD || { err "Could not reach $repo"; exit 1; }
|
||||
git -C "$tree" reset --hard FETCH_HEAD >/dev/null
|
||||
else
|
||||
info "Cloning $name"
|
||||
mkdir -p "$APPS_WORK"
|
||||
rm -rf "$tree"
|
||||
git clone --depth 1 "$repo" "$tree" || { err "Could not clone $repo"; exit 1; }
|
||||
fi
|
||||
|
||||
info "Building ${BOLD}${name}${RESET} — this takes a while and needs the network"
|
||||
if ( cd "$tree" && build ); then
|
||||
ok "$name installed"
|
||||
printf 'Built from %s\n' "$(git -C "$tree" rev-parse --short HEAD)"
|
||||
else
|
||||
err "$name failed to build"
|
||||
printf 'The tree is left at %s so the failure can be read.\n' "$tree" >&2
|
||||
printf 'This builds against upstream HEAD, so a break there breaks this.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Command: apps
|
||||
# ----------------------------------------------------------------------------
|
||||
# The optional applications, chosen a few at a time rather than all at once.
|
||||
#
|
||||
# ./install offers the same catalog as whole categories, because during a first
|
||||
# install you want coarse and fast. This is the other end of it: pick a
|
||||
# category, then tick the applications inside, and install just those. Both read
|
||||
# setup/lib/extras-catalog, so neither can drift from the other.
|
||||
#
|
||||
# Source-built applications appear here too, as their own category. They are not
|
||||
# part of ./install for good reason -- a build is slow, wants the network
|
||||
# throughout, and can fail on an upstream that moved -- but "choose, then
|
||||
# install" is exactly the right shape for them, which is what this is.
|
||||
cmd_apps() {
|
||||
if ! command -v gum >/dev/null 2>&1; then
|
||||
err "gum is not installed. It is in setup/packages/initial-packages; run 'panama upgrade'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=../setup/lib/extras-catalog
|
||||
source "$PANAMA_DIR/setup/lib/extras-catalog"
|
||||
|
||||
local extras_dir="$PANAMA_DIR/setup/packages/extras"
|
||||
local apps_dir="$PANAMA_DIR/setup/apps"
|
||||
|
||||
local -a categories=()
|
||||
mapfile -t categories < <(catalog_categories "$extras_dir")
|
||||
[[ -d "$apps_dir" ]] && categories+=("built from source")
|
||||
|
||||
(( ${#categories[@]} > 0 )) || { err "No application categories found."; exit 1; }
|
||||
|
||||
local category
|
||||
category="$(gum choose --header "Which kind of application?" "${categories[@]}")" || return 0
|
||||
[[ -n "$category" ]] || return 0
|
||||
|
||||
if [[ "$category" == "built from source" ]]; then
|
||||
cmd_apps_source "$apps_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
local file="$extras_dir/$category"
|
||||
local -a labels=() targets=()
|
||||
local target label marker
|
||||
while IFS=$'\t' read -r target label; do
|
||||
[[ -n "$target" ]] || continue
|
||||
# Marked, not hidden: reinstalling what is present is harmless, but a menu
|
||||
# that silently omits it leaves you wondering where it went.
|
||||
if catalog_installed "$target"; then marker=" (installed)"; else marker=""; fi
|
||||
targets+=("$target")
|
||||
labels+=("$label$marker")
|
||||
done < <(catalog_entries "$file")
|
||||
|
||||
local chosen
|
||||
chosen="$(gum choose --no-limit --header "$category — space to select, enter to accept" "${labels[@]}")" || return 0
|
||||
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
|
||||
|
||||
# Back from labels to install targets, then out to everything each one carries.
|
||||
local -a install_targets=()
|
||||
local pick i
|
||||
while IFS= read -r pick; do
|
||||
[[ -n "$pick" ]] || continue
|
||||
for i in "${!labels[@]}"; do
|
||||
if [[ "${labels[$i]}" == "$pick" ]]; then
|
||||
mapfile -t -O "${#install_targets[@]}" install_targets < <(catalog_targets "$file" "${targets[$i]}")
|
||||
break
|
||||
fi
|
||||
done
|
||||
done <<< "$chosen"
|
||||
|
||||
local -a dnf_packages=() flatpak_ids=()
|
||||
for target in "${install_targets[@]}"; do
|
||||
if [[ "$target" == flatpak:* ]]; then flatpak_ids+=("${target#flatpak:}"); else dnf_packages+=("$target"); fi
|
||||
done
|
||||
|
||||
header "About to install"
|
||||
(( ${#dnf_packages[@]} > 0 )) && printf ' dnf %s\n' "${dnf_packages[*]}"
|
||||
(( ${#flatpak_ids[@]} > 0 )) && printf ' flatpak %s\n' "${flatpak_ids[*]}"
|
||||
echo
|
||||
confirm "Install these?" || { warn "Nothing installed."; return 0; }
|
||||
|
||||
if (( ${#dnf_packages[@]} > 0 )); then
|
||||
info "Installing ${#dnf_packages[@]} package(s) with dnf"
|
||||
sudo dnf install -y "${dnf_packages[@]}" || warn "Some packages did not install"
|
||||
fi
|
||||
if (( ${#flatpak_ids[@]} > 0 )); then
|
||||
info "Installing ${#flatpak_ids[@]} flatpak(s)"
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo >/dev/null
|
||||
sudo flatpak install -y flathub "${flatpak_ids[@]}" || warn "Some flatpaks did not install"
|
||||
fi
|
||||
ok "Done."
|
||||
}
|
||||
|
||||
# The source-built applications, offered by the same two-step flow and handed to
|
||||
# the existing `panama app` so there is one build path, not two.
|
||||
cmd_apps_source() {
|
||||
local apps_dir="$1"
|
||||
local -a names=()
|
||||
mapfile -t names < <(for f in "$apps_dir"/*; do [[ -f "$f" ]] && basename "$f"; done)
|
||||
(( ${#names[@]} > 0 )) || { err "No applications in $apps_dir."; return 1; }
|
||||
|
||||
local chosen
|
||||
chosen="$(gum choose --no-limit --header "Built from source — these take a while" "${names[@]}")" || return 0
|
||||
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
|
||||
|
||||
local name
|
||||
while IFS= read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
header "Building $name"
|
||||
cmd_app "$name" || warn "$name did not build"
|
||||
done <<< "$chosen"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ----------------------------------------------------------------------------
|
||||
@@ -213,6 +540,12 @@ main() {
|
||||
case "$cmd" in
|
||||
update) shift; cmd_update "$@" ;;
|
||||
edit) shift; cmd_edit "$@" ;;
|
||||
doctor) shift; cmd_doctor "$@" ;;
|
||||
test) shift; cmd_test "$@" ;;
|
||||
upgrade) shift; cmd_upgrade "$@" ;;
|
||||
migrate) shift; cmd_migrate "$@" ;;
|
||||
app) shift; cmd_app "$@" ;;
|
||||
apps) shift; cmd_apps "$@" ;;
|
||||
help|-h|--help|"") usage ;;
|
||||
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
|
||||
*)
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Tell somebody when a program crashes.
|
||||
#
|
||||
# On GNOME, ABRT says so. Under a hand-assembled Hyprland desktop nothing does,
|
||||
# and applications die silently -- which is most of how "Linux is flaky" gets
|
||||
# earned. Fedora ships systemd-coredump by default, so the information is
|
||||
# already there; nobody is reading it.
|
||||
#
|
||||
# Follows the journal for systemd-coredump's own message id and reports each
|
||||
# program once per session.
|
||||
#
|
||||
# ONCE PER SESSION IS THE WHOLE DESIGN. This machine's portal backend crashes
|
||||
# between eleven and sixty times a day -- see the portal-stability check in
|
||||
# panama-doctor -- and a notification per crash would be a notification every
|
||||
# few minutes for something the user can do nothing about. The first one is
|
||||
# news; the fortieth is why people turn notifications off. The health page
|
||||
# carries the running count for anyone who wants it.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
|
||||
# systemd-coredump's MESSAGE_ID. Matching on this rather than on text keeps
|
||||
# working when the wording changes and never matches a program that merely
|
||||
# mentions the word "crash" in its own logs.
|
||||
readonly COREDUMP_MESSAGE_ID='fc2e22bc6ee647b6b90729ab34a250b1'
|
||||
|
||||
command -v journalctl >/dev/null 2>&1 || exit 0
|
||||
command -v notify-send >/dev/null 2>&1 || exit 0
|
||||
|
||||
# The shell owns org.freedesktop.Notifications, and the crash most worth
|
||||
# reporting is the one that took the shell with it. Waiting means that report
|
||||
# arrives rather than vanishing into a bus nobody is serving.
|
||||
for _ in $(seq 1 60); do
|
||||
busctl --user status org.freedesktop.Notifications >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
declare -A reported=()
|
||||
|
||||
# -f from now, not from the boot: a session that starts after a crash should
|
||||
# not open with a notification about something the user has already lived
|
||||
# through and cannot act on.
|
||||
journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev/null \
|
||||
| while IFS= read -r line; do
|
||||
[[ -n "$line" ]] || continue
|
||||
|
||||
uid="$(jq -r '.COREDUMP_UID // empty' <<<"$line" 2>/dev/null)"
|
||||
exe="$(jq -r '.COREDUMP_EXE // empty' <<<"$line" 2>/dev/null)"
|
||||
comm="$(jq -r '.COREDUMP_COMM // empty' <<<"$line" 2>/dev/null)"
|
||||
|
||||
# Another user's crash is not this session's business, and reporting it
|
||||
# would leak what they are running.
|
||||
[[ "$uid" == "$(id -u)" ]] || continue
|
||||
[[ -n "$exe" || -n "$comm" ]] || continue
|
||||
|
||||
# The executable name first: COREDUMP_COMM is the kernel's comm field
|
||||
# and is truncated to fifteen characters, so it reports
|
||||
# "panama-test-cra" for a program called panama-test-crasher.
|
||||
if [[ -n "$exe" ]]; then
|
||||
program="$(basename "$exe")"
|
||||
else
|
||||
program="$comm"
|
||||
fi
|
||||
[[ -z "${reported[$program]:-}" ]] || continue
|
||||
reported[$program]=1
|
||||
|
||||
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
||||
"$program stopped unexpectedly" \
|
||||
"It crashed and was not able to recover. System Health has the details." \
|
||||
2>/dev/null || true
|
||||
done
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Stamps a new migration, so the name is never chosen by hand.
|
||||
#
|
||||
# panama-dev-migration "remove the stale settings-ssh-keys.sh launcher command"
|
||||
#
|
||||
# The filename is the commit timestamp of HEAD, which makes glob order
|
||||
# chronological without a sequence number that two branches could pick at the
|
||||
# same time. Two migrations authored against the same commit would collide, so
|
||||
# a taken name gets the next free second rather than silently overwriting.
|
||||
#
|
||||
# Developer tool, not part of any install path. See bin/panama-migrate for what
|
||||
# runs these and what rules they have to follow.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
MIGRATIONS_DIR="$PANAMA_PATH/migrations"
|
||||
|
||||
describe="${1:-}"
|
||||
if [[ -z "$describe" ]]; then
|
||||
echo 'usage: panama-dev-migration "what this repairs"' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
stamp="$(git -C "$PANAMA_PATH" log -1 --format=%cd --date=unix 2>/dev/null || date +%s)"
|
||||
mkdir -p "$MIGRATIONS_DIR"
|
||||
while [[ -e "$MIGRATIONS_DIR/$stamp.sh" ]]; do
|
||||
stamp=$(( stamp + 1 ))
|
||||
done
|
||||
file="$MIGRATIONS_DIR/$stamp.sh"
|
||||
|
||||
cat >"$file" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# $describe
|
||||
#
|
||||
# Rules, because the runner cannot enforce them:
|
||||
#
|
||||
# * Safe to run twice. The marker records success, not intent.
|
||||
# * Tolerant of the repair already being correct -- the user may have fixed
|
||||
# it by hand, or a later ./install may have put it back.
|
||||
# * Root work goes through \`panama-sudo --reason "..."\`, never bare sudo,
|
||||
# so the password prompt names the repair.
|
||||
# * Exit non-zero to be retried at the next login. Exit zero only when the
|
||||
# machine is genuinely in the state this describes.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="\${PANAMA_PATH:-\$HOME/.local/share/Panama}"
|
||||
|
||||
# ... the repair goes here.
|
||||
EOF
|
||||
|
||||
chmod +x "$file"
|
||||
printf 'Created %s\n' "$file"
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The pressure valve.
|
||||
#
|
||||
# panama-hook theme-set dark orchid
|
||||
#
|
||||
# Runs ~/.config/panama/hooks/<name> and everything executable in
|
||||
# ~/.config/panama/hooks/<name>.d/, in sorted order, with the hook's arguments.
|
||||
#
|
||||
# This exists so "can Panama also do X when the theme changes" is a five-line
|
||||
# file somebody drops in a directory rather than a fork, a feature request, or
|
||||
# a patch that has to be rebased forever. docs/UPSTREAM-INSPIRATION.md defers a
|
||||
# plugin host as premature and still should: this is the thirty-line version
|
||||
# that covers most of what people actually want from one, and it has no API to
|
||||
# keep stable beyond "we will run your script and tell you what happened".
|
||||
#
|
||||
# A failing hook is reported and stepped over. Somebody's broken script must
|
||||
# never break a theme change, an upgrade, or a login -- which is exactly what
|
||||
# would happen if this used `set -e` and the caller did too.
|
||||
#
|
||||
# Hooks run synchronously, so a slow one delays whatever called it. That is
|
||||
# deliberate: the alternative is a hook whose output arrives after the thing it
|
||||
# was reacting to has already finished, which is harder to reason about than a
|
||||
# pause.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HOOK_DIR="${PANAMA_HOOK_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks}"
|
||||
|
||||
name="${1:-}"
|
||||
if [[ -z "$name" ]]; then
|
||||
echo 'usage: panama-hook <name> [args...]' >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
# A hook name reaches the filesystem, so it cannot be allowed to leave the
|
||||
# directory. Callers are all in-repo today, which is exactly when this is
|
||||
# cheap to add and easy to forget.
|
||||
if [[ ! "$name" =~ ^[a-z][a-z0-9-]*$ ]]; then
|
||||
echo "panama-hook: refusing hook name: $name" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
run_one() {
|
||||
local script="$1"
|
||||
# Shifted off before the arguments are forwarded, or every hook receives
|
||||
# its own path as $1 and the real arguments arrive one place late.
|
||||
shift
|
||||
[[ -f "$script" && -x "$script" ]] || return 0
|
||||
if ! "$script" "$@"; then
|
||||
printf 'panama-hook: %s failed (%s); continuing\n' \
|
||||
"$(basename "$script")" "$name" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# The single file first, then the .d directory in sorted order. Both are
|
||||
# optional and having neither is the normal case.
|
||||
run_one "$HOOK_DIR/$name" "$@"
|
||||
|
||||
if [[ -d "$HOOK_DIR/$name.d" ]]; then
|
||||
while IFS= read -r script; do
|
||||
[[ -n "$script" ]] || continue
|
||||
run_one "$script" "$@"
|
||||
done < <(find "$HOOK_DIR/$name.d" -maxdepth 1 -type f | sort)
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# What this machine is, asked one yes-or-no question at a time.
|
||||
#
|
||||
# panama-hw laptop && echo "portable"
|
||||
# panama-hw clamshell && panama-lid close
|
||||
#
|
||||
# Every subcommand exits 0 for yes and 1 for no, prints nothing, and answers
|
||||
# correctly on a machine that has none of the hardware in question. That last
|
||||
# part is the whole point: a desktop must be able to ask "am I in clamshell
|
||||
# mode" and get a calm no rather than an error, because the scripts and
|
||||
# services that ask are shared between machines.
|
||||
#
|
||||
# `--json` answers everything at once, for the health page and for contracts.
|
||||
#
|
||||
# Detection reads sysfs directly rather than shelling out to lspci or upower:
|
||||
# lspci touches PCI config space and wakes a runtime-suspended GPU, which is a
|
||||
# real cost to pay for a question asked at every login.
|
||||
#
|
||||
# Paths are overridable (PANAMA_HW_SYS, PANAMA_HW_ACPI) so the contract can
|
||||
# drive fixture trees. Nothing else should set them.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SYS="${PANAMA_HW_SYS:-/sys}"
|
||||
ACPI="${PANAMA_HW_ACPI:-/proc/acpi}"
|
||||
|
||||
# SMBIOS chassis types that mean "carried around": Portable, Laptop, Notebook,
|
||||
# Hand Held, Sub Notebook, Tablet, Convertible, Detachable. A machine that
|
||||
# reports something else, or reports nothing, is treated as stationary --
|
||||
# guessing "laptop" on an unknown chassis would put battery chrome on a desktop.
|
||||
readonly PORTABLE_CHASSIS=" 8 9 10 11 14 30 31 32 "
|
||||
|
||||
is_laptop() {
|
||||
local type_file="$SYS/class/dmi/id/chassis_type" chassis
|
||||
[[ -r "$type_file" ]] || return 1
|
||||
chassis="$(cat "$type_file" 2>/dev/null)" || return 1
|
||||
[[ "$PORTABLE_CHASSIS" == *" $chassis "* ]]
|
||||
}
|
||||
|
||||
# The first battery, or nothing. Named rather than assumed to be BAT0: the
|
||||
# second battery in a ThinkPad is BAT1, and a machine with only BAT1 exists.
|
||||
battery_path() {
|
||||
local supply type
|
||||
for supply in "$SYS"/class/power_supply/*; do
|
||||
[[ -r "$supply/type" ]] || continue
|
||||
type="$(cat "$supply/type" 2>/dev/null)"
|
||||
if [[ "$type" == "Battery" ]]; then
|
||||
printf '%s\n' "$supply"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_battery() { battery_path >/dev/null; }
|
||||
|
||||
# On wall power. A machine with no mains supply at all is a desktop, and a
|
||||
# desktop is always on wall power -- answering "no" there would make every
|
||||
# battery-aware timing apply to a machine that cannot run out of power.
|
||||
on_ac() {
|
||||
local supply type online found=1
|
||||
for supply in "$SYS"/class/power_supply/*; do
|
||||
[[ -r "$supply/type" ]] || continue
|
||||
type="$(cat "$supply/type" 2>/dev/null)"
|
||||
[[ "$type" == "Mains" ]] || continue
|
||||
found=0
|
||||
online="$(cat "$supply/online" 2>/dev/null || echo 0)"
|
||||
[[ "$online" == "1" ]] && return 0
|
||||
done
|
||||
# Mains exists and none of it is online: genuinely on battery.
|
||||
(( found == 0 )) && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
lid_closed() {
|
||||
local state
|
||||
for state in "$ACPI"/button/lid/*/state; do
|
||||
[[ -r "$state" ]] || continue
|
||||
grep -qi closed "$state" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# A connected output that is not the built-in panel. eDP, LVDS and DSI are the
|
||||
# internal ones; everything else arrived through a cable.
|
||||
has_external_monitor() {
|
||||
local status connector
|
||||
for status in "$SYS"/class/drm/card*-*/status; do
|
||||
[[ -r "$status" ]] || continue
|
||||
[[ "$(cat "$status" 2>/dev/null)" == "connected" ]] || continue
|
||||
connector="$(basename "$(dirname "$status")")"
|
||||
case "$connector" in
|
||||
*eDP*|*LVDS*|*DSI*) continue ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The one definition the rest of the laptop work hangs on: the lid is shut and
|
||||
# there is still a screen to use. Closing the lid on a dock must not suspend;
|
||||
# closing it on a train must.
|
||||
is_clamshell() { lid_closed && has_external_monitor; }
|
||||
|
||||
has_touchpad() {
|
||||
local name
|
||||
for name in "$SYS"/class/input/*/name; do
|
||||
[[ -r "$name" ]] || continue
|
||||
grep -qi touchpad "$name" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Vendor 0x10de on a display-class device. Read from sysfs rather than lspci
|
||||
# so an idle discrete GPU is not woken to answer.
|
||||
has_nvidia() {
|
||||
local device vendor class
|
||||
for device in "$SYS"/bus/pci/devices/*; do
|
||||
[[ -r "$device/vendor" && -r "$device/class" ]] || continue
|
||||
vendor="$(cat "$device/vendor" 2>/dev/null)"
|
||||
[[ "$vendor" == "0x10de" ]] || continue
|
||||
class="$(cat "$device/class" 2>/dev/null)"
|
||||
[[ "$class" == 0x03* ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
answer() { "$1" && printf 'true' || printf 'false'; }
|
||||
|
||||
cmd_json() {
|
||||
printf '{"laptop":%s,"battery":%s,"ac":%s,"lidClosed":%s,"externalMonitor":%s,"clamshell":%s,"touchpad":%s,"nvidia":%s}\n' \
|
||||
"$(answer is_laptop)" "$(answer has_battery)" "$(answer on_ac)" \
|
||||
"$(answer lid_closed)" "$(answer has_external_monitor)" \
|
||||
"$(answer is_clamshell)" "$(answer has_touchpad)" "$(answer has_nvidia)"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
laptop) is_laptop ;;
|
||||
battery) has_battery ;;
|
||||
battery-path) battery_path ;;
|
||||
ac) on_ac ;;
|
||||
lid-closed) lid_closed ;;
|
||||
external-monitor) has_external_monitor ;;
|
||||
clamshell) is_clamshell ;;
|
||||
touchpad) has_touchpad ;;
|
||||
nvidia) has_nvidia ;;
|
||||
--json) cmd_json ;;
|
||||
-h|--help|"")
|
||||
cat <<'USAGE'
|
||||
usage: panama-hw <predicate>
|
||||
|
||||
Exits 0 for yes, 1 for no, and prints nothing.
|
||||
|
||||
laptop a portable chassis
|
||||
battery a battery is present
|
||||
battery-path print the first battery's sysfs path (0 if found)
|
||||
ac on wall power (a machine with no mains is always yes)
|
||||
lid-closed the lid is shut
|
||||
external-monitor a connected output that is not the built-in panel
|
||||
clamshell lid shut AND an external monitor: docked, keep working
|
||||
touchpad a touchpad is present
|
||||
nvidia an NVIDIA display device is present
|
||||
|
||||
--json every answer at once
|
||||
USAGE
|
||||
;;
|
||||
*) printf 'panama-hw: unknown predicate: %s\n' "$1" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Focus the window if it is already open; start it if it is not.
|
||||
#
|
||||
# panama-launch --class '^helium$' -- helium-browser-bin
|
||||
# panama-launch --class '^kitty$' --title 'nvim' -- kitty nvim .
|
||||
#
|
||||
# This is what the application keys do on every other desktop. Pressing the
|
||||
# browser key twice on macOS or Windows raises the browser; here it used to
|
||||
# open a second one, which is the single most common "Linux feels wrong"
|
||||
# moment and a twenty-line fix.
|
||||
#
|
||||
# Matching is a regular expression against the window class, optionally
|
||||
# narrowed by title. Both halves matter: the terminal and the editor are both
|
||||
# kitty on this desktop, and only the title tells them apart -- so a class-only
|
||||
# match would make the editor key raise whatever terminal happened to be open.
|
||||
#
|
||||
# Anchor your patterns. `--class mail` would match `gmail-notifier`, and the
|
||||
# key that should open Thunderbird would raise somebody's notifier instead.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
class_pattern=""
|
||||
title_pattern=""
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--class) class_pattern="${2:-}"; shift 2 ;;
|
||||
--title) title_pattern="${2:-}"; shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$class_pattern" || $# -eq 0 ]]; then
|
||||
echo 'usage: panama-launch --class <regex> [--title <regex>] -- command [args...]' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
launch() {
|
||||
# setsid so the application outlives this script and is not a child of the
|
||||
# compositor's exec, which would tie its lifetime to a shell that exits.
|
||||
setsid "$@" >/dev/null 2>&1 &
|
||||
exit 0
|
||||
}
|
||||
|
||||
# No compositor, no window list: just start the thing.
|
||||
command -v hyprctl >/dev/null 2>&1 || launch "$@"
|
||||
|
||||
address="$(hyprctl clients -j 2>/dev/null | jq -r --arg class "$class_pattern" --arg title "$title_pattern" '
|
||||
[ .[]
|
||||
| select(.mapped)
|
||||
| select(.class | test($class))
|
||||
| select($title == "" or (.title | test($title)))
|
||||
]
|
||||
# Most recently focused first: with several matches, raise the one the
|
||||
# user was last in rather than whichever the compositor lists first.
|
||||
| sort_by(-.focusHistoryID)
|
||||
| .[0].address // empty
|
||||
' 2>/dev/null)"
|
||||
|
||||
if [[ -n "$address" ]]; then
|
||||
# Hyprland 0.56 dispatches through Lua: `hyprctl dispatch focuswindow
|
||||
# address:0x...` is parsed as Lua source and fails. The selector string is
|
||||
# what hl.focus accepts; a table of the same fields is refused.
|
||||
exec hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })"
|
||||
fi
|
||||
|
||||
launch "$@"
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Bringing an already-installed machine up to what this checkout expects.
|
||||
#
|
||||
# `./install` is additive: it copies files over `/`, links dotfiles, and
|
||||
# installs packages. It has no way to say "remove that file", "disable that
|
||||
# unit", "that symlink now points at the wrong place". So a machine installed
|
||||
# in March keeps March's mistakes forever, and the only thing that ever fixes
|
||||
# them is the person who happens to read a commit message.
|
||||
#
|
||||
# A migration is one shell script that performs one such repair, exactly once,
|
||||
# on machines that need it.
|
||||
#
|
||||
# migrations/<unix-timestamp>.sh
|
||||
#
|
||||
# The name is the commit timestamp of HEAD when it was authored, so glob order
|
||||
# over fixed-width epoch seconds IS chronological order -- no sequence numbers
|
||||
# to collide on across branches. `panama-dev-migration` stamps them.
|
||||
#
|
||||
# State is one empty marker file per migration under
|
||||
# $XDG_STATE_HOME/panama/migrations. Present means applied. There is no
|
||||
# database and no version integer, because the failure mode of a version
|
||||
# integer is that one bad migration strands every later one behind it.
|
||||
#
|
||||
# The rules a migration must follow are in the template that
|
||||
# `panama-dev-migration` writes, and they are worth repeating here because
|
||||
# this runner cannot enforce them:
|
||||
#
|
||||
# * Safe to run twice. The marker only records that it succeeded once.
|
||||
# * Tolerant of the repair already being correct -- a user may have fixed it
|
||||
# by hand, or a later `./install` may have overwritten it back.
|
||||
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
|
||||
# so the prompt names the repair. See bin/panama-sudo.
|
||||
#
|
||||
# The marker is written ONLY on success, so a migration that fails stays
|
||||
# pending and is retried at the next login. That is deliberate: a repair that
|
||||
# could not complete has not happened, and recording it as done would hide it
|
||||
# forever.
|
||||
#
|
||||
# This mirrors config/dot/quickshell/config/Migrations.qml, which does the same
|
||||
# job for the settings JSON and documents the same reasoning. That one handles
|
||||
# renamed preference keys; this one handles everything else.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
MIGRATIONS_DIR="$PANAMA_PATH/migrations"
|
||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations"
|
||||
|
||||
export PANAMA_PATH
|
||||
|
||||
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; }
|
||||
err() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# Every migration this checkout ships, oldest first. Empty is a valid state.
|
||||
all_migrations() {
|
||||
[[ -d "$MIGRATIONS_DIR" ]] || return 0
|
||||
local file
|
||||
for file in "$MIGRATIONS_DIR"/*.sh; do
|
||||
[[ -e "$file" ]] || continue
|
||||
basename "$file"
|
||||
done | sort
|
||||
}
|
||||
|
||||
pending_migrations() {
|
||||
local name
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
[[ -e "$STATE_DIR/$name" ]] || printf '%s\n' "$name"
|
||||
done < <(all_migrations)
|
||||
}
|
||||
|
||||
run_one() {
|
||||
local name="$1" file="$MIGRATIONS_DIR/$1"
|
||||
info "$name"
|
||||
# A subshell with its own strictness: a migration that forgets `set -e` is
|
||||
# still stopped by its first failing command, and one that sets shell
|
||||
# options cannot leak them into the next migration.
|
||||
if bash -euo pipefail "$file"; then
|
||||
mkdir -p "$STATE_DIR"
|
||||
: >"$STATE_DIR/$name"
|
||||
ok "$name applied"
|
||||
return 0
|
||||
fi
|
||||
err "$name failed and will be retried at the next login"
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
local pending
|
||||
pending="$(pending_migrations)"
|
||||
if [[ -z "$pending" ]]; then
|
||||
ok "Nothing to migrate; this machine matches the checkout."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local count failed=0 name
|
||||
count="$(grep -c . <<<"$pending")"
|
||||
info "$count migration(s) to apply"
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
# Stop at the first failure rather than continuing. Migrations are
|
||||
# ordered, and a later one may assume an earlier one landed; running
|
||||
# it anyway turns one stuck repair into an unpredictable machine.
|
||||
if ! run_one "$name"; then
|
||||
failed=1
|
||||
break
|
||||
fi
|
||||
done <<<"$pending"
|
||||
|
||||
if (( failed )); then
|
||||
warn "Re-running 'panama migrate' is safe and will retry from the failure."
|
||||
return 1
|
||||
fi
|
||||
ok "This machine now matches the checkout."
|
||||
# Only after repairs actually ran: a hook that fires on every login when
|
||||
# there was nothing to do is a hook people disable.
|
||||
"$PANAMA_PATH/bin/panama-hook" post-migrate || true
|
||||
}
|
||||
|
||||
# The check the login notifier runs. Exit 0 means work is waiting, so it reads
|
||||
# as `if panama-migrate --pending; then notify; fi`.
|
||||
cmd_pending() {
|
||||
local pending
|
||||
pending="$(pending_migrations)"
|
||||
[[ -n "$pending" ]] || return 1
|
||||
grep -c . <<<"$pending"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local name
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
if [[ -e "$STATE_DIR/$name" ]]; then
|
||||
printf 'applied %s\n' "$name"
|
||||
else
|
||||
printf 'pending %s\n' "$name"
|
||||
fi
|
||||
done < <(all_migrations)
|
||||
}
|
||||
|
||||
# Re-run one that already succeeded. For developing a migration, and for the
|
||||
# rare case where a repair was undone by something else.
|
||||
cmd_force() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || { err "force needs a migration name"; return 2; }
|
||||
[[ -e "$MIGRATIONS_DIR/$name" ]] || { err "no such migration: $name"; return 2; }
|
||||
rm -f "$STATE_DIR/$name"
|
||||
run_one "$name"
|
||||
}
|
||||
|
||||
# Mark everything applied without running it. This is what a fresh install
|
||||
# does: the machine was just built from this checkout, so every repair those
|
||||
# migrations describe is already true of it, and running them would apply
|
||||
# fixes for versions it never had.
|
||||
cmd_baseline() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
local name count=0
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
[[ -e "$STATE_DIR/$name" ]] && continue
|
||||
: >"$STATE_DIR/$name"
|
||||
count=$(( count + 1 ))
|
||||
done < <(all_migrations)
|
||||
ok "Marked $count migration(s) as already applied."
|
||||
}
|
||||
|
||||
case "${1:-run}" in
|
||||
run) shift || true; cmd_run "$@" ;;
|
||||
--pending) shift || true; cmd_pending "$@" ;;
|
||||
--list|list) shift || true; cmd_list "$@" ;;
|
||||
--force) shift || true; cmd_force "$@" ;;
|
||||
--baseline) shift || true; cmd_baseline "$@" ;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
usage: panama-migrate [run|--pending|--list|--force <name>|--baseline]
|
||||
|
||||
run Apply every pending migration, oldest first (default)
|
||||
--pending Exit 0 and print the count when work is waiting, else exit 1
|
||||
--list Show every migration and whether it has been applied
|
||||
--force Re-run one migration that already succeeded
|
||||
--baseline Mark everything applied without running it (fresh installs)
|
||||
USAGE
|
||||
;;
|
||||
*) err "unknown argument: $1"; exit 2 ;;
|
||||
esac
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# "Panama has repairs waiting for this machine."
|
||||
#
|
||||
# A migration that nobody knows about is a migration that never runs. This
|
||||
# checks once per session and, when work is pending, sends one notification
|
||||
# whose action opens a terminal running `panama migrate` -- so the repair is
|
||||
# always something the user chose, never something that happened to them.
|
||||
#
|
||||
# Waiting for the notification server first is not politeness. Quickshell owns
|
||||
# org.freedesktop.Notifications, and a shell that has not started yet would
|
||||
# swallow this silently -- which is precisely the session where a pending
|
||||
# migration is most likely to matter.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
TERMINAL="${PANAMA_TERMINAL:-kitty}"
|
||||
|
||||
command -v notify-send >/dev/null 2>&1 || exit 1
|
||||
|
||||
count="$("$PANAMA_PATH/bin/panama-migrate" --pending)" || exit 1
|
||||
[[ -n "$count" ]] || exit 1
|
||||
|
||||
# Up to ~15s for the shell to claim the bus name. Longer than a healthy start
|
||||
# needs, short enough that a session without a shell gives up rather than
|
||||
# lingering.
|
||||
for _ in $(seq 1 30); do
|
||||
if busctl --user status org.freedesktop.Notifications >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if (( count == 1 )); then
|
||||
body="One repair is waiting for this machine."
|
||||
else
|
||||
body="$count repairs are waiting for this machine."
|
||||
fi
|
||||
|
||||
# Critical so it waits to be read: a notification that expires while the user
|
||||
# is elsewhere has told nobody anything. The action is the whole point -- there
|
||||
# is no instruction to remember and nothing to type.
|
||||
action="$(notify-send --urgency=critical --icon=system-software-update \
|
||||
--app-name=Panama \
|
||||
--action=migrate="Apply now" --action=later="Later" \
|
||||
"Panama updates" "$body" 2>/dev/null)" || exit 0
|
||||
|
||||
[[ "$action" == "migrate" ]] || exit 0
|
||||
exec "$TERMINAL" --hold "$PANAMA_PATH/bin/panama-migrate" run
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# pkexec, with a stated reason on the password prompt.
|
||||
#
|
||||
# panama-sudo --reason "Installing gamemode hooks" -- dnf install gamemode
|
||||
#
|
||||
# The reason travels to the shell over Quickshell IPC before pkexec runs, and
|
||||
# Panama's prompt shows it clearly labeled beside polkitd's own action message
|
||||
# -- beside, never instead of: anything can claim any reason, so the real
|
||||
# action text stays the trust anchor. Meant for agents and scripts, so the
|
||||
# person at the keyboard learns WHY before typing their password.
|
||||
#
|
||||
# Degrades to plain pkexec: no --reason, no running shell, or no qs on PATH
|
||||
# all behave identically to calling pkexec yourself.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
reason=""
|
||||
if [[ "${1:-}" == "--reason" ]]; then
|
||||
reason="${2:?panama-sudo: --reason needs a value}"
|
||||
shift 2
|
||||
fi
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
|
||||
if (( $# == 0 )); then
|
||||
echo 'usage: panama-sudo [--reason "why"] -- command [args...]' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$reason" ]] && command -v qs >/dev/null 2>&1; then
|
||||
qs ipc call polkit reason "$reason" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
exec pkexec "$@"
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Make a video smaller, or turn a picture into another format.
|
||||
#
|
||||
# panama-transcode video <file> 1080p|720p|480p
|
||||
# panama-transcode image <file> jpg|png|webp
|
||||
#
|
||||
# The two things people actually right-click a media file to do. macOS has
|
||||
# Quick Actions for both; Linux file managers have neither, and the usual
|
||||
# answer is a web uploader or a forgotten ffmpeg incantation.
|
||||
#
|
||||
# Two rules, both about not losing work:
|
||||
#
|
||||
# * The input is never written to. Output goes beside it with a suffix.
|
||||
# * An existing output is never overwritten. The suffix gains a number
|
||||
# rather than replacing something somebody made earlier.
|
||||
#
|
||||
# ffmpeg does the work and is already a declared dependency; this only decides
|
||||
# the arguments, which is the part worth writing down once.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
err() { printf 'panama-transcode: %s\n' "$*" >&2; }
|
||||
|
||||
command -v ffmpeg >/dev/null 2>&1 || { err 'ffmpeg is not installed'; exit 1; }
|
||||
|
||||
# A path that does not exist yet, beside the input.
|
||||
free_path() {
|
||||
local dir="$1" stem="$2" suffix="$3" ext="$4"
|
||||
local candidate="$dir/$stem-$suffix.$ext"
|
||||
local counter=2
|
||||
while [[ -e "$candidate" ]]; do
|
||||
candidate="$dir/$stem-$suffix-$counter.$ext"
|
||||
counter=$(( counter + 1 ))
|
||||
done
|
||||
printf '%s' "$candidate"
|
||||
}
|
||||
|
||||
notify() {
|
||||
command -v notify-send >/dev/null 2>&1 || return 0
|
||||
notify-send --icon="${3:-video-x-generic}" "$1" "$2" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cmd_video() {
|
||||
local input="${1:-}" preset="${2:-1080p}"
|
||||
[[ -f "$input" ]] || { err 'that file does not exist'; return 2; }
|
||||
|
||||
local height
|
||||
case "$preset" in
|
||||
1080p) height=1080 ;;
|
||||
720p) height=720 ;;
|
||||
480p) height=480 ;;
|
||||
*) err "unknown size: $preset"; return 2 ;;
|
||||
esac
|
||||
|
||||
local dir stem output
|
||||
dir="$(dirname "$input")"
|
||||
stem="$(basename "${input%.*}")"
|
||||
output="$(free_path "$dir" "$stem" "$preset" mp4)"
|
||||
|
||||
notify "Transcoding" "$(basename "$input") → $preset" video-x-generic
|
||||
# -2 rather than -1 on width: H.264 needs even dimensions, and an odd one
|
||||
# fails at the very end of a long encode.
|
||||
if ffmpeg -nostdin -loglevel error -i "$input" \
|
||||
-vf "scale=-2:'min($height,ih)'" \
|
||||
-c:v libx264 -crf 23 -preset medium \
|
||||
-c:a aac -b:a 128k \
|
||||
"$output" </dev/null; then
|
||||
notify "Transcoded" "$(basename "$output")" video-x-generic
|
||||
printf '%s\n' "$output"
|
||||
else
|
||||
rm -f "$output"
|
||||
notify "Transcode failed" "$(basename "$input")" dialog-error-symbolic
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_image() {
|
||||
local input="${1:-}" format="${2:-jpg}"
|
||||
[[ -f "$input" ]] || { err 'that file does not exist'; return 2; }
|
||||
|
||||
case "$format" in
|
||||
jpg|png|webp) ;;
|
||||
*) err "unknown format: $format"; return 2 ;;
|
||||
esac
|
||||
|
||||
local dir stem output
|
||||
dir="$(dirname "$input")"
|
||||
stem="$(basename "${input%.*}")"
|
||||
output="$(free_path "$dir" "$stem" converted "$format")"
|
||||
|
||||
local -a quality=()
|
||||
[[ "$format" == "jpg" ]] && quality=(-q:v 3)
|
||||
[[ "$format" == "webp" ]] && quality=(-quality 82)
|
||||
|
||||
if ffmpeg -nostdin -loglevel error -i "$input" "${quality[@]}" "$output" </dev/null; then
|
||||
notify "Converted" "$(basename "$output")" image-x-generic
|
||||
printf '%s\n' "$output"
|
||||
else
|
||||
rm -f "$output"
|
||||
notify "Conversion failed" "$(basename "$input")" dialog-error-symbolic
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
video) shift; cmd_video "$@" ;;
|
||||
image) shift; cmd_image "$@" ;;
|
||||
-h|--help|"")
|
||||
cat <<'USAGE'
|
||||
usage: panama-transcode video <file> [1080p|720p|480p]
|
||||
panama-transcode image <file> [jpg|png|webp]
|
||||
|
||||
Writes beside the input, never over it, and never over an existing output.
|
||||
USAGE
|
||||
;;
|
||||
*) err "unknown command: $1"; exit 2 ;;
|
||||
esac
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# A website, as an application.
|
||||
#
|
||||
# panama-webapp install https://app.example.com "Example"
|
||||
# panama-webapp list
|
||||
# panama-webapp remove "Example"
|
||||
#
|
||||
# Both macOS and Windows ship this now -- Safari's "Add to Dock", Edge's
|
||||
# "Install this site as an app" -- and the dock and launcher here had nothing
|
||||
# to feed them but installed packages. A web app gets its own icon, its own
|
||||
# window with no browser chrome, and its own entry in the launcher, which is
|
||||
# most of what "installed" means in practice.
|
||||
#
|
||||
# The desktop entry is ordinary and inspectable: it lives in
|
||||
# ~/.local/share/applications with everything else, and `remove` finds its own
|
||||
# entries by the launcher line rather than by keeping a list somewhere.
|
||||
#
|
||||
# Chromium-family browsers implement --app. Firefox does not, and there is no
|
||||
# honest equivalent, so a machine whose default browser is Firefox is told
|
||||
# rather than given something that opens a normal window and pretends.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
APPLICATIONS="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps"
|
||||
LAUNCH_MARKER="panama-webapp"
|
||||
|
||||
err() { printf 'panama-webapp: %s\n' "$*" >&2; }
|
||||
|
||||
# A filename that cannot escape the applications directory. Everything that is
|
||||
# not a letter or a digit becomes a hyphen, which also makes the result
|
||||
# predictable enough for `remove` to find.
|
||||
slugify() {
|
||||
printf '%s' "$1" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
|
||||
}
|
||||
|
||||
# The browser to open the app window with. Follows the desktop's own default
|
||||
# rather than naming one, and refuses rather than degrading when that browser
|
||||
# cannot do app mode.
|
||||
resolve_browser() {
|
||||
local desktop exec_line binary
|
||||
desktop="$(xdg-settings get default-web-browser 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$desktop" ]]; then
|
||||
local file
|
||||
for dir in "$APPLICATIONS" /usr/local/share/applications /usr/share/applications; do
|
||||
file="$dir/$desktop"
|
||||
[[ -r "$file" ]] || continue
|
||||
exec_line="$(sed -n 's/^Exec=//p' "$file" | head -1)"
|
||||
binary="$(awk '{ print $1 }' <<<"$exec_line")"
|
||||
break
|
||||
done
|
||||
fi
|
||||
|
||||
[[ -n "${binary:-}" ]] || binary="$(command -v chromium || command -v google-chrome || true)"
|
||||
[[ -n "$binary" ]] || return 1
|
||||
|
||||
# Chromium-family only. The name check is crude but the alternative is
|
||||
# launching a browser to ask it, which is worse.
|
||||
case "$(basename "$binary")" in
|
||||
*firefox*|*librewolf*|*zen*) return 2 ;;
|
||||
esac
|
||||
printf '%s' "$binary"
|
||||
}
|
||||
|
||||
# Four attempts, in descending order of how much the site had to say about it.
|
||||
# An icon is never worth failing an install over: a web app with the generic
|
||||
# icon still works.
|
||||
fetch_icon() {
|
||||
local url="$1" slug="$2" origin html href target
|
||||
origin="$(sed -E 's#^(https?://[^/]+).*#\1#' <<<"$url")"
|
||||
target="$ICONS/$slug.png"
|
||||
mkdir -p "$ICONS"
|
||||
|
||||
html="$(curl -fsSL --max-time 10 "$url" 2>/dev/null || true)"
|
||||
href="$(grep -oiE '<link[^>]+rel="[^"]*apple-touch-icon[^"]*"[^>]*>' <<<"$html" \
|
||||
| grep -oiE 'href="[^"]+"' | head -1 | sed 's/href="//I; s/"$//' || true)"
|
||||
|
||||
if [[ -n "$href" ]]; then
|
||||
case "$href" in
|
||||
http*) ;;
|
||||
/*) href="$origin$href" ;;
|
||||
*) href="$origin/$href" ;;
|
||||
esac
|
||||
curl -fsSL --max-time 10 -o "$target" "$href" 2>/dev/null && { printf '%s' "$slug"; return 0; }
|
||||
fi
|
||||
|
||||
curl -fsSL --max-time 10 -o "$target" "$origin/apple-touch-icon.png" 2>/dev/null \
|
||||
&& { printf '%s' "$slug"; return 0; }
|
||||
|
||||
local host
|
||||
host="$(sed -E 's#^https?://([^/]+).*#\1#' <<<"$url")"
|
||||
curl -fsSL --max-time 10 -o "$target" \
|
||||
"https://www.google.com/s2/favicons?sz=256&domain=$host" 2>/dev/null \
|
||||
&& { printf '%s' "$slug"; return 0; }
|
||||
|
||||
rm -f "$target"
|
||||
printf 'applications-internet'
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
local url="${1:-}" name="${2:-}"
|
||||
[[ -n "$url" ]] || { err 'install needs a URL'; return 2; }
|
||||
[[ "$url" =~ ^https?:// ]] || { err 'the URL must begin with http:// or https://'; return 2; }
|
||||
|
||||
# Default the name from the host, so `install https://app.example.com` is
|
||||
# enough for the common case.
|
||||
[[ -n "$name" ]] || name="$(sed -E 's#^https?://(www\.)?([^/]+).*#\2#' <<<"$url")"
|
||||
|
||||
local slug; slug="$(slugify "$name")"
|
||||
[[ -n "$slug" ]] || { err 'that name has no usable characters in it'; return 2; }
|
||||
|
||||
local browser status
|
||||
browser="$(resolve_browser)"; status=$?
|
||||
if (( status == 2 )); then
|
||||
err 'the default browser cannot open a site as its own application.'
|
||||
err 'Chromium-family browsers implement --app; Firefox does not.'
|
||||
return 1
|
||||
fi
|
||||
[[ -n "$browser" ]] || { err 'no browser found'; return 1; }
|
||||
|
||||
local icon; icon="$(fetch_icon "$url" "$slug")"
|
||||
|
||||
mkdir -p "$APPLICATIONS"
|
||||
local entry="$APPLICATIONS/$LAUNCH_MARKER-$slug.desktop"
|
||||
cat >"$entry" <<ENTRY
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=$name
|
||||
Comment=$url
|
||||
Exec=$browser --app=$url
|
||||
Icon=$icon
|
||||
Terminal=false
|
||||
Categories=Network;
|
||||
StartupWMClass=$browser
|
||||
X-Panama-WebApp=$url
|
||||
ENTRY
|
||||
|
||||
command -v update-desktop-database >/dev/null 2>&1 \
|
||||
&& update-desktop-database "$APPLICATIONS" >/dev/null 2>&1
|
||||
command -v gtk-update-icon-cache >/dev/null 2>&1 \
|
||||
&& gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null
|
||||
|
||||
printf 'Installed %s\n' "$name"
|
||||
printf ' %s\n' "$entry"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local entry name url
|
||||
shopt -s nullglob
|
||||
for entry in "$APPLICATIONS/$LAUNCH_MARKER-"*.desktop; do
|
||||
name="$(sed -n 's/^Name=//p' "$entry" | head -1)"
|
||||
url="$(sed -n 's/^X-Panama-WebApp=//p' "$entry" | head -1)"
|
||||
printf '%s\t%s\n' "$name" "$url"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_remove() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || { err 'remove needs a name'; return 2; }
|
||||
local slug; slug="$(slugify "$name")"
|
||||
local entry="$APPLICATIONS/$LAUNCH_MARKER-$slug.desktop"
|
||||
|
||||
# Only ever removes an entry this command installed. The prefix and the
|
||||
# X-Panama-WebApp key both have to be there, so a name collision with a
|
||||
# real application cannot delete it.
|
||||
[[ -f "$entry" ]] || { err "no web app named '$name'"; return 1; }
|
||||
grep -q '^X-Panama-WebApp=' "$entry" || { err "$entry is not a Panama web app"; return 1; }
|
||||
|
||||
rm -f "$entry" "$ICONS/$slug.png"
|
||||
command -v update-desktop-database >/dev/null 2>&1 \
|
||||
&& update-desktop-database "$APPLICATIONS" >/dev/null 2>&1
|
||||
printf 'Removed %s\n' "$name"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) shift; cmd_install "$@" ;;
|
||||
list) shift; cmd_list "$@" ;;
|
||||
remove) shift; cmd_remove "$@" ;;
|
||||
-h|--help|"")
|
||||
cat <<'USAGE'
|
||||
usage: panama-webapp install <url> [name]
|
||||
panama-webapp list
|
||||
panama-webapp remove <name>
|
||||
|
||||
Turns a website into an application: its own icon, its own window with no
|
||||
browser chrome, and its own entry in the launcher.
|
||||
USAGE
|
||||
;;
|
||||
*) err "unknown command: $1"; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Panama's front door: the one command a fresh Fedora machine needs.
|
||||
#
|
||||
# bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot)
|
||||
#
|
||||
# Deliberately dumb, because a copy of this script leaves the repository the
|
||||
# moment somebody curls it -- nothing here can be fixed by re-running
|
||||
# ./install, so there is as little here as possible: get git, get the clone,
|
||||
# hand off. Everything with judgment in it lives in `install`, which is also
|
||||
# where re-runs and upgrades already work.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="https://git.gbrown.org/gib/Panama.git"
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
export PANAMA_PATH
|
||||
|
||||
# Root would put the clone and every dotfile in root's home and run the
|
||||
# desktop setup for the wrong user. sudo is used inside where it is needed.
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Panama assumes Fedora's repositories, package names, and GNOME base install.
|
||||
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
|
||||
echo "This looks like something other than Fedora; Panama only supports Fedora Workstation." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# git is the one dependency the clone itself needs. Everything else -- gum
|
||||
# included -- is bootstrapped by `install`.
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "Installing git, which the clone needs"
|
||||
sudo dnf install -y git
|
||||
fi
|
||||
|
||||
if [[ -d "$PANAMA_PATH/.git" ]]; then
|
||||
# An existing clone makes this the recovery command too. Only a fast-forward:
|
||||
# local work is never rewritten, and a diverged clone still installs from
|
||||
# what it has rather than stopping someone mid-repair.
|
||||
echo "Panama is already cloned at $PANAMA_PATH; updating"
|
||||
git -C "$PANAMA_PATH" pull --ff-only \
|
||||
|| echo "Could not fast-forward; installing from the clone as it is" >&2
|
||||
else
|
||||
git clone "$REPO_URL" "$PANAMA_PATH"
|
||||
fi
|
||||
|
||||
# `curl | bash` and `bash <(curl ...)` can leave stdin as the pipe, and the
|
||||
# first thing install runs is the interview, which has to be able to ask.
|
||||
# Reattach the terminal when there is one; without one the interview will say
|
||||
# so itself.
|
||||
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
||||
# controlling terminal passes -r and then fails the redirect.
|
||||
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
||||
exec "$PANAMA_PATH/install" </dev/tty
|
||||
fi
|
||||
exec "$PANAMA_PATH/install"
|
||||
+4
-5
@@ -24,8 +24,7 @@ else
|
||||
fi
|
||||
export PATH
|
||||
fi
|
||||
. "$HOME/.cargo/env"
|
||||
|
||||
# >>> Codex installer >>>
|
||||
export PATH="/home/gib/.local/bin:$PATH"
|
||||
# <<< Codex installer <<<
|
||||
# rustup writes this file, and initial-packages installs rustup rather than
|
||||
# running rustup-init -- so on a fresh machine it does not exist yet and an
|
||||
# unguarded source made every single shell start with an error.
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
|
||||
+2
-2
@@ -3,7 +3,6 @@
|
||||
# Aliases I like
|
||||
alias :q="exit"
|
||||
alias :wq="exit"
|
||||
alias startsunshine="systemctl --user restart sunshine.service"
|
||||
alias sourcerc="source ~/.bashrc"
|
||||
alias c="clear"
|
||||
alias shutdown="systemctl poweroff"
|
||||
@@ -32,7 +31,8 @@ alias ls='eza -lh --group-directories-first --icons'
|
||||
alias lsa='ls -a'
|
||||
alias lt='eza --tree --level=2 --long --icons --git'
|
||||
alias lta='lt -a'
|
||||
alias ff="fzf --preview 'batcat --style=numbers --color=always {}'"
|
||||
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
|
||||
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
||||
|
||||
# Directories
|
||||
alias ..='cd ..'
|
||||
|
||||
+12
-3
@@ -22,8 +22,10 @@ export DOTNETPATH="$HOME/.dotnet/tools"
|
||||
# Set complete path
|
||||
export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH"
|
||||
|
||||
# Nvm
|
||||
source /etc/profile.d/nvm.sh
|
||||
# Nvm. Guarded because the file belongs to the nvm package: before that is
|
||||
# installed it does not exist, and an unconditional source means every shell on
|
||||
# a fresh machine opens with an error.
|
||||
[ -f /etc/profile.d/nvm.sh ] && source /etc/profile.d/nvm.sh
|
||||
# Auto-switch Node version when entering a directory with .nvmrc
|
||||
_nvm_auto_use() {
|
||||
if [[ -f .nvmrc ]]; then
|
||||
@@ -41,4 +43,11 @@ fi
|
||||
eval "$(zoxide init bash)"
|
||||
|
||||
# Oh My Posh
|
||||
eval "$(oh-my-posh init bash --config $PANAMA_PATH/config/dot/ohmyposh/gib.omp.json)"
|
||||
# Guarded for the same reason the nvm source above is: oh-my-posh is a package,
|
||||
# and a shell opened before it is installed -- a stage re-run by hand, an
|
||||
# install that failed partway -- would otherwise print command-not-found on
|
||||
# every prompt. An unthemed prompt is a worse shell; an erroring one is a
|
||||
# broken-looking machine.
|
||||
if command -v oh-my-posh >/dev/null 2>&1; then
|
||||
eval "$(oh-my-posh init bash --config "$PANAMA_PATH/config/dot/ohmyposh/gib.omp.json")"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Dictation's speech-to-text server, as a Quadlet.
|
||||
#
|
||||
# Quadlet rather than a hand-written unit wrapping `podman run`: systemd
|
||||
# generates the unit from this at boot, so there is one description of the
|
||||
# container rather than a unit and a command line drifting apart.
|
||||
#
|
||||
# Upstream's own Vulkan image, not one built here. whisper.cpp publishes it, it
|
||||
# already contains whisper-server, and it is maintained by the people who write
|
||||
# the thing -- a Containerfile in this repository would be a compile step on
|
||||
# every machine and a build to keep working, in exchange for nothing.
|
||||
#
|
||||
# Vulkan rather than ROCm, which is the reason this tag and not another. ROCm's
|
||||
# runtime is seven gigabytes and serves AMD alone; Vulkan compute runs on AMD,
|
||||
# Intel and NVIDIA through whatever Mesa driver a machine already has. These
|
||||
# machines are a mix of all three, and this image works on every one of them.
|
||||
#
|
||||
# Deliberately no [Install] section. whisper-server loads the model when it
|
||||
# starts and holds it, so a container started at login costs half a gigabyte of
|
||||
# memory in every session where nobody dictates. panama-dictate starts it on the
|
||||
# first press of the key, and it stays up for the rest of the session.
|
||||
|
||||
[Unit]
|
||||
Description=Panama dictation speech-to-text server
|
||||
Documentation=https://github.com/ggml-org/whisper.cpp
|
||||
|
||||
[Container]
|
||||
Image=ghcr.io/ggml-org/whisper.cpp:main-vulkan
|
||||
|
||||
# The image's own entrypoint is a shell; the server is what is wanted.
|
||||
Entrypoint=/app/build/bin/whisper-server
|
||||
Exec=--host 0.0.0.0 --port 8791 --model /models/ggml-small.bin --inference-path /inference
|
||||
|
||||
# The whole directory rather than a renderD node: the number differs between
|
||||
# machines, and this file is meant to be identical on all of them.
|
||||
AddDevice=/dev/dri
|
||||
|
||||
# The model is host state -- fetched once, kept across image updates, and shared
|
||||
# with nothing else. Read-only because the server never writes to it, and :z
|
||||
# relabels for SELinux, which is enforcing on Fedora.
|
||||
Volume=%h/.local/share/panama/whisper:/models:ro,z
|
||||
|
||||
# Loopback only. This transcribes whatever it is sent, with no authentication,
|
||||
# and has no business being reachable from the network.
|
||||
PublishPort=127.0.0.1:8791:8791
|
||||
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Service]
|
||||
# Loading the model takes a few seconds on a cold start; systemd should wait for
|
||||
# it rather than give up, and should bring the server back if it dies mid-session.
|
||||
TimeoutStartSec=180
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
@@ -0,0 +1,22 @@
|
||||
# Clear the mountpoint before starting, so a start that follows a crash lands
|
||||
# on clean ground.
|
||||
#
|
||||
# This service provides the fuse mount at /run/user/$UID/doc, and bwrap binds
|
||||
# doc/by-app/<id> into every flatpak sandbox it builds. No mount, no sandbox,
|
||||
# no application -- all of them, not one, because they all share this.
|
||||
#
|
||||
# The shipped unit is Type=dbus with Restart=no, so nothing restarts it on
|
||||
# failure: it comes back only when something next calls its bus name. On this
|
||||
# machine that was a flatpak launching two seconds after the daemon exited
|
||||
# 21, and the activation raced the dying instance's fuse state and came up
|
||||
# with no mount at all. systemctl then reports active (running) -- the
|
||||
# fusermount3 helper is even still there as a child -- while every new sandbox
|
||||
# fails. Nothing running at the time notices, because a sandbox needs the
|
||||
# mount only while it is being built, so the breakage surfaces whenever you
|
||||
# next open something you had not opened yet. Here that was three days later,
|
||||
# and it was the second time: the first was written off as a one-off.
|
||||
#
|
||||
# `-` because a clean start has nothing to unmount and fusermount3 exits 1
|
||||
# saying so, which is not a failure to start on.
|
||||
[Service]
|
||||
ExecStartPre=-/usr/bin/fusermount3 -u /run/user/%U/doc
|
||||
@@ -0,0 +1,9 @@
|
||||
# External monitor brightness (DDC/CI) on GPUs that are not "VGA".
|
||||
#
|
||||
# ddcutil ships 60-ddcutil-i2c.rules, which grants the seated user access to
|
||||
# the GPU's i2c buses -- but only when the GPU's PCI class is 0x030000 (VGA
|
||||
# compatible controller). An AMD iGPU that is not the primary boot display
|
||||
# enumerates as 0x038000 (Display controller) instead, so every DDC bus it
|
||||
# exposes stays root-only and the Brightness service reads EACCES. Same grant,
|
||||
# broadened to the class that hardware actually reports.
|
||||
SUBSYSTEM=="i2c-dev", KERNEL=="i2c-[0-9]*", ATTRS{class}=="0x038000", TAG+="uaccess"
|
||||
@@ -1,614 +0,0 @@
|
||||
"""nautilus extension: nautilus_open_any_terminal"""
|
||||
|
||||
# based on: https://github.com/gnunn1/tilix/blob/master/data/nautilus/open-tilix.py
|
||||
|
||||
import ast
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cache
|
||||
from gettext import gettext, translation
|
||||
from os.path import expanduser
|
||||
from subprocess import Popen
|
||||
from typing import Optional
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
from gi import require_version
|
||||
|
||||
try:
|
||||
require_version("Nautilus", "4.1")
|
||||
except ValueError:
|
||||
require_version("Nautilus", "4.0")
|
||||
|
||||
require_version("Gtk", "4.0")
|
||||
|
||||
from gi.repository import Nautilus as FileManager
|
||||
API_VERSION = "4.1"
|
||||
|
||||
from gi.repository import Gio, GLib, GObject, Gtk # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Terminal:
|
||||
"""Data class representing a terminal configuration."""
|
||||
|
||||
name: str
|
||||
workdir_arguments: Optional[list[str]] = None
|
||||
new_tab_arguments: Optional[list[str]] = None
|
||||
new_window_arguments: Optional[list[str]] = None
|
||||
command_arguments: list[str] = field(default_factory=lambda: ["-e"])
|
||||
flatpak_package: Optional[str] = None
|
||||
|
||||
|
||||
_ = gettext
|
||||
for localedir in [expanduser("~/.local/share/locale"), "/usr/share/locale"]:
|
||||
try:
|
||||
trans = translation("nautilus-open-any-terminal", localedir)
|
||||
trans.install()
|
||||
_ = trans.gettext
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
TERMINALS = {
|
||||
"alacritty": Terminal("Alacritty"),
|
||||
"app2unit-term": Terminal("app2unit-term"),
|
||||
"blackbox": Terminal(
|
||||
"Black Box",
|
||||
workdir_arguments=["--working-directory"],
|
||||
command_arguments=["-c"],
|
||||
flatpak_package="com.raggesilver.BlackBox",
|
||||
),
|
||||
"blackbox-terminal": Terminal(
|
||||
"Black Box",
|
||||
workdir_arguments=["--working-directory"],
|
||||
command_arguments=["-c"],
|
||||
),
|
||||
"bobcat": Terminal(
|
||||
"Bobcat",
|
||||
workdir_arguments=["--working-dir"],
|
||||
command_arguments=["--"],
|
||||
),
|
||||
"cool-retro-term": Terminal("cool-retro-term", workdir_arguments=["--workdir"]),
|
||||
"custom": Terminal(_("Terminal"), command_arguments=[]),
|
||||
"contour": Terminal(
|
||||
"Contour",
|
||||
workdir_arguments=["--working-directory"],
|
||||
flatpak_package="org.contourterminal.Contour",
|
||||
),
|
||||
"cosmic-term": Terminal("COSMIC Terminal"),
|
||||
"deepin-terminal": Terminal("Deepin Terminal"),
|
||||
"ddterm": Terminal(
|
||||
"Drop down Terminal extension",
|
||||
workdir_arguments=["--working-directory"],
|
||||
flatpak_package="com.github.amezin.ddterm",
|
||||
),
|
||||
"foot": Terminal("Foot"),
|
||||
"footclient": Terminal("FootClient"),
|
||||
"ghostty": Terminal("Ghostty"),
|
||||
"gnome-terminal": Terminal("Terminal", new_tab_arguments=["--tab"], command_arguments=["--"]),
|
||||
"guake": Terminal("Guake", workdir_arguments=["--show", "--new-tab"]),
|
||||
"kermit": Terminal("Kermit"),
|
||||
"kgx": Terminal("Console", new_tab_arguments=["--tab"]),
|
||||
"kitty": Terminal("Kitty"),
|
||||
"konsole": Terminal("Konsole", new_tab_arguments=["--new-tab"]),
|
||||
"mate-terminal": Terminal("Mate Terminal", new_tab_arguments=["--tab"]),
|
||||
"mlterm": Terminal("Mlterm"),
|
||||
"ptyxis": Terminal(
|
||||
"Ptyxis",
|
||||
workdir_arguments=["-d"],
|
||||
command_arguments=["--"],
|
||||
new_tab_arguments=["--tab"],
|
||||
new_window_arguments=["--new-window"],
|
||||
flatpak_package="app.devsuite.Ptyxis",
|
||||
),
|
||||
"ptyxis-nightly": Terminal(
|
||||
"Ptyxis",
|
||||
workdir_arguments=["-d"],
|
||||
command_arguments=["--"],
|
||||
new_tab_arguments=["--tab"],
|
||||
new_window_arguments=["--new-window"],
|
||||
flatpak_package="org.gnome.Ptyxis.Devel",
|
||||
),
|
||||
"qterminal": Terminal("QTerminal"),
|
||||
"rio": Terminal("Rio"),
|
||||
"sakura": Terminal("Sakura"),
|
||||
"st": Terminal("Simple Terminal"),
|
||||
"tabby": Terminal("Tabby", command_arguments=["run"], workdir_arguments=["open"]),
|
||||
"terminator": Terminal("Terminator", new_tab_arguments=["--new-tab"]),
|
||||
"terminology": Terminal("Terminology"),
|
||||
"terminus": Terminal("Terminus"),
|
||||
"termite": Terminal("Termite"),
|
||||
"tilix": Terminal("Tilix", flatpak_package="com.gexperts.Tilix"),
|
||||
"urxvt": Terminal("rxvt-unicode"),
|
||||
"urxvtc": Terminal("urxvtc"),
|
||||
"uwsm-terminal": Terminal("uwsm-terminal"),
|
||||
"uxterm": Terminal("UXTerm"),
|
||||
"warp": Terminal(
|
||||
"Warp",
|
||||
new_tab_arguments=["--virtual-arg-for-tabs"], # This is just to indicate tab support
|
||||
),
|
||||
"wezterm": Terminal(
|
||||
"Wez's Terminal Emulator",
|
||||
workdir_arguments=["--cwd"],
|
||||
new_tab_arguments=["start", "--new-tab"],
|
||||
new_window_arguments=["start"],
|
||||
flatpak_package="org.wezfurlong.wezterm",
|
||||
),
|
||||
"xfce4-terminal": Terminal("Xfce Terminal", new_tab_arguments=["--tab"]),
|
||||
"xterm": Terminal("XTerm"),
|
||||
}
|
||||
|
||||
FLATPAK_PARMS = ["off", "system", "user"]
|
||||
|
||||
terminal = "gnome-terminal"
|
||||
terminal_cmd: list[str] = None # type: ignore
|
||||
terminal_data: Terminal = TERMINALS["gnome-terminal"]
|
||||
new_tab = False
|
||||
flatpak = FLATPAK_PARMS[0]
|
||||
custom_local_command: str
|
||||
custom_remote_command: str
|
||||
|
||||
GSETTINGS_PATH = "com.github.stunkymonkey.nautilus-open-any-terminal"
|
||||
GSETTINGS_KEYBINDINGS = "keybindings"
|
||||
GSETTINGS_BIND_REMOTE = "bind-remote"
|
||||
GSETTINGS_TERMINAL = "terminal"
|
||||
GSETTINGS_NEW_TAB = "new-tab"
|
||||
GSETTINGS_FLATPAK = "flatpak"
|
||||
GSETTINGS_USE_GENERIC_TERMINAL_NAME = "use-generic-terminal-name"
|
||||
GSETTINGS_CUSTOM_LOCAL_COMMAND = "custom-local-command"
|
||||
GSETTINGS_CUSTOM_REMOTE_COMMAND = "custom-remote-command"
|
||||
REMOTE_URI_SCHEME = ["ftp", "sftp"]
|
||||
|
||||
|
||||
# Adapted from https://www.freedesktop.org/software/systemd/man/latest/os-release.html
|
||||
def read_os_release():
|
||||
"""Read and parse the OS release information."""
|
||||
possible_os_release_paths = ["/etc/os-release", "/usr/lib/os-release"]
|
||||
for file_path in possible_os_release_paths:
|
||||
try:
|
||||
with open(file_path, mode="r", encoding="utf-8") as os_release:
|
||||
for line_number, line in enumerate(os_release, start=1):
|
||||
line = line.rstrip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
result = re.match(r"([A-Z][A-Z_0-9]+)=(.*)", line)
|
||||
if result:
|
||||
name, val = result.groups()
|
||||
if val and val[0] in "\"'":
|
||||
val = ast.literal_eval(val)
|
||||
yield name, val
|
||||
else:
|
||||
raise OSError(f"{file_path}:{line_number}: bad line {line!r}")
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
|
||||
@cache
|
||||
def distro_id() -> set[str]:
|
||||
"""get the set of distribution ids"""
|
||||
try:
|
||||
os_release = dict(read_os_release())
|
||||
except OSError:
|
||||
return set(["unknown"])
|
||||
ids = [os_release["ID"]]
|
||||
if id_like := os_release.get("ID_LIKE"):
|
||||
ids.extend(id_like.split(" "))
|
||||
return set(ids)
|
||||
|
||||
|
||||
def parse_custom_command(command: str, data: str | list[str]) -> list[str]:
|
||||
"""Substitute every '%s' in the command with data and split it into arguments"""
|
||||
if isinstance(data, str):
|
||||
data = [data]
|
||||
|
||||
return shlex.split(command.replace("%s", shlex.join(data)))
|
||||
|
||||
|
||||
def run_command_in_terminal(command: list[str], *, cwd: str | None = None):
|
||||
if terminal == "custom":
|
||||
cmd = parse_custom_command(custom_remote_command, command)
|
||||
else:
|
||||
cmd = terminal_cmd.copy()
|
||||
if cwd and terminal_data.workdir_arguments:
|
||||
cmd.extend(terminal_data.workdir_arguments)
|
||||
cmd.append(cwd)
|
||||
cmd.extend(terminal_data.command_arguments)
|
||||
cmd.extend(command)
|
||||
|
||||
Popen(cmd, cwd=cwd) # pylint: disable=consider-using-with
|
||||
|
||||
|
||||
def ssh_command_from_uri(uri: str, *, is_directory: bool):
|
||||
"""Creates an ssh command that executes or cd's into remote uri"""
|
||||
result = urlparse(uri)
|
||||
cmd = ["ssh", "-t"]
|
||||
if result.username:
|
||||
cmd.append(f"{result.username}@{result.hostname}")
|
||||
else:
|
||||
cmd.append(result.hostname) # type: ignore
|
||||
|
||||
if result.port:
|
||||
cmd.append("-p")
|
||||
cmd.append(str(result.port))
|
||||
|
||||
target = shlex.quote(unquote(result.path))
|
||||
if is_directory:
|
||||
cmd.extend(["cd", target, ";", "exec", "${SHELL:-/bin/sh}", "-l"])
|
||||
else:
|
||||
cmd.extend(["exec", target])
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def open_remote_terminal_in_uri(uri: str):
|
||||
"""Open a new remote terminal"""
|
||||
run_command_in_terminal(ssh_command_from_uri(uri, is_directory=True))
|
||||
|
||||
|
||||
def open_local_terminal_in_uri(uri: str):
|
||||
"""open the new terminal with correct path"""
|
||||
result = urlparse(uri)
|
||||
filename = unquote(result.path)
|
||||
if result.scheme == "admin":
|
||||
run_command_in_terminal(["sudo", "-s"], cwd=filename)
|
||||
return
|
||||
|
||||
if terminal == "warp":
|
||||
# Force new_tab to be considered even without traditional tab arguments
|
||||
Popen( # pylint: disable=consider-using-with
|
||||
["xdg-open", f"warp://action/new_{'tab' if new_tab else 'window'}?path={result.path}"]
|
||||
)
|
||||
return
|
||||
|
||||
cmd = terminal_cmd.copy()
|
||||
if terminal == "custom":
|
||||
cmd = parse_custom_command(custom_local_command, filename)
|
||||
elif filename and terminal_data.workdir_arguments:
|
||||
cmd.extend(terminal_data.workdir_arguments)
|
||||
cmd.append(filename)
|
||||
|
||||
Popen(cmd, cwd=filename) # pylint: disable=consider-using-with
|
||||
|
||||
|
||||
def directory_menu_item_id(*, foreground: bool, remote: bool):
|
||||
return f"OpenTerminal::open{'_' if foreground else '_bg_'}{'remote' if remote else 'file'}_item"
|
||||
|
||||
|
||||
def executable_menu_item_id(*, remote: bool):
|
||||
return f"OpenTerminal::execute{'_remote_' if remote else '_file_'}item"
|
||||
|
||||
|
||||
def get_directory_menu_items(
|
||||
file: FileManager.FileInfo, callback, *, foreground: bool, terminal_name: str | None = None
|
||||
):
|
||||
items = []
|
||||
remote = file.get_uri_scheme() in REMOTE_URI_SCHEME
|
||||
terminal_name = terminal_name or terminal_data.name
|
||||
|
||||
if remote:
|
||||
if foreground:
|
||||
REMOTE_LABEL = _("Open in Remote {}")
|
||||
REMOTE_TIP = _("Open Remote {} in {}")
|
||||
LOCAL_LABEL = _("Open in Local {}")
|
||||
LOCAL_TIP = _("Open Local {} in {}")
|
||||
tip = REMOTE_TIP.format(terminal_name, file.get_name())
|
||||
else:
|
||||
REMOTE_LABEL = _("Open Remote {} Here")
|
||||
REMOTE_TIP = _("Open Remote {} in This Directory")
|
||||
LOCAL_LABEL = _("Open Local {} Here")
|
||||
LOCAL_TIP = _("Open Local {} in This Directory")
|
||||
tip = REMOTE_TIP.format(terminal_name)
|
||||
|
||||
item = FileManager.MenuItem(
|
||||
name=directory_menu_item_id(foreground=foreground, remote=True),
|
||||
label=REMOTE_LABEL.format(terminal_name),
|
||||
tip=tip,
|
||||
)
|
||||
item.connect("activate", callback, file, True)
|
||||
items.append(item)
|
||||
elif foreground:
|
||||
LOCAL_LABEL = _("Open in {}")
|
||||
LOCAL_TIP = _("Open {} in {}")
|
||||
else:
|
||||
LOCAL_LABEL = _("Open {} Here")
|
||||
LOCAL_TIP = _("Open {} in This Directory")
|
||||
|
||||
# Let wezterm handle opening a local terminal
|
||||
if terminal == "wezterm" and flatpak == "off":
|
||||
return items
|
||||
|
||||
if foreground:
|
||||
tip = LOCAL_TIP.format(terminal_name, file.get_name())
|
||||
else:
|
||||
tip = LOCAL_TIP.format(terminal_name)
|
||||
|
||||
item = FileManager.MenuItem(
|
||||
name=directory_menu_item_id(foreground=foreground, remote=False),
|
||||
label=LOCAL_LABEL.format(terminal_name),
|
||||
tip=tip,
|
||||
)
|
||||
item.connect("activate", callback, file, False)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def get_executable_menu_items(file: FileManager.FileInfo, callback, *, terminal_name: str | None = None):
|
||||
items = []
|
||||
remote = file.get_uri_scheme() in REMOTE_URI_SCHEME
|
||||
terminal_name = terminal_name or terminal_data.name
|
||||
|
||||
if remote:
|
||||
REMOTE_LABEL = _("Execute in Remote {}")
|
||||
REMOTE_TIP = _("Execute {} in {} via SSH")
|
||||
LOCAL_LABEL = _("Execute in Local {}")
|
||||
LOCAL_TIP = _("Execute {} in Local {}")
|
||||
|
||||
tip = REMOTE_TIP.format(file.get_name(), terminal_name)
|
||||
item = FileManager.MenuItem(
|
||||
name=executable_menu_item_id(remote=True),
|
||||
label=REMOTE_LABEL.format(terminal_name),
|
||||
tip=tip,
|
||||
)
|
||||
item.connect("activate", callback, file, True)
|
||||
items.append(item)
|
||||
else:
|
||||
LOCAL_LABEL = _("Execute in {}")
|
||||
LOCAL_TIP = _("Execute {} in {}")
|
||||
|
||||
tip = LOCAL_TIP.format(file.get_name(), terminal_name)
|
||||
item = FileManager.MenuItem(
|
||||
name=executable_menu_item_id(remote=False),
|
||||
label=LOCAL_LABEL.format(terminal_name),
|
||||
tip=tip,
|
||||
)
|
||||
item.connect("activate", callback, file, False)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def is_executable(file: Gio.File) -> bool:
|
||||
try:
|
||||
attributes = file.query_info("access::can-execute", Gio.FileQueryInfoFlags.NONE)
|
||||
except GLib.Error:
|
||||
return False
|
||||
return attributes.get_attribute_boolean("access::can-execute")
|
||||
|
||||
|
||||
def set_terminal_args(*_args):
|
||||
# pylint: disable=possibly-used-before-assignment
|
||||
"""set the terminal_cmd to the correct values"""
|
||||
global new_tab
|
||||
global flatpak
|
||||
global terminal_cmd
|
||||
global terminal_data
|
||||
global custom_local_command
|
||||
global custom_remote_command
|
||||
value = _gsettings.get_string(GSETTINGS_TERMINAL)
|
||||
newer_tab = _gsettings.get_boolean(GSETTINGS_NEW_TAB)
|
||||
flatpak = FLATPAK_PARMS[_gsettings.get_enum(GSETTINGS_FLATPAK)]
|
||||
new_terminal_data = TERMINALS.get(value)
|
||||
if not new_terminal_data:
|
||||
print(f'open-any-terminal: unknown terminal "{value}"')
|
||||
return
|
||||
|
||||
global terminal
|
||||
terminal = value
|
||||
terminal_data = new_terminal_data
|
||||
if newer_tab and terminal_data.new_tab_arguments:
|
||||
new_tab = newer_tab
|
||||
new_tab_text = "opening in a new tab"
|
||||
else:
|
||||
new_tab_text = "opening a new window"
|
||||
if newer_tab and not terminal_data.new_tab_arguments:
|
||||
new_tab_text += " (terminal does not support tabs)"
|
||||
if flatpak != FLATPAK_PARMS[0] and terminal_data.flatpak_package is not None:
|
||||
terminal_cmd = ["flatpak", "run", "--" + flatpak, terminal_data.flatpak_package]
|
||||
flatpak_text = f"with flatpak as {flatpak}"
|
||||
else:
|
||||
terminal_cmd = [terminal]
|
||||
if terminal == "blackbox" and "fedora" in distro_id():
|
||||
# It's called like this on fedora
|
||||
terminal_cmd[0] = "blackbox-terminal"
|
||||
flatpak = FLATPAK_PARMS[0]
|
||||
flatpak_text = ""
|
||||
|
||||
if terminal == "custom":
|
||||
terminal_cmd = []
|
||||
custom_local_command = _gsettings.get_string(GSETTINGS_CUSTOM_LOCAL_COMMAND)
|
||||
custom_remote_command = _gsettings.get_string(GSETTINGS_CUSTOM_REMOTE_COMMAND)
|
||||
elif new_tab and terminal_data.new_tab_arguments:
|
||||
terminal_cmd.extend(terminal_data.new_tab_arguments)
|
||||
elif terminal_data.new_window_arguments:
|
||||
terminal_cmd.extend(terminal_data.new_window_arguments)
|
||||
|
||||
print(f'open-any-terminal: terminal is set to "{terminal}" {new_tab_text} {flatpak_text}')
|
||||
|
||||
|
||||
if API_VERSION == ("4.0", "4.1"):
|
||||
|
||||
class OpenAnyTerminalShortcutProvider(GObject.GObject, FileManager.MenuProvider):
|
||||
"""Provide keyboard shortcuts for opening terminals in Nautilus."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.previous_cwd = expanduser("~")
|
||||
|
||||
gsettings_source = Gio.SettingsSchemaSource.get_default()
|
||||
if gsettings_source.lookup(GSETTINGS_PATH, True):
|
||||
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
|
||||
self._setup_keybindings()
|
||||
|
||||
def get_background_items(self, current_folder: FileManager.FileInfo):
|
||||
"""Update current URI when folder changes."""
|
||||
if current_folder:
|
||||
if current_folder.get_uri_scheme() in REMOTE_URI_SCHEME:
|
||||
folder_path = current_folder.get_uri()
|
||||
else:
|
||||
folder_path = current_folder.get_location().get_path()
|
||||
|
||||
if folder_path and folder_path != self.previous_cwd:
|
||||
self.previous_cwd = folder_path
|
||||
return []
|
||||
|
||||
def _open_terminal(self, *_args):
|
||||
"""Open the terminal at the specified URI."""
|
||||
if self._gsettings.get_boolean(GSETTINGS_BIND_REMOTE):
|
||||
open_remote_terminal_in_uri(self.previous_cwd)
|
||||
else:
|
||||
open_local_terminal_in_uri(self.previous_cwd)
|
||||
|
||||
def _setup_keybindings(self):
|
||||
"""Set up custom keybindings for the extension."""
|
||||
self.app = Gtk.Application.get_default()
|
||||
if self.app is None:
|
||||
print("No Gtk.Application found. Keybindings cannot be set.")
|
||||
return
|
||||
|
||||
action = Gio.SimpleAction.new("open_any_terminal", None)
|
||||
action.connect("activate", self._open_terminal)
|
||||
self.app.add_action(action)
|
||||
self._bind_shortcut()
|
||||
self._gsettings.connect("changed", self._update_shortcut)
|
||||
|
||||
def _update_shortcut(self, _gsettings, key):
|
||||
"""remove keybinding"""
|
||||
if key == GSETTINGS_KEYBINDINGS:
|
||||
self.app.set_accels_for_action("app.open_any_terminal", [])
|
||||
self._bind_shortcut()
|
||||
|
||||
def _bind_shortcut(self):
|
||||
"""Parse and update keybindings when settings change."""
|
||||
shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS)
|
||||
|
||||
if not shortcut:
|
||||
self.app.set_accels_for_action("app.open_any_terminal", [])
|
||||
return
|
||||
|
||||
valid, key, mods = Gtk.accelerator_parse(shortcut)
|
||||
if not valid:
|
||||
print("Invalid shortcut in GSettings: %r", shortcut)
|
||||
self.app.set_accels_for_action("app.open_any_terminal", [])
|
||||
return
|
||||
|
||||
normalized = Gtk.accelerator_name(key, mods)
|
||||
self.app.set_accels_for_action("app.open_any_terminal", [normalized])
|
||||
|
||||
elif API_VERSION in ("3.0", "2.0"):
|
||||
|
||||
class OpenAnyTerminalShortcutProviderLegacy(GObject.GObject, FileManager.LocationWidgetProvider):
|
||||
"""Provide keyboard shortcuts for opening terminals in Nautilus/Caja."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
gsettings_source = Gio.SettingsSchemaSource.get_default()
|
||||
if gsettings_source.lookup(GSETTINGS_PATH, True):
|
||||
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
|
||||
self._gsettings.connect("changed", self._bind_shortcut)
|
||||
self._create_accel_group()
|
||||
self._window = None
|
||||
self._uri = None
|
||||
|
||||
def _create_accel_group(self):
|
||||
self._accel_group = Gtk.AccelGroup()
|
||||
shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS)
|
||||
key, mod = Gtk.accelerator_parse(shortcut)
|
||||
self._accel_group.connect(key, mod, Gtk.AccelFlags.VISIBLE, self._open_terminal)
|
||||
|
||||
def _bind_shortcut(self, _gsettings, key):
|
||||
if key == GSETTINGS_KEYBINDINGS:
|
||||
self._accel_group.disconnect(self._open_terminal)
|
||||
self._create_accel_group()
|
||||
|
||||
def _open_terminal(self, *_args):
|
||||
if _gsettings.get_boolean(GSETTINGS_BIND_REMOTE):
|
||||
open_local_terminal_in_uri(self._uri)
|
||||
else:
|
||||
open_remote_terminal_in_uri(self._uri)
|
||||
|
||||
def get_widget(self, uri, window):
|
||||
"""follows uri and sets the correct window"""
|
||||
self._uri = uri
|
||||
if self._window:
|
||||
self._window.remove_accel_group(self._accel_group)
|
||||
if self._gsettings:
|
||||
window.add_accel_group(self._accel_group)
|
||||
self._window = window
|
||||
|
||||
|
||||
class OpenAnyTerminalExtension(GObject.GObject, FileManager.MenuProvider):
|
||||
"""Provide context menu items for opening terminals in Nautilus."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
gsettings_source = Gio.SettingsSchemaSource.get_default()
|
||||
if gsettings_source.lookup(GSETTINGS_PATH, True):
|
||||
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
|
||||
|
||||
def _get_terminal_name(self):
|
||||
if self._gsettings.get_boolean(GSETTINGS_USE_GENERIC_TERMINAL_NAME):
|
||||
return _("Terminal")
|
||||
return None
|
||||
|
||||
def _menu_dir_activate_cb(self, menu, file_, remote: bool):
|
||||
if remote:
|
||||
open_remote_terminal_in_uri(file_.get_uri())
|
||||
else:
|
||||
if file_.get_uri_scheme() == "smb":
|
||||
file_uri = "file://" + quote(file_.get_location().get_path())
|
||||
else:
|
||||
file_uri = file_.get_uri()
|
||||
open_local_terminal_in_uri(file_uri)
|
||||
|
||||
def _menu_exe_activate_cb(self, menu, file_, remote: bool):
|
||||
if remote:
|
||||
cmd = ssh_command_from_uri(file_.get_uri(), is_directory=False)
|
||||
else:
|
||||
result = urlparse(file_.get_uri())
|
||||
file = unquote(result.path)
|
||||
|
||||
if result.scheme == "admin":
|
||||
cmd = ["sudo", file]
|
||||
elif terminal in ["xterm", "uxterm"]:
|
||||
cmd = [f"exec {shlex.quote(file)}"]
|
||||
else:
|
||||
cmd = [file]
|
||||
run_command_in_terminal(cmd)
|
||||
|
||||
def get_file_items(self, *args):
|
||||
"""Generates a list of menu items for a file or folder in the Nautilus file manager."""
|
||||
# `args` will be `[files: List[Nautilus.FileInfo]]` in Nautilus 4.0 API,
|
||||
# and `[window: Gtk.Widget, files: List[Nautilus.FileInfo]]` in Nautilus 3.0 API.
|
||||
|
||||
files = args[-1]
|
||||
|
||||
if len(files) != 1:
|
||||
return []
|
||||
file_ = files[0]
|
||||
|
||||
if file_.is_directory():
|
||||
return get_directory_menu_items(
|
||||
file_, self._menu_dir_activate_cb, foreground=True, terminal_name=self._get_terminal_name()
|
||||
)
|
||||
|
||||
if is_executable(file_.get_location()):
|
||||
return get_executable_menu_items(file_, self._menu_exe_activate_cb, terminal_name=self._get_terminal_name())
|
||||
|
||||
return []
|
||||
|
||||
def get_background_items(self, *args):
|
||||
"""Generates a list of background menu items for a file or folder in the Nautilus file manager."""
|
||||
# `args` will be `[folder: Nautilus.FileInfo]` in Nautilus 4.0 API,
|
||||
# and `[window: Gtk.Widget, file: Nautilus.FileInfo]` in Nautilus 3.0 API.
|
||||
|
||||
file_ = args[-1]
|
||||
return get_directory_menu_items(
|
||||
file_, self._menu_dir_activate_cb, foreground=False, terminal_name=self._get_terminal_name()
|
||||
)
|
||||
|
||||
|
||||
source = Gio.SettingsSchemaSource.get_default()
|
||||
if source is not None and source.lookup(GSETTINGS_PATH, True):
|
||||
_gsettings = Gio.Settings.new(GSETTINGS_PATH)
|
||||
_gsettings.connect("changed", set_terminal_args)
|
||||
set_terminal_args()
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# btop ships no Tokyo Night light variant at all, and the nearest stock light
|
||||
# theme (flat-remix-light) is a different palette that happens to have a similar
|
||||
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
|
||||
# background. Same colors as kitty/themes/tokyonight-day.conf so the terminal
|
||||
# and what runs inside it cannot disagree.
|
||||
#
|
||||
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
# espanso match file
|
||||
# https://espanso.org/docs/
|
||||
# Name and email triggers (:fn, :em, ...) are NOT here: they are personal, and
|
||||
# this file is shared. setup-identity seeds them into match/identity.yml --
|
||||
# per-machine, gitignored, yours to edit -- from the install interview.
|
||||
matches:
|
||||
# Name
|
||||
- trigger: ":fn"
|
||||
replace: "Gabriel Brown"
|
||||
- trigger: ":fin"
|
||||
replace: "Gabriel A Brown"
|
||||
# Email
|
||||
- trigger: ":em"
|
||||
replace: "[email protected]"
|
||||
- trigger: ":empro"
|
||||
replace: "[email protected]"
|
||||
|
||||
# Date
|
||||
- trigger: ":date"
|
||||
replace: "{{mydate}}"
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Spotify",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "bitwarden",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
.tiled {
|
||||
color: rgba(236, 94, 94, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.split {
|
||||
color: rgba(255, 246, 108, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.stacked {
|
||||
color: rgba(247, 162, 43, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.tabbed {
|
||||
color: rgba(17, 199, 224, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.floated {
|
||||
color: rgba(180, 167, 214, 1);
|
||||
border-width: 3px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-tiled-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-split-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* forge-36le: St does not paint an asymmetric (single-side) border when a
|
||||
* border-radius is set, so the directional hint never rendered. Drop the radius
|
||||
* on the one-edge variants; St paints a straight single-side border reliably. */
|
||||
|
||||
.window-split-horizontal {
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
border-bottom-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.window-split-vertical {
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
border-right-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.window-stacked-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tabbed-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tabbed-bg {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.window-tabbed-tab {
|
||||
background-color: rgba(54, 47, 45, 1);
|
||||
border-color: rgba(98,160,234,0.6);
|
||||
border-width: 1px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
margin: 1px;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.window-tabbed-tab-active {
|
||||
background-color: rgb(98,160,234);
|
||||
color: black;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.window-tabbed-tab-close {
|
||||
padding: 3px;
|
||||
margin: 4px;
|
||||
border-radius: 16px;
|
||||
width: 16px;
|
||||
background-color: #e06666;
|
||||
}
|
||||
|
||||
.window-tabbed-tab-icon {
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.window-floated-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tilepreview-tiled {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
.window-tilepreview-stacked {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
.window-tilepreview-swap {
|
||||
border-width: 1px;
|
||||
border-color: rgba(162, 247, 43, 0.4);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(162, 247, 43, 0.4);
|
||||
}
|
||||
|
||||
.window-tilepreview-tabbed {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
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,190 +0,0 @@
|
||||
.tiled {
|
||||
color: rgba(236, 94, 94, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.split {
|
||||
color: rgba(255, 246, 108, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.stacked {
|
||||
color: rgba(247, 162, 43, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.tabbed {
|
||||
color: rgba(17, 199, 224, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.floated {
|
||||
color: rgba(180, 167, 214, 1);
|
||||
border-width: 3px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-tiled-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-split-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* forge-36le: St does not paint an asymmetric (single-side) border when a
|
||||
* border-radius is set, so the directional hint never rendered. Drop the radius
|
||||
* on the one-edge variants; St paints a straight single-side border reliably. */
|
||||
|
||||
.window-split-horizontal {
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
border-bottom-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.window-split-vertical {
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
border-right-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.window-stacked-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tabbed-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tabbed-bg {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.window-tabbed-tab {
|
||||
background-color: rgba(54, 47, 45, 1);
|
||||
border-color: rgba(98,160,234,0.6);
|
||||
border-width: 1px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
margin: 1px;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.window-tabbed-tab-active {
|
||||
background-color: rgb(98,160,234);
|
||||
color: black;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.window-tabbed-tab-close {
|
||||
padding: 3px;
|
||||
margin: 4px;
|
||||
border-radius: 16px;
|
||||
width: 16px;
|
||||
background-color: #e06666;
|
||||
}
|
||||
|
||||
.window-tabbed-tab-icon {
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.window-floated-border {
|
||||
border-width: 1px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tilepreview-tiled {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
.window-tilepreview-stacked {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
.window-tilepreview-swap {
|
||||
border-width: 1px;
|
||||
border-color: rgba(162, 247, 43, 0.4);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(162, 247, 43, 0.4);
|
||||
}
|
||||
|
||||
.window-tilepreview-tabbed {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
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,6 +1,6 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# The theme name and dark preference below follow Panama's color
|
||||
# scheme, so this file is regenerated on every switch and is not
|
||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# The theme name and dark preference below follow Panama's color
|
||||
# scheme, so this file is regenerated on every switch and is not
|
||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||
|
||||
@@ -15,15 +15,15 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| 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 |
|
||||
| Minimize | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
||||
| Dock | Quickshell dock with GNOME favorites 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 |
|
||||
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favorites, 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 |
|
||||
@@ -36,14 +36,37 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| 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 | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labeled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
|
||||
## Portable machines
|
||||
|
||||
Panama was a desktop configuration until 2026-08-22, in the code as well as on
|
||||
the machine: there was no reference to a battery, a lid, or a power source
|
||||
anywhere in it, and `hypridle.conf` said "This is a desktop" in its own header.
|
||||
|
||||
| Capability | Panama implementation | Status |
|
||||
|---|---|---|
|
||||
| Battery level | `services/Battery.qml` reads sysfs the way Vitals reads procfs; bar indicator hides entirely without a battery | Live |
|
||||
| Power source | `panama-hw ac`; a machine with no mains supply reports as on wall power, so a desktop is never treated as running out | Live |
|
||||
| Charge threshold | Power page control, only where the firmware exposes a ceiling; written through `panama-sudo` | Live |
|
||||
| Idle timings per power source | hypridle holds one set at a time, so `panama-idle` rebuilds its config when the charger comes or goes | Live |
|
||||
| Lock on lid close | hypridle's `before_sleep_cmd` already locks, so a lid-close suspend is a locked suspend | Live |
|
||||
| Clamshell / docked | A logind `handle-lid-switch` inhibitor held while an external display is connected. No lid watcher, and a failure returns logind's default | Live, untested on a lid |
|
||||
| Display arrangement on reconnect | `Displays.plannedRestore()` reapplies a confirmed layout, refusing when the stored mode is not offered | Live |
|
||||
| Charger and low-battery notices | `StatusEvents`; the charger is ambient so DND quiets it, critical battery is not | Live |
|
||||
| Hardware predicates | `panama-hw`: laptop, battery, ac, lid-closed, external-monitor, clamshell, touchpad, nvidia | Live |
|
||||
|
||||
The clamshell path is the one row that has not run on hardware with a lid.
|
||||
Everything testable about it is tested; the end-to-end behaviour of closing a
|
||||
real lid on a docked machine is not, and the helper's header says so rather
|
||||
than letting silence imply otherwise.
|
||||
|
||||
## System health and recovery
|
||||
|
||||
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||
appears only for actionable warnings or errors and opens the same **System
|
||||
Health** page available from Settings and the Vicinae command **Panama: Check
|
||||
Health** page available from Settings and the Vicinae command **Check
|
||||
System Health**. The terminal summary is available with:
|
||||
|
||||
```bash
|
||||
@@ -59,7 +82,7 @@ They never install packages, invoke `sudo`, delete user data, or rewrite
|
||||
arbitrary configuration.
|
||||
|
||||
Generic Fedora configuration remains with the system tools that own it. The
|
||||
final System Health card hands network settings, users, sharing, colour
|
||||
final System Health card hands network settings, users, sharing, color
|
||||
profiles, and digital wellbeing to their exact GNOME Settings panels rather
|
||||
than presenting inert Hyprland controls.
|
||||
|
||||
@@ -77,9 +100,9 @@ than presenting inert Hyprland controls.
|
||||
| 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 |
|
||||
| Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
|
||||
| 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 |
|
||||
| Home Assistant | Replaced by secure favorites 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 |
|
||||
@@ -91,6 +114,14 @@ 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.
|
||||
2. Closing a real lid on a docked laptop. The inhibitor, the predicates and the
|
||||
decision are all tested; the physical gesture is not.
|
||||
3. A keyboard-layout change notice. Hyprland reports the active keymap but not
|
||||
a change event Quickshell already consumes, so this needs either polling or
|
||||
new event plumbing, and a single-layout machine cannot test it.
|
||||
4. Sticky keys, slow keys and bounce keys. AccessX is an X11 server feature
|
||||
with no Wayland equivalent; GNOME, macOS and Windows all ship these.
|
||||
5. An on-screen keyboard, for a touch or convertible machine.
|
||||
|
||||
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
||||
tracked in `setup/packages/hyprland-packages`.
|
||||
@@ -111,4 +142,4 @@ menu for a minority, which is worse than omitting it. Relevant references:
|
||||
- <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.
|
||||
move, fullscreen, and close. It should not be labeled as an application menu.
|
||||
|
||||
+21
-19
@@ -29,8 +29,8 @@ Don't "fix" them.
|
||||
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
||||
| `prefs.lua` | Reads the settings file the Settings app 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 |
|
||||
| `monitors.lua` | Monitor geometry and scaling (the Kuycon by description), and the HDR decision |
|
||||
| `looks.lua` | Colors, 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 |
|
||||
@@ -87,8 +87,8 @@ 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
|
||||
wrong-typed settings file costs you your customizations and nothing else;
|
||||
`tests/hypr/prefs-fallback-contract` pins that, including that Hyprland still
|
||||
accepts the config in each of those states.
|
||||
|
||||
### Adding a keybind: use `bind`, not `hl.bind`
|
||||
@@ -120,7 +120,7 @@ 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 the Settings app 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
|
||||
`tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
|
||||
this cannot regress silently.
|
||||
|
||||
```lua
|
||||
@@ -163,7 +163,7 @@ four places, and nowhere else —
|
||||
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
|
||||
115°. Unfocused windows get no color 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.
|
||||
@@ -179,9 +179,9 @@ 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
|
||||
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolor. Where a
|
||||
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
|
||||
paints a palette colour through the icon's alpha.
|
||||
paints a palette color 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:
|
||||
@@ -247,7 +247,7 @@ The mental model is unchanged from Forge:
|
||||
| `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 + SHIFT + I/U` · `,/N` | Taller · shorter |
|
||||
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
|
||||
| `SUPER + Q` | Close |
|
||||
| `SUPER + U` | Fullscreen |
|
||||
@@ -256,7 +256,7 @@ The mental model is unchanged from Forge:
|
||||
| `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 |
|
||||
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimize to scratchpad |
|
||||
|
||||
### Workspaces (dynamic, like GNOME)
|
||||
| Key | Action |
|
||||
@@ -270,20 +270,22 @@ The mental model is unchanged from Forge:
|
||||
### Launcher and shell
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite |
|
||||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favorite |
|
||||
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
|
||||
| `SUPER + V` | Clipboard history |
|
||||
| `SUPER + .` | Emoji picker |
|
||||
| `SUPER + S` | Quick settings |
|
||||
| `SUPER + I` | Settings |
|
||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||
| `SUPER + B` | Notification centre |
|
||||
| `SUPER + B` | Notification center |
|
||||
| `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") |
|
||||
| `SUPER + SHIFT + P` | Color picker |
|
||||
| `CTRL + ALT + L` · `SUPER + Backspace` | Lock (SUPER+L is "focus right") |
|
||||
| `SUPER + /` | Every shortcut, on screen. Reads the live keymap, so a rebind shows here |
|
||||
| `SUPER + ALT + T/N/W/F/C/E` | Go to that application if it is open, rather than starting another |
|
||||
| `CTRL + ALT + Delete` | Power menu |
|
||||
|
||||
### Apps
|
||||
@@ -325,8 +327,8 @@ 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 **Settings → Home & Phone** to choose favourites,
|
||||
Home shows the first four selected favorites at rest and every selected light
|
||||
when expanded. Use **Settings → Home & Phone** to choose favorites,
|
||||
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
|
||||
@@ -334,7 +336,7 @@ 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
|
||||
fallback. Favorites, 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.
|
||||
@@ -361,10 +363,10 @@ 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.
|
||||
behaviors, 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.
|
||||
- **Lock on `CTRL + ALT + L` or `SUPER + Backspace`.** `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`**,
|
||||
|
||||
@@ -23,8 +23,38 @@ hl.on("hyprland.start", function()
|
||||
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")
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
|
||||
-- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
|
||||
-- commands without waiting for them to finish, so the dbus-update call
|
||||
-- above racing this one is not safe to assume complete -- a lost race
|
||||
-- leaves the Condition unmet and the unit silently never starts (exit 0,
|
||||
-- no error). hypridle is the only listener for the logind Lock signal,
|
||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||
-- synchronously in the same shell invocation first so the Condition
|
||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
||||
-- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled
|
||||
-- into its binary and cannot be themed. Only one agent may register per
|
||||
-- session, so they must not both start. hyprpolkitagent stays INSTALLED as
|
||||
-- the fallback: `systemctl --user start hyprpolkitagent` restores the stock
|
||||
-- prompt if Panama's ever fails to come up.
|
||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
|
||||
-- Pending repairs for this machine, if any. Oneshot: it checks, tells the
|
||||
-- user when there is something to tell, and exits. Started here rather
|
||||
-- than enabled so it belongs to the Hyprland session; see the unit.
|
||||
hl.exec_cmd("systemctl --user start panama-migrate-notify.service")
|
||||
|
||||
-- Notices when a program dumps core and says so once per session. Under
|
||||
-- GNOME, ABRT does this; here nothing did, and applications died silently.
|
||||
hl.exec_cmd("systemctl --user start panama-crash-watch.service")
|
||||
|
||||
-- Text expansion. change-settings runs `espanso service register`, which
|
||||
-- writes and enables espanso's own user unit; the explicit start makes the
|
||||
-- first Hyprland login after a fresh install work rather than the second.
|
||||
-- Started on its own line: unlike the units above it carries no
|
||||
-- ConditionEnvironment, and a missing unit (espanso not yet registered)
|
||||
-- must not muddy the start of the four that lock and wallpaper depend on.
|
||||
hl.exec_cmd("systemctl --user start espanso.service")
|
||||
|
||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||
@@ -39,18 +69,22 @@ hl.on("hyprland.start", function()
|
||||
-- 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.
|
||||
-- Nextcloud and Bitwarden are NOT started here. Both ship a
|
||||
-- ~/.config/autostart/*.desktop entry, and systemd's own
|
||||
-- systemd-xdg-autostart-generator turns every such entry into a
|
||||
-- `PartOf=graphical-session.target` unit (`[email protected]`,
|
||||
-- `[email protected]`) that fires once the uwsm
|
||||
-- session brings up graphical-session.target -- confirmed live via
|
||||
-- `systemctl --user list-units 'app-*@autostart.service'`. An explicit
|
||||
-- second launch here used to duplicate that: for Bitwarden specifically,
|
||||
-- each `flatpak run` gets its own sandbox instance, so the two starts
|
||||
-- didn't just race, they left two competing processes fighting over the
|
||||
-- app's single-instance lock, with neither reliably owning a usable
|
||||
-- window. Trust the generator instead of re-launching.
|
||||
--
|
||||
-- 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")
|
||||
-- RustDesk is deliberately absent too: 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.
|
||||
end)
|
||||
|
||||
hl.on("hyprland.shutdown", function()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Environment
|
||||
--
|
||||
-- hl.env() sets variables before the display server initialises, which is what
|
||||
-- hl.env() sets variables before the display server initializes, which is what
|
||||
-- makes it valid for AQ_* and cursor variables.
|
||||
--
|
||||
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
|
||||
@@ -58,7 +58,7 @@ 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
|
||||
-- approximating it -- and it gives them GTK file/font/color 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hypridle — idle behaviour
|
||||
# hypridle — idle behavior
|
||||
#
|
||||
# Mirrors the GNOME power profile this replaces, exactly:
|
||||
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Shared preferences first: looks/input/monitors read their defaults through it.
|
||||
-- It never raises, so a missing or malformed settings file costs customisations
|
||||
-- It never raises, so a missing or malformed settings file costs customizations
|
||||
-- and nothing else.
|
||||
require("prefs")
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#
|
||||
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||
# next colour scheme change.
|
||||
# next color scheme change.
|
||||
#
|
||||
# The colours here follow the desktop's light/dark setting. They used to be
|
||||
# The colors here follow the desktop's light/dark setting. They used to be
|
||||
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
|
||||
# stayed dark when everything else went light.
|
||||
#
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# hyprland-guiutils hyprland-dialog / hyprland-run
|
||||
# hyprpolkitagent authentication prompts (via hyprland-qt-support)
|
||||
#
|
||||
# Colours are Tokyo Night Moon, matching quickshell/config/Theme.qml and
|
||||
# Colors 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).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,10 +22,10 @@ hl.config({
|
||||
repeat_rate = prefs.get("keyRepeatRate", 33),
|
||||
|
||||
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
|
||||
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
|
||||
-- previously claimed 1 was "click to focus, GNOME's behavior", which
|
||||
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
|
||||
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
|
||||
-- so click-to-focus is 0. Changing the shipped value is a behaviour
|
||||
-- so click-to-focus is 0. Changing the shipped value is a behavior
|
||||
-- decision rather than a correction, so the value is left alone and
|
||||
-- only the description is fixed; Settings exposes all four.
|
||||
follow_mouse = prefs.getInt("followMouse", 1),
|
||||
@@ -44,7 +44,28 @@ hl.config({
|
||||
},
|
||||
})
|
||||
|
||||
-- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever
|
||||
-- runs this config, add touchpad settings in overrides.lua.
|
||||
-- ── Touchpad gestures ───────────────────────────────────────────────────────
|
||||
--
|
||||
-- GNOME's gestures, reproduced: three fingers sideways moves between
|
||||
-- workspaces, three fingers up opens the overview, three fingers down closes
|
||||
-- it. That is the same muscle memory the keybinds were built to preserve.
|
||||
--
|
||||
-- Registered unconditionally rather than behind a preference. Hyprland 0.56
|
||||
-- dropped `gestures:workspace_swipe` in favour of this `gesture` keyword, and a
|
||||
-- registration is read at config time -- so a toggle would need a reload to
|
||||
-- take effect, which is worse than the nothing these cost on a machine with no
|
||||
-- touchpad. What IS tunable at runtime lives in Settings: how far a swipe has
|
||||
-- to travel, and which way round it goes.
|
||||
--
|
||||
-- open and close rather than toggle twice: with a toggle on both directions,
|
||||
-- swiping up from an already-open overview would close it, and swiping down
|
||||
-- would reopen it. GNOME does not do that, and neither does this.
|
||||
local overview = function(fn)
|
||||
return function() hl.exec_cmd("qs ipc call overview " .. fn) end
|
||||
end
|
||||
|
||||
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
|
||||
hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
|
||||
hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
|
||||
|
||||
return true
|
||||
|
||||
+197
-21
@@ -34,6 +34,10 @@ local osd = function(action)
|
||||
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
||||
end
|
||||
|
||||
local dictate = function(action)
|
||||
return "$HOME/.config/quickshell/scripts/panama-dictate " .. action
|
||||
end
|
||||
|
||||
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
|
||||
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
||||
|
||||
@@ -66,24 +70,137 @@ local function valid_chord(chord)
|
||||
return chord:match("^[%w_+%s:]+$") ~= nil
|
||||
end
|
||||
|
||||
-- ── Categories ──────────────────────────────────────────────────────────────
|
||||
-- What a bind is FOR, as opposed to what it does.
|
||||
--
|
||||
-- The cheatsheet groups by this, and the Shortcuts settings page uses it too.
|
||||
-- It is recorded here rather than guessed from the description, which is what
|
||||
-- Keybinds.qml used to do: matching substrings put "Close window" and "Close
|
||||
-- the notification list" in the same group and left anything phrased unusually
|
||||
-- in whichever bucket matched first.
|
||||
--
|
||||
-- The sections of this file already ARE the categories, so a section sets one
|
||||
-- and every bind below it inherits it. That keeps the annotation to one line
|
||||
-- per section instead of one per bind, and makes the grouping impossible to
|
||||
-- forget: a new bind lands in the category of the section it was written in.
|
||||
local categories = {}
|
||||
local current_category = "Other"
|
||||
|
||||
local function category(name)
|
||||
current_category = name
|
||||
end
|
||||
|
||||
local function bind(chord, action, opts)
|
||||
local override = overrides[chord]
|
||||
if valid_chord(override) then
|
||||
chord = override
|
||||
end
|
||||
-- Keyed by the chord actually bound, so the shell can join on what
|
||||
-- hyprctl reports without having to know about overrides.
|
||||
categories[chord] = current_category
|
||||
return hl.bind(chord, action, opts)
|
||||
end
|
||||
|
||||
-- Written where the shell can read it. Hyprland reports a Lua bind's
|
||||
-- dispatcher as `__lua` with a bytecode offset, so there is no way to attach
|
||||
-- anything to a bind that survives into `hyprctl binds` -- the manifest is
|
||||
-- how this side of the desktop tells the other what these binds are for.
|
||||
--
|
||||
-- Never raises. A read-only or missing state directory costs the categories,
|
||||
-- which the shell falls back from, and must never cost the keymap.
|
||||
local function write_categories()
|
||||
local state_home = os.getenv("XDG_STATE_HOME")
|
||||
if state_home == nil or state_home == "" then
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then
|
||||
return
|
||||
end
|
||||
state_home = home .. "/.local/state"
|
||||
end
|
||||
|
||||
local parts = {}
|
||||
for chord, name in pairs(categories) do
|
||||
-- Chords and category names are both from this file, so the only
|
||||
-- escaping that can matter is the quote character itself.
|
||||
parts[#parts + 1] = string.format('%q:%q', chord, name)
|
||||
end
|
||||
table.sort(parts)
|
||||
|
||||
local path = state_home .. "/panama/keybind-categories.json"
|
||||
os.execute("mkdir -p " .. string.format("%q", state_home .. "/panama"))
|
||||
local file = io.open(path, "w")
|
||||
if file == nil then
|
||||
return
|
||||
end
|
||||
file:write("{" .. table.concat(parts, ",") .. "}\n")
|
||||
file:close()
|
||||
end
|
||||
|
||||
-- SUPER opens a new one. SUPER+ALT goes to the one you already have.
|
||||
--
|
||||
-- That order matters and was chosen deliberately after trying the reverse.
|
||||
-- Making the plain key focus an existing window reads well in a demo and is
|
||||
-- what macOS does, but it makes "give me another terminal" the awkward case --
|
||||
-- and on a tiling desktop, opening a second terminal beside the first is not
|
||||
-- an edge case, it is the normal way to work. So the plain key keeps doing
|
||||
-- what it has always done, and the modifier is the new capability rather than
|
||||
-- a tax on the old one.
|
||||
--
|
||||
-- ALT rather than SHIFT because SUPER+SHIFT is already the window-manipulation
|
||||
-- space: Files, Neovim and Settings would have collided with Focus session,
|
||||
-- Taller and Shorter, and breaking two keys out of the eight-key resize set to
|
||||
-- make room is a worse trade than borrowing a modifier.
|
||||
--
|
||||
-- The go-to binds still launch when nothing is open. A key that silently does
|
||||
-- nothing is worse than one that does the obvious thing.
|
||||
--
|
||||
-- Patterns are regular expressions and are anchored. An unanchored "mail"
|
||||
-- would match gmail-notifier, and the go-to-mail key would raise somebody's
|
||||
-- notifier instead. Single-quoted for the shell so a backslash reaches the
|
||||
-- matcher rather than being eaten on the way.
|
||||
local function shell_quote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
|
||||
|
||||
local function go_to(class, command, title)
|
||||
local parts = { launcher_bin, "--class", shell_quote(class) }
|
||||
if title then
|
||||
parts[#parts + 1] = "--title"
|
||||
parts[#parts + 1] = shell_quote(title)
|
||||
end
|
||||
parts[#parts + 1] = "--"
|
||||
parts[#parts + 1] = command
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
category("Applications")
|
||||
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" })
|
||||
|
||||
-- Go to the one already open, or start it if there is none.
|
||||
bind(mod .. " + ALT + T", hl.dsp.exec_cmd(go_to("^kitty$", terminal)),
|
||||
{ description = "Go to terminal" })
|
||||
bind(mod .. " + ALT + N", hl.dsp.exec_cmd(go_to("^kitty$", editor, "nvim")),
|
||||
{ description = "Go to Neovim" })
|
||||
bind(mod .. " + ALT + W", hl.dsp.exec_cmd(go_to("^helium", browser)),
|
||||
{ description = "Go to browser" })
|
||||
bind(mod .. " + ALT + F", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Nautilus$", files)),
|
||||
{ description = "Go to files" })
|
||||
bind(mod .. " + ALT + C", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Calculator$", calculator)),
|
||||
{ description = "Go to calculator" })
|
||||
bind(mod .. " + ALT + E", hl.dsp.exec_cmd(go_to("^org\\.mozilla\\.thunderbird", mail)),
|
||||
{ description = "Go to mail" })
|
||||
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
||||
|
||||
-- ── Launcher ────────────────────────────────────────────────────────────────
|
||||
category("Applications")
|
||||
-- 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,
|
||||
@@ -97,6 +214,10 @@ bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||
-- application without dropping to a TTY. Depends on nothing but wofi itself.
|
||||
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
|
||||
|
||||
-- Every shortcut, on one key. Slash because "what are the keys" is a question,
|
||||
-- and because it is the one punctuation key no other bind wants.
|
||||
bind(mod .. " + slash", hl.dsp.exec_cmd(qs("cheatsheet", "toggle")), { description = "Keyboard shortcuts" })
|
||||
|
||||
-- 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"),
|
||||
@@ -105,6 +226,7 @@ bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"
|
||||
{ description = "Emoji picker" })
|
||||
|
||||
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
|
||||
category("Shell")
|
||||
-- SUPER+S was GNOME's quick settings; kept.
|
||||
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
|
||||
|
||||
@@ -118,7 +240,7 @@ bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { descriptio
|
||||
-- which Hyprland has no equivalent for.
|
||||
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
|
||||
|
||||
-- Notification centre.
|
||||
-- Notification center.
|
||||
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
|
||||
|
||||
-- Screenshot / screen record. One key, then pick screen / window / region and
|
||||
@@ -133,10 +255,11 @@ bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description =
|
||||
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" })
|
||||
-- Color picker: copies the hex under the cursor to the clipboard.
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
|
||||
|
||||
-- ── Window management ───────────────────────────────────────────────────────
|
||||
category("Windows")
|
||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
|
||||
|
||||
@@ -180,20 +303,30 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
|
||||
|
||||
-- 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.
|
||||
-- Forge resized one named EDGE at a time: its resize() grows the window for a
|
||||
-- positive amount in every direction, and the edge only decides which side
|
||||
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
|
||||
-- an axis and lets the layout choose the border, so those eight distinct
|
||||
-- behaviours collapse onto four and the direction is simply not expressible.
|
||||
--
|
||||
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
|
||||
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
|
||||
-- correct on paper and wrong under the fingers: with the edge gone, the keys
|
||||
-- that used to pull a window open from one side now push it from the other.
|
||||
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
|
||||
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
|
||||
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" })
|
||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
-- SUPER+SHIFT+P was double-bound with the color picker above; moved to
|
||||
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
|
||||
-- uses rather than landing on an arbitrary free key.
|
||||
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
-- showing what you are choosing between.
|
||||
@@ -218,11 +351,23 @@ bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description =
|
||||
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
|
||||
|
||||
-- ── Workspaces ──────────────────────────────────────────────────────────────
|
||||
category("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.
|
||||
|
||||
-- Behavior for the relative/cyclic binds below. These are Hyprland's own
|
||||
-- `binds:` options -- not part of general/dwindle -- and have no other home
|
||||
-- in the config, so they are read here rather than in looks.lua.
|
||||
hl.config({
|
||||
binds = {
|
||||
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
|
||||
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
|
||||
},
|
||||
})
|
||||
|
||||
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" })
|
||||
@@ -245,26 +390,29 @@ end
|
||||
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.
|
||||
-- Minimize, as far as Hyprland has one.
|
||||
--
|
||||
-- Hyprland has no minimise: it receives the request (the binary has
|
||||
-- Hyprland has no minimize: 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
|
||||
-- minimize 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" })
|
||||
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
|
||||
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })
|
||||
|
||||
-- ── Session ─────────────────────────────────────────────────────────────────
|
||||
category("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("SUPER + Backspace", 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 ────────────────────────────────────────────────────────
|
||||
category("Media & hardware")
|
||||
-- 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" })
|
||||
@@ -272,6 +420,31 @@ bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = t
|
||||
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" })
|
||||
|
||||
-- ── Dictation ───────────────────────────────────────────────────────────────
|
||||
category("Shell")
|
||||
--
|
||||
-- Hold to talk, exactly like push-to-talk anywhere else: the mic is open only
|
||||
-- while the key is down, so it cannot be left listening by forgetting about it.
|
||||
-- Two binds on one chord, the second flagged `release`.
|
||||
--
|
||||
-- No `repeating`: holding a key normally repeats the press, which would restart
|
||||
-- the recording several times a second. The daemon refuses a second start while
|
||||
-- one is running, so a repeat would be harmless -- but not asking for it is
|
||||
-- better than relying on being refused.
|
||||
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("start")),
|
||||
{ description = "Dictate (hold to talk)" })
|
||||
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("stop")),
|
||||
{ release = true, description = "Dictate (transcribe on release)" })
|
||||
|
||||
-- Escape out of a recording without transcribing it. Bound to the same modifier
|
||||
-- so it can be reached with the dictation key still held.
|
||||
bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd(dictate("cancel")),
|
||||
{ description = "Cancel dictation" })
|
||||
|
||||
-- Back to media: the dictation binds sit here for historical reasons, and the
|
||||
-- category has to be set again or everything below inherits theirs.
|
||||
category("Media & hardware")
|
||||
|
||||
-- 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)" })
|
||||
@@ -285,6 +458,7 @@ bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , desc
|
||||
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" })
|
||||
|
||||
category("Applications")
|
||||
-- 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" })
|
||||
@@ -293,4 +467,6 @@ 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" })
|
||||
|
||||
write_categories()
|
||||
|
||||
return true
|
||||
|
||||
+50
-11
@@ -1,8 +1,8 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Look and feel -- Tokyo Night Moon
|
||||
--
|
||||
-- Colours here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent #82aaff borders / focus
|
||||
-- Colors here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
|
||||
-- bg #222436 base
|
||||
--
|
||||
-- Performance note: every animation below is event-driven. Nothing uses the
|
||||
@@ -14,6 +14,29 @@
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- The eight accents, from config/palette.json -- the one place they are
|
||||
-- written down for everything outside QML. They used to be restated here,
|
||||
-- which made adding a ninth accent a five-file edit; the file that got
|
||||
-- forgotten was always the one that fails silently.
|
||||
--
|
||||
-- Falls back to the shipped blue pair if the palette cannot be read, for the
|
||||
-- same reason prefs never raises: a missing file costs the accent colour, not
|
||||
-- the compositor config.
|
||||
local palette = prefs.readJson(os.getenv("HOME") .. "/.config/quickshell/config/palette.json")
|
||||
local accents = palette.accents or {
|
||||
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
|
||||
}
|
||||
|
||||
-- The accent pair a fresh session or `hyprctl reload` starts from.
|
||||
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
|
||||
-- data, once the shell settles (~1.2s after startup -- see its `settle`
|
||||
-- Timer). This table exists only so the compositor is never observably blue
|
||||
-- for a non-blue accent during the gap before that first live apply.
|
||||
local accentScheme = prefs.get("colorScheme", "dark")
|
||||
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
|
||||
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
|
||||
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
gaps_in = prefs.get("gapsIn", 5),
|
||||
@@ -22,12 +45,14 @@ hl.config({
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The focused accent role: blue leads, orchid follows, on a
|
||||
-- diagonal so the pair is visible on both a tall and a wide
|
||||
-- window. ColorScheme never writes this role; a future accent
|
||||
-- picker can own it without fighting light/dark mode.
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- The neutral inactive role follows the colour scheme because a
|
||||
-- The focused accent role, on a diagonal so the pair is visible
|
||||
-- on both a tall and a wide window. Driven by the chosen
|
||||
-- accentName (see the `accents` table above); services/
|
||||
-- ColorScheme.qml applies the same values live, and restates them
|
||||
-- from Theme.accent/Theme.accentSecondary on every scheme change
|
||||
-- too, since each accent carries a separate pair per scheme.
|
||||
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
|
||||
-- The neutral inactive role follows the color scheme because a
|
||||
-- dark neutral disappears against a light desktop.
|
||||
-- services/ColorScheme.qml applies the same values live; this is
|
||||
-- the value a fresh session starts from.
|
||||
@@ -104,11 +129,13 @@ hl.config({
|
||||
-- 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.
|
||||
-- Derived from the same accent as active_border above, not hardcoded,
|
||||
-- so the halo never disagrees with the border it surrounds.
|
||||
glow = {
|
||||
enabled = prefs.get("glowEnabled", true),
|
||||
range = prefs.get("glowRange", 8),
|
||||
render_power = 2,
|
||||
color = "rgba(82aaff33)",
|
||||
color = "rgba(" .. accentStart .. "33)",
|
||||
color_inactive = "rgba(00000000)",
|
||||
},
|
||||
|
||||
@@ -165,12 +192,24 @@ hl.config({
|
||||
-- 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.
|
||||
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
|
||||
focus_on_activate = false,
|
||||
|
||||
-- Window swallowing: a terminal hides itself while a graphical
|
||||
-- application launched from it is open, and comes back when that
|
||||
-- application exits. Off by default -- it is a real change in how the
|
||||
-- desktop behaves, and one that is confusing rather than broken if you
|
||||
-- did not ask for it: your terminal appears to vanish.
|
||||
--
|
||||
-- The regex is narrow on purpose. Anything matching it can swallow, so
|
||||
-- a permissive pattern means windows disappearing in cases nobody
|
||||
-- intended. Only the two terminals this desktop actually ships.
|
||||
enable_swallow = prefs.get("windowSwallow", false),
|
||||
swallow_regex = "^(kitty|com\\.mitchellh\\.ghostty)$",
|
||||
},
|
||||
|
||||
render = {
|
||||
-- Colour management is stable and on by default in 0.56.
|
||||
-- Color management is stable and on by default in 0.56.
|
||||
cm_enabled = true,
|
||||
|
||||
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Monitors
|
||||
--
|
||||
-- Kuycon P20 on DP-2: 4500x3000 @ 60Hz, 1.5x fractional scale.
|
||||
-- Kuycon P20 (matched by description): 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.
|
||||
--
|
||||
@@ -25,7 +25,7 @@ local prefs = require("prefs")
|
||||
-- x = 0, y = 0, primary = true,
|
||||
-- } }
|
||||
--
|
||||
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||
-- Only mode, scale, and transform are read. Color 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.
|
||||
@@ -125,32 +125,10 @@ local function display_position(entry, fallback)
|
||||
return fallback
|
||||
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 = display_position(dp2, "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.
|
||||
-- Every connected output uses the same validated per-output store. Automatic
|
||||
-- placement and the compositor's normal color policy unless the entry says
|
||||
-- otherwise.
|
||||
for output, _ in pairs(displays) do
|
||||
if output ~= "DP-2" then
|
||||
local entry = display_entry(output)
|
||||
if entry ~= nil then
|
||||
hl.monitor({
|
||||
@@ -162,7 +140,32 @@ for output, _ in pairs(displays) do
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The Kuycon P20, matched by what it is rather than where it is plugged in.
|
||||
-- This used to be a rule for connector DP-2 outright, which handed the panel's
|
||||
-- 4500x3000 mode and 1.5 scale to whatever monitor a stranger's machine had on
|
||||
-- its most common DisplayPort connector. Emitted after the prefs loop so a
|
||||
-- saved entry for its connector still carries the mode/scale/position, while
|
||||
-- this rule holds the shipped defaults and the panel-specific color policy.
|
||||
local shipped_mode = "4500x3000@60"
|
||||
local shipped_scale = 1.5
|
||||
local shipped_transform = 0
|
||||
local kuycon = display_entry("DP-2")
|
||||
hl.monitor({
|
||||
output = "desc:GVT Kuycon P20",
|
||||
mode = kuycon and kuycon.mode or shipped_mode,
|
||||
position = display_position(kuycon, "0x0"),
|
||||
scale = kuycon and kuycon.scale or shipped_scale,
|
||||
transform = kuycon and kuycon.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",
|
||||
})
|
||||
|
||||
-- Any monitor not named above: sane defaults rather than nothing.
|
||||
hl.monitor({
|
||||
@@ -172,4 +175,40 @@ hl.monitor({
|
||||
scale = "auto",
|
||||
})
|
||||
|
||||
-- ── Workspaces on the primary display only ──────────────────────────────────
|
||||
--
|
||||
-- GNOME offered one workspace choice worth reproducing: whether the other
|
||||
-- screens join in. Off, every monitor has its own workspaces and switching
|
||||
-- affects whichever one has focus -- Hyprland's own behaviour, so it needs no
|
||||
-- rules at all. On, workspaces 1-10 are pinned to the primary display and a
|
||||
-- second screen keeps a workspace of its own that stays put.
|
||||
--
|
||||
-- Ten because that is how many the keybinds reach: ALT+1 through ALT+0 in
|
||||
-- keybinds.lua. Binding more would pin workspaces nothing can navigate to, and
|
||||
-- binding fewer would leave the last few behaving differently from the rest for
|
||||
-- no reason a person could see.
|
||||
--
|
||||
-- The rules are emitted here rather than written live because Hyprland reads
|
||||
-- them at config time and offers no way to remove one afterwards: writing an
|
||||
-- empty monitor leaves the previous binding in place. So the config is the only
|
||||
-- honest source, and applying a change is a reload.
|
||||
if prefs.get("workspacesOnPrimaryOnly", false) == true then
|
||||
local primary = nil
|
||||
for output, entry in pairs(displays) do
|
||||
if type(entry) == "table" and entry.primary == true
|
||||
and type(output) == "string" and output:match("^[%w_.-]+$") ~= nil then
|
||||
primary = output
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Without a primary there is nothing to pin to, and guessing one would move
|
||||
-- every workspace onto whichever screen happened to sort first.
|
||||
if primary ~= nil then
|
||||
for i = 1, 10 do
|
||||
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- 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
|
||||
-- file must cost the user nothing worse than their customizations; it must
|
||||
-- never cost them a working compositor. Every failure path returns the caller's
|
||||
-- fallback.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -244,6 +244,33 @@ function prefs.getInt(key, fallback)
|
||||
return math.floor(value + 0.5)
|
||||
end
|
||||
|
||||
-- Read and decode any JSON file, using the same never-raise parser the
|
||||
-- settings store uses. Returns an empty table for a file that is missing,
|
||||
-- empty, or malformed, so a caller can index the result without checking.
|
||||
--
|
||||
-- Exists so config/palette.json can be read by looks.lua rather than the eight
|
||||
-- accents being written out a second time in Lua. A bad palette costs the
|
||||
-- accent colours, never the compositor config.
|
||||
function prefs.readJson(path)
|
||||
if type(path) ~= "string" or path == "" then
|
||||
return {}
|
||||
end
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
return {}
|
||||
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 not ok or type(parsed) ~= "table" then
|
||||
return {}
|
||||
end
|
||||
return parsed
|
||||
end
|
||||
|
||||
-- True when a settings file was actually read. Useful from overrides.lua.
|
||||
function prefs.loaded()
|
||||
return next(values) ~= nil
|
||||
|
||||
@@ -69,7 +69,7 @@ hl.window_rule({
|
||||
float = true,
|
||||
})
|
||||
|
||||
-- Settings-style utility windows and pickers: float and centre, like GNOME did.
|
||||
-- Settings-style utility windows and pickers: float and center, like GNOME did.
|
||||
hl.window_rule({
|
||||
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
|
||||
float = true,
|
||||
@@ -77,6 +77,18 @@ hl.window_rule({
|
||||
center = true,
|
||||
})
|
||||
|
||||
-- Quick Look. The GNOME previewer is what the file manager opens on space,
|
||||
-- and it is an overlay rather than a window someone manages: tiled, it shoves
|
||||
-- the file manager aside and has to be dismissed before the list is usable
|
||||
-- again. Sized generously because a preview that needs zooming is not a
|
||||
-- preview; it still gets a margin so the file underneath stays visible.
|
||||
hl.window_rule({
|
||||
match = { class = "^org\\.gnome\\.NautilusPreviewer$" },
|
||||
float = true,
|
||||
size = { "monitor_w * 0.7", "monitor_h * 0.8" },
|
||||
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)$" },
|
||||
@@ -150,7 +162,7 @@ hl.layer_rule({
|
||||
ignore_alpha = 0.3,
|
||||
})
|
||||
|
||||
-- Popovers: calendar, quick settings, notification centre, tray menus.
|
||||
-- Popovers: calendar, quick settings, notification center, tray menus.
|
||||
hl.layer_rule({
|
||||
name = "qs-popover",
|
||||
match = { namespace = "^qs-popover" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 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
|
||||
# includes current-theme.conf, which Panama generates from the color 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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-- 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
|
||||
-- user their color scheme preference and nothing else; editing text is more
|
||||
-- important than matching the desktop.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
-- Tokyo Night, following the desktop's colour scheme.
|
||||
-- Tokyo Night, following the desktop's color 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
|
||||
-- against Moon's palette -- a pale comment color, a mid-gray gutter -- and
|
||||
-- applying them to Day would put light gray text on a light background, which
|
||||
-- is exactly the legibility problem they exist to solve, inverted.
|
||||
|
||||
local panama = require("config.panama")
|
||||
|
||||
@@ -22,7 +22,7 @@ console_title_template = '{{ .Shell }} in {{ .Folder }}'
|
||||
[[blocks.segments]]
|
||||
type = 'git'
|
||||
style = 'plain'
|
||||
foreground = 'p:grey'
|
||||
foreground = 'p:gray'
|
||||
background = 'transparent'
|
||||
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs after `panama migrate` has applied repairs, and only when it applied at
|
||||
# least one. Takes no arguments.
|
||||
#
|
||||
# Useful when a machine keeps local state that a repair might have invalidated.
|
||||
#
|
||||
# Copy to ~/.config/panama/hooks/post-migrate and make it executable.
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs at the end of ./install, after every stage and after migrations.
|
||||
#
|
||||
# Takes no arguments. This is where per-machine setup goes that Panama should
|
||||
# not carry for everyone: a work laptop's VPN client, a private repository
|
||||
# somebody clones, a package only this machine wants.
|
||||
#
|
||||
# Copy to ~/.config/panama/hooks/post-upgrade and make it executable.
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs whenever the colour scheme or accent changes.
|
||||
#
|
||||
# $1 scheme: "dark" or "light"
|
||||
# $2 accent: blue, orchid, teal, green, amber, orange, rose or slate
|
||||
#
|
||||
# Copy to ~/.config/panama/hooks/theme-set and make it executable. Anything in
|
||||
# theme-set.d/ runs too, in sorted order, so several things can react without
|
||||
# fighting over one file.
|
||||
#
|
||||
# A failure here is reported and stepped over: it will never cost you a theme
|
||||
# change.
|
||||
|
||||
scheme="$1"
|
||||
accent="$2"
|
||||
|
||||
# For example: repaint something Panama does not know about.
|
||||
# printf 'set-theme %s\n' "$scheme" | nc -U "$HOME/.local/share/some-app/socket"
|
||||
@@ -0,0 +1,41 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
Item {
|
||||
width: 680
|
||||
height: editor.implicitHeight
|
||||
|
||||
AccentEditor {
|
||||
id: editor
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ThemeProfilePicker {
|
||||
width: parent.width
|
||||
visible: false
|
||||
}
|
||||
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
visible: false
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "accent-controls-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify(ThemeProfiles.activeProfile);
|
||||
}
|
||||
|
||||
function adjust(target: string, channel: string, ratio: real): string {
|
||||
editor.changeChannel(target, channel, ratio);
|
||||
return status();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ Singleton {
|
||||
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
||||
|
||||
// Upgrade before anything reads a value. A stored key the current
|
||||
// schema no longer recognises is carried through untouched and silently
|
||||
// schema no longer recognizes is carried through untouched and silently
|
||||
// stops taking effect, so the conversion has to happen here rather than
|
||||
// being noticed later by whoever owns that setting.
|
||||
const result = Migrations.apply(raw);
|
||||
@@ -130,6 +130,14 @@ Singleton {
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
|
||||
// Adopt writes made from outside the shell -- a hand edit, a script,
|
||||
// a restored snapshot -- instead of holding a stale copy in memory
|
||||
// and silently erasing them at the next save. A change made anywhere
|
||||
// must survive everywhere; the shell is the editor, not the owner.
|
||||
// The shell's own atomic writes land here too and reload as a no-op.
|
||||
watchChanges: true
|
||||
onFileChanged: this.reload()
|
||||
|
||||
onLoaded: root.load()
|
||||
// No file yet is the normal first-run case, not an error.
|
||||
onLoadFailed: root.load()
|
||||
@@ -145,7 +153,27 @@ Singleton {
|
||||
Timer {
|
||||
id: persistTimer
|
||||
interval: 0
|
||||
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
|
||||
// Merge with what is on disk rather than overwriting it. This model
|
||||
// was loaded at startup; a key written to the file since then -- a
|
||||
// hand edit, a script, another shell instance during a session
|
||||
// handoff -- would otherwise be erased by the next unrelated save,
|
||||
// which is how a setting "changed itself back". Keys this shell has
|
||||
// set win; keys it has never seen survive.
|
||||
onTriggered: {
|
||||
let disk = {};
|
||||
try {
|
||||
const text = preferencesFile.text();
|
||||
if (text && text.trim().length > 0)
|
||||
disk = JSON.parse(text);
|
||||
} catch (error) {
|
||||
// An unreadable file loses the merge, never the write.
|
||||
}
|
||||
if (!disk || typeof disk !== "object")
|
||||
disk = {};
|
||||
const merged = Object.assign({}, disk, root.values);
|
||||
root.values = merged;
|
||||
preferencesFile.setText(JSON.stringify(merged, null, 2) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// One-time move from the pre-Stage-1 location inside Quickshell's state
|
||||
|
||||
@@ -19,7 +19,7 @@ Singleton {
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
onSaved: root.saveError = ""
|
||||
onSaveFailed: error => root.saveError = "Could not save Home favourites."
|
||||
onSaveFailed: error => root.saveError = "Could not save Home favorites."
|
||||
|
||||
JsonAdapter {
|
||||
id: values
|
||||
|
||||
@@ -5,7 +5,7 @@ pragma Singleton
|
||||
// The schema is the single source of truth for what a setting IS, but it cannot
|
||||
// describe what a setting USED to be. Renaming a key, changing its units, or
|
||||
// splitting one setting into two all leave a stored value that the new schema
|
||||
// does not recognise -- and an unrecognised key is silently carried through
|
||||
// does not recognize -- and an unrecognized key is silently carried through
|
||||
// untouched, so the user's choice simply stops taking effect with nothing to
|
||||
// say why. That is the failure this exists to prevent.
|
||||
//
|
||||
|
||||
@@ -84,6 +84,55 @@ Singleton {
|
||||
label: "Graphics",
|
||||
detail: "Show graphics usage beside the workspace indicator"
|
||||
},
|
||||
// Only ever visible on a machine that has a battery: the indicator
|
||||
// gates on Battery.available as well as this, the way the graphics
|
||||
// field gates on Vitals.gpuAvailable.
|
||||
{
|
||||
key: "showBattery", type: "bool", def: true, group: "vitals",
|
||||
label: "Battery",
|
||||
detail: "Show the charge level in the bar, on machines that have a battery"
|
||||
},
|
||||
// The number beside the icon, GNOME's "Show Battery Percentage".
|
||||
// Off by default for the same reason GNOME ships it off: the icon
|
||||
// already says what matters, and the number is for people who want it.
|
||||
{
|
||||
key: "showBatteryPercent", type: "bool", def: false, group: "vitals",
|
||||
label: "Battery percentage",
|
||||
detail: "Show the exact number beside the battery icon"
|
||||
},
|
||||
|
||||
// Off by default: this is a coding-tool readout, not something a
|
||||
// general-purpose desktop should show without being asked.
|
||||
{
|
||||
key: "showAgentUsage", type: "bool", def: false, group: "vitals",
|
||||
label: "Claude usage",
|
||||
detail: "Show how much of the Claude subscription has been used, beside the other vitals"
|
||||
},
|
||||
|
||||
// ── Battery ─────────────────────────────────────────────────────────
|
||||
// The two points at which the desktop starts telling you. Low is a
|
||||
// quiet mention; critical is the one that interrupts, so it is
|
||||
// published at a priority Do Not Disturb does not silence.
|
||||
{
|
||||
key: "batteryLowPercent", type: "int", def: 20, min: 5, max: 50, step: 5,
|
||||
unit: "%", group: "battery",
|
||||
label: "Warn at",
|
||||
detail: "Mention the battery once it drops this low"
|
||||
},
|
||||
{
|
||||
key: "batteryCriticalPercent", type: "int", def: 5, min: 1, max: 25, step: 1,
|
||||
unit: "%", group: "battery",
|
||||
label: "Urgent at",
|
||||
detail: "Interrupt at this level, even during Do Not Disturb"
|
||||
},
|
||||
// Only offered where the firmware exposes a ceiling; the Power page
|
||||
// hides the control entirely otherwise. 100 means charge to full.
|
||||
{
|
||||
key: "batteryChargeLimit", type: "int", def: 100, min: 50, max: 100, step: 5,
|
||||
unit: "%", group: "battery",
|
||||
label: "Stop charging at",
|
||||
detail: "Charging to less than full is easier on the battery over years"
|
||||
},
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -91,6 +140,32 @@ Singleton {
|
||||
label: "Automatically hide the Dock",
|
||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||
},
|
||||
{
|
||||
key: "dockPosition", type: "enum", def: "bottom", group: "dock",
|
||||
label: "Position",
|
||||
detail: "Which edge the Dock lives on",
|
||||
options: [
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" }
|
||||
]
|
||||
},
|
||||
|
||||
// A "json" value: the screen names the Dock appears on. Empty means
|
||||
// every screen, which is both the sensible default and the right
|
||||
// answer for the common single-monitor case -- storing a list of
|
||||
// names there would go stale the moment a display is unplugged.
|
||||
{
|
||||
key: "dockScreens", type: "json", def: [], group: "dock",
|
||||
label: "Screens",
|
||||
detail: "Which displays show the Dock"
|
||||
},
|
||||
{
|
||||
key: "dockIconSize", type: "int", def: 48, min: 32, max: 80, step: 4,
|
||||
unit: "px", group: "dock",
|
||||
label: "Icon size",
|
||||
detail: "How large the Dock's application icons are drawn"
|
||||
},
|
||||
{
|
||||
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
|
||||
unit: "ms",
|
||||
@@ -107,6 +182,41 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Focus ───────────────────────────────────────────────────────────
|
||||
// ── Focus modes ─────────────────────────────────────────────────────
|
||||
// A "json" value: named modes, each with what turns it on and what it
|
||||
// does. Triggers rather than a scheduler -- a mode is on because a
|
||||
// condition is true right now, which is re-evaluated rather than fired
|
||||
// once. A schedule is one of those conditions ("is now inside this
|
||||
// window?"), which is why suspend, a reboot mid-window, and a lid
|
||||
// opened after the start time all behave correctly without special
|
||||
// cases: there is no alarm to have missed.
|
||||
//
|
||||
// Gaming ships enabled because the behaviour already existed as
|
||||
// gamingSilenceNotifications; Sleep ships disabled, because a desktop
|
||||
// that starts silencing someone on first boot has overstepped.
|
||||
{
|
||||
key: "focusModes", type: "json", group: "focus",
|
||||
label: "Focus modes",
|
||||
detail: "What quiets this machine, and what turns it on",
|
||||
def: [
|
||||
{
|
||||
id: "deep-work", name: "Deep work", enabled: true,
|
||||
triggers: [{ kind: "manual" }],
|
||||
durationMinutes: 45, silence: true, keepAwake: true, allow: []
|
||||
},
|
||||
{
|
||||
id: "gaming", name: "Gaming", enabled: true,
|
||||
triggers: [{ kind: "game" }],
|
||||
durationMinutes: 0, silence: true, keepAwake: true, allow: []
|
||||
},
|
||||
{
|
||||
id: "sleep", name: "Sleep", enabled: false,
|
||||
triggers: [{ kind: "schedule", start: "23:30", end: "07:00",
|
||||
days: [0, 1, 2, 3, 4, 5, 6] }],
|
||||
durationMinutes: 0, silence: true, keepAwake: false, allow: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||
unit: "min",
|
||||
@@ -119,6 +229,19 @@ Singleton {
|
||||
// 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.
|
||||
// GNOME's Multitasking panel had exactly this choice, and it is the one
|
||||
// worth reproducing: not which workspace goes on which screen, but
|
||||
// whether the second screen participates in workspaces at all.
|
||||
//
|
||||
// No `hypr` block, because this is not an option. It becomes workspace
|
||||
// rules in monitors.lua, and Hyprland reads those at config time and
|
||||
// will not let one be removed afterwards -- so applying a change is a
|
||||
// reload rather than a write, which is what Workspaces.qml owns.
|
||||
{
|
||||
key: "workspacesOnPrimaryOnly", type: "bool", def: false, group: "display",
|
||||
label: "Workspaces on the primary display only",
|
||||
detail: "Other screens keep one workspace of their own rather than switching along with it"
|
||||
},
|
||||
{
|
||||
key: "autoHdr", type: "bool", def: true, group: "display",
|
||||
label: "Game-aware HDR",
|
||||
@@ -171,7 +294,7 @@ Singleton {
|
||||
unit: "px",
|
||||
group: "windows",
|
||||
label: "Inner gaps",
|
||||
detail: "Space between neighbouring tiled windows",
|
||||
detail: "Space between neighboring tiled windows",
|
||||
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
|
||||
},
|
||||
{
|
||||
@@ -278,7 +401,7 @@ Singleton {
|
||||
// ── Master layout ───────────────────────────────────────────────────
|
||||
// Only meaningful when the tiling layout is Master and stack. Offering
|
||||
// that layout with none of its options was an omission: it is the one
|
||||
// layout whose whole behaviour is in these settings.
|
||||
// layout whose whole behavior is in these settings.
|
||||
{
|
||||
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
|
||||
group: "master",
|
||||
@@ -295,7 +418,7 @@ Singleton {
|
||||
{ value: "right", label: "Right" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
{ value: "center", label: "Centre" }
|
||||
{ value: "center", label: "Center" }
|
||||
],
|
||||
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
||||
},
|
||||
@@ -429,7 +552,7 @@ Singleton {
|
||||
{
|
||||
key: "keyboardVariant", type: "string", def: "", group: "input",
|
||||
// Same shape as the layout list and for the same reason: this is
|
||||
// serialised into an hl.config string.
|
||||
// serialized into an hl.config string.
|
||||
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
|
||||
label: "Layout variant",
|
||||
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
|
||||
@@ -475,7 +598,7 @@ Singleton {
|
||||
// The compositor publishes the authoritative mapping itself --
|
||||
// `hyprctl descriptions` gives
|
||||
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
|
||||
// -- so a desktop labelled "Click to focus" was in fact following
|
||||
// -- so a desktop labeled "Click to focus" was in fact following
|
||||
// the pointer, and the way to actually get click-to-focus was to
|
||||
// choose "Never". Value 3 was missing entirely.
|
||||
//
|
||||
@@ -612,6 +735,24 @@ Singleton {
|
||||
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
||||
},
|
||||
|
||||
// Tuning for the three-finger gestures registered in hypr/input.lua.
|
||||
// The gestures themselves are not settings: Hyprland reads a gesture
|
||||
// registration at config time, so switching one on would need a reload,
|
||||
// and these two are the parts it will accept at runtime.
|
||||
{
|
||||
key: "swipeDistance", type: "int", def: 300, min: 100, max: 800, step: 20,
|
||||
unit: "px", group: "touchpad",
|
||||
label: "Swipe distance",
|
||||
detail: "How far a three-finger swipe must travel to change workspace",
|
||||
hypr: { path: ["gestures", "workspace_swipe_distance"], option: "gestures:workspace_swipe_distance", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "swipeInvert", type: "bool", def: true, group: "touchpad",
|
||||
label: "Natural swipe direction",
|
||||
detail: "Swiping left moves to the workspace on the right, as content follows your fingers",
|
||||
hypr: { path: ["gestures", "workspace_swipe_invert"], option: "gestures:workspace_swipe_invert", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Multitasking ────────────────────────────────────────────────────
|
||||
//
|
||||
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
|
||||
@@ -673,6 +814,12 @@ Singleton {
|
||||
detail: "An application asking for attention is switched to, rather than only highlighted",
|
||||
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "windowSwallow", type: "bool", def: false, group: "multitasking",
|
||||
label: "Hide the terminal that launched a window",
|
||||
detail: "A terminal disappears while an application started from it is open, and returns when it closes",
|
||||
hypr: { path: ["misc", "enable_swallow"], option: "misc:enable_swallow", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
||||
label: "Pointer changes active display",
|
||||
@@ -702,6 +849,14 @@ Singleton {
|
||||
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
||||
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "highContrast", type: "bool", def: false, group: "accessibility",
|
||||
label: "High contrast",
|
||||
detail: "Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here.",
|
||||
// No hypr mapping: this is a GNOME interface setting the portal
|
||||
// republishes as org.freedesktop.appearance contrast, which is what
|
||||
// libadwaita actually reads. DesktopStyle applies it.
|
||||
},
|
||||
{
|
||||
key: "dimInactive", type: "bool", def: false, group: "accessibility",
|
||||
label: "Dim inactive windows",
|
||||
@@ -716,6 +871,22 @@ Singleton {
|
||||
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
||||
},
|
||||
|
||||
// ── Gaming ──────────────────────────────────────────────────────────
|
||||
// What Panama does while a game runs. gamemode tells us when that
|
||||
// starts and stops through its own hook scripts, so these are real
|
||||
// behaviours rather than hints -- and each one is undone afterwards to
|
||||
// whatever it was before, not to a default.
|
||||
{
|
||||
key: "gamingPerformanceProfile", type: "bool", def: true, group: "gaming",
|
||||
label: "Use the performance power profile",
|
||||
detail: "Switches while a game runs and switches back when it exits"
|
||||
},
|
||||
{
|
||||
key: "gamingNotifyOnStart", type: "bool", def: false, group: "gaming",
|
||||
label: "Say when Game Mode engages",
|
||||
detail: "A notification when a game requests it, which is otherwise invisible"
|
||||
},
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────
|
||||
{
|
||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||
@@ -738,6 +909,19 @@ Singleton {
|
||||
// ── Desktop background ──────────────────────────────────────────────
|
||||
// Applied through hyprpaper's IPC. Not an hl.config option, so it has
|
||||
// no `hypr` block; services/Wallpaper.qml owns applying it.
|
||||
|
||||
// One folder, not four. The picker used to sweep ~/Pictures/Wallpapers,
|
||||
// ~/Pictures/Backgrounds, ~/.local/share/backgrounds and
|
||||
// /usr/share/backgrounds, which meant the distribution's stock
|
||||
// images turned up mixed in with the user's own and there was no
|
||||
// way to say "only mine". Where wallpapers live is a thing somebody
|
||||
// knows about their own machine; it is a setting, not a search.
|
||||
{
|
||||
key: "wallpaperDir", type: "string", def: "Pictures/Wallpapers", group: "wallpaper",
|
||||
pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
|
||||
label: "Wallpaper folder",
|
||||
detail: "Where the picker looks. Relative to your home folder unless it starts with /"
|
||||
},
|
||||
{
|
||||
key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
|
||||
// Reaches hyprpaper as the "<output>,<path>" argument form, so a
|
||||
@@ -835,6 +1019,33 @@ Singleton {
|
||||
detail: "Requires your password when the machine wakes"
|
||||
},
|
||||
|
||||
// The same three timings again, for when the machine is running on
|
||||
// its own battery. hypridle has no concept of a power source, so
|
||||
// there is one generated config and panama-idle rebuilds it from
|
||||
// whichever set applies when the charger comes or goes.
|
||||
//
|
||||
// Shorter defaults, because the cost of an idle screen differs: on
|
||||
// wall power it is a screen, on battery it is the rest of your
|
||||
// afternoon. A machine with no battery never reads these at all.
|
||||
{
|
||||
key: "screenBlankMinutesBattery", type: "int", def: 2, min: 0, max: 120, step: 1,
|
||||
unit: "min", group: "idleBattery",
|
||||
label: "Turn the screen off after",
|
||||
detail: "On battery. Blanks the display; nothing is locked yet"
|
||||
},
|
||||
{
|
||||
key: "lockMinutesBattery", type: "int", def: 5, min: 0, max: 240, step: 1,
|
||||
unit: "min", group: "idleBattery",
|
||||
label: "Lock after",
|
||||
detail: "On battery. Requires your password to get back in"
|
||||
},
|
||||
{
|
||||
key: "suspendMinutesBattery", type: "int", def: 20, min: 0, max: 480, step: 5,
|
||||
unit: "min", group: "idleBattery",
|
||||
label: "Suspend after",
|
||||
detail: "On battery, sleeping is what makes the charge last"
|
||||
},
|
||||
|
||||
// ── 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
|
||||
@@ -852,14 +1063,14 @@ Singleton {
|
||||
detail: "A time earlier than the start simply means the next morning"
|
||||
},
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────
|
||||
// ── Color 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
|
||||
// compositor's border colors, because an application toolbar or a
|
||||
// window border still wearing the other scheme is more jarring than
|
||||
// either scheme on its own.
|
||||
{
|
||||
@@ -871,6 +1082,39 @@ Singleton {
|
||||
{ value: "light", label: "Light" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "accentName", type: "enum", def: "blue", group: "appearance",
|
||||
label: "Accent color",
|
||||
detail: "Drives the focused window border, the bar hairline, and every active state",
|
||||
// NAMED accents, not a free color. Each name carries a curated
|
||||
// pair per scheme, because one hex cannot serve both: a color
|
||||
// legible on the dark ground is usually illegible on the light one.
|
||||
// The palette and its measured contrast live in config/Theme.qml,
|
||||
// which is also what stops this list drifting from what is drawn.
|
||||
options: [
|
||||
{ value: "blue", label: "Prism blue" },
|
||||
{ value: "orchid", label: "Orchid" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "amber", label: "Amber" },
|
||||
{ value: "orange", label: "Orange" },
|
||||
{ value: "rose", label: "Rose" },
|
||||
{ value: "slate", label: "Slate" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "themeProfileId", type: "string", def: "moon", group: "appearance",
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||
label: "Selected theme profile",
|
||||
detail: "The shipped or saved theme currently applied to the desktop",
|
||||
internal: true
|
||||
},
|
||||
{
|
||||
key: "themeProfiles", type: "json", def: [], group: "appearance",
|
||||
label: "Saved theme profiles",
|
||||
detail: "Named custom colour schemes and accent pairs",
|
||||
internal: true
|
||||
},
|
||||
|
||||
// ── Application themes ─────────────────────────────────────────────
|
||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||
@@ -978,7 +1222,7 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Application titlebars ──────────────────────────────────────────
|
||||
// These affect applications that honour GNOME's window preferences.
|
||||
// These affect applications that honor GNOME's window preferences.
|
||||
// Hyprland itself has no server-side titlebar buttons, so minimize is
|
||||
// deliberately absent rather than presented as a switch that lies.
|
||||
{
|
||||
@@ -1041,7 +1285,7 @@ Singleton {
|
||||
// ── 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
|
||||
// that can be read for utilization; VitalsWidget hides itself when the
|
||||
// path is unreadable, so a stale value degrades to no readout rather
|
||||
// than a wrong one.
|
||||
{
|
||||
@@ -1057,7 +1301,7 @@ Singleton {
|
||||
// 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.
|
||||
// reading recognizable.
|
||||
{
|
||||
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
@@ -1117,25 +1361,23 @@ Singleton {
|
||||
// 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.
|
||||
|
||||
// Free text rather than three choices. The three were a guess at
|
||||
// where somebody keeps screenshots, and a guess cannot include the
|
||||
// folder the rest of their software already writes to -- which is
|
||||
// the only folder that matters. A path starting with / is taken as
|
||||
// absolute, so a drive that is not under home works too.
|
||||
{
|
||||
key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
|
||||
key: "screenshotDir", type: "string", def: "Pictures/Screenshots", group: "capture",
|
||||
pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
|
||||
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" }
|
||||
]
|
||||
detail: "Where screenshots are saved. Relative to your home folder unless it starts with /"
|
||||
},
|
||||
{
|
||||
key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
|
||||
key: "recordingDir", type: "string", def: "Videos/Screencasts", group: "capture",
|
||||
pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
|
||||
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" }
|
||||
]
|
||||
detail: "Where screen recordings are saved. Relative to your home folder unless it starts with /"
|
||||
},
|
||||
{
|
||||
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
|
||||
@@ -1168,7 +1410,7 @@ Singleton {
|
||||
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||
"io.podman_desktop.PodmanDesktop", "claude-desktop",
|
||||
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
||||
"codex-desktop", "md.obsidian.Obsidian",
|
||||
"com.obsproject.Studio", "steam"
|
||||
]
|
||||
@@ -1192,7 +1434,7 @@ Singleton {
|
||||
|
||||
// ── Display configuration ───────────────────────────────────────────
|
||||
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
||||
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||
// hypr/monitors.lua on top of the shipped values. Color 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.
|
||||
@@ -1219,6 +1461,12 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "welcomeSeen", type: "bool", def: false, group: "internal",
|
||||
internal: true,
|
||||
label: "Welcome shown",
|
||||
detail: "Set once the first-run welcome has been dismissed. Restoring defaults shows it again, which is intended: a reset machine is one somebody wants introduced to them"
|
||||
},
|
||||
{
|
||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||
internal: true,
|
||||
@@ -1305,7 +1553,7 @@ Singleton {
|
||||
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
|
||||
// of these are serialized 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))
|
||||
|
||||
@@ -38,6 +38,13 @@ Singleton {
|
||||
readonly property bool showCpu: DesktopPreferences.get("showCpu")
|
||||
readonly property bool showMemory: DesktopPreferences.get("showMemory")
|
||||
readonly property bool showGpu: DesktopPreferences.get("showGpu")
|
||||
readonly property bool showBattery: DesktopPreferences.get("showBattery")
|
||||
readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
|
||||
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
|
||||
|
||||
// ── Battery ─────────────────────────────────────────────────────────────
|
||||
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
|
||||
readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent")
|
||||
|
||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||
// widget hides itself if the path is missing rather than showing zeros.
|
||||
@@ -62,7 +69,12 @@ Singleton {
|
||||
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────────
|
||||
// Pinned apps, in order, taken from the GNOME dash favourites.
|
||||
// Pinned apps, in order, taken from the GNOME dash favorites.
|
||||
readonly property string dockPosition: DesktopPreferences.get("dockPosition")
|
||||
readonly property var dockScreens: {
|
||||
const stored = DesktopPreferences.get("dockScreens");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||
|
||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pragma Singleton
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tokyo Night Moon — the single source of truth for every colour, radius,
|
||||
// Tokyo Night Moon — the single source of truth for every color, 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.
|
||||
// color; 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,
|
||||
@@ -14,11 +14,12 @@ 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
|
||||
import qs.services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────────
|
||||
// ── Color 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
|
||||
@@ -42,13 +43,38 @@ Singleton {
|
||||
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
|
||||
// ── The accent ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// `accent` is the primary and carries every state meaning (focused, active,
|
||||
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||
// gradient. That restraint is the whole point: the two colors meeting is
|
||||
// the signature, so the second color stops being special the moment it is
|
||||
// used as a flat fill.
|
||||
//
|
||||
// NAMED accents rather than a free color. Each name carries a curated
|
||||
// triple per scheme, because an arbitrary hex cannot work in both: a color
|
||||
// legible on the Moon background is usually illegible on the Day one, and a
|
||||
// picker that lets someone choose an unreadable desktop is not a feature.
|
||||
// Every pair below measures at least 3:1 against the ground it sits on.
|
||||
// This is also GNOME's model, which is the parity being chased.
|
||||
//
|
||||
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
|
||||
// the default.
|
||||
//
|
||||
// The table itself lives in services/ThemeProfileModel.js so a curated
|
||||
// accent and a custom profile are the same kind of record. Its `gnome`
|
||||
// member is the nearest name in GNOME's own accent-color enum, which is a
|
||||
// fixed list of nine we do not get to extend; it is what libadwaita
|
||||
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
|
||||
// accent here recolors them too instead of leaving them in GNOME blue.
|
||||
readonly property var accents: ThemeProfiles.curatedAccents
|
||||
readonly property var activeProfile: ThemeProfiles.activeProfile
|
||||
|
||||
// ThemeProfiles validates every persisted record before it can become
|
||||
// active, so these bindings are both reactive and safe to expose as the
|
||||
// shell-wide colour roles.
|
||||
readonly property color accent: root.activeProfile.accent
|
||||
readonly property color accentSecondary: root.activeProfile.secondary
|
||||
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"
|
||||
@@ -75,6 +101,13 @@ Singleton {
|
||||
// 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
|
||||
|
||||
// Toasts sit a little lighter than a popover you opened on purpose. A
|
||||
// notification arrives unbidden over whatever you were doing, and at full
|
||||
// popover weight it reads as a dialog demanding an answer -- but the 6%
|
||||
// foreground tint it used to have left the text competing with the desktop
|
||||
// behind it. This is the point between the two.
|
||||
readonly property real toastAlpha: root.dark ? 0.86 : 0.94
|
||||
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
|
||||
@@ -84,7 +117,7 @@ Singleton {
|
||||
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 dockIconSize: DesktopPreferences.get("dockIconSize")
|
||||
readonly property int dockPadding: 8
|
||||
readonly property int dockGap: 8
|
||||
readonly property int dockRadius: 20
|
||||
@@ -93,7 +126,6 @@ Singleton {
|
||||
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
|
||||
@@ -108,7 +140,7 @@ Singleton {
|
||||
|
||||
// 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).
|
||||
// hardcoded dark fill Qt will not recolor (see widgets/ThemedIcon.qml).
|
||||
readonly property string fontMono: DesktopPreferences.get("iconFont")
|
||||
|
||||
// Apply to any text whose digits change in place — clocks, percentages,
|
||||
@@ -152,7 +184,7 @@ Singleton {
|
||||
}
|
||||
|
||||
// Blend two tokens — used for hover/pressed states so we never invent a
|
||||
// colour that isn't derived from the palette.
|
||||
// color 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);
|
||||
}
|
||||
@@ -163,7 +195,7 @@ Singleton {
|
||||
// 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.
|
||||
// reads left first and blue is the color that carries meaning.
|
||||
readonly property real prismStart: 0.22
|
||||
readonly property real prismEnd: 0.78
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"_comment": "Generated-by-hand source of truth for every consumer outside QML. See config/dot/quickshell/config/PALETTE.md.",
|
||||
"default": "blue",
|
||||
"accents": {
|
||||
"blue": {
|
||||
"dark": "82aaff",
|
||||
"darkSecondary": "b172b0",
|
||||
"light": "2e7de9",
|
||||
"lightSecondary": "9854f1",
|
||||
"label": "Prism blue",
|
||||
"gnome": "blue"
|
||||
},
|
||||
"orchid": {
|
||||
"dark": "c099ff",
|
||||
"darkSecondary": "fca7ea",
|
||||
"light": "7847bd",
|
||||
"lightSecondary": "9854f1",
|
||||
"label": "Orchid",
|
||||
"gnome": "purple"
|
||||
},
|
||||
"teal": {
|
||||
"dark": "86e1fc",
|
||||
"darkSecondary": "82aaff",
|
||||
"light": "007197",
|
||||
"lightSecondary": "2e7de9",
|
||||
"label": "Teal",
|
||||
"gnome": "teal"
|
||||
},
|
||||
"green": {
|
||||
"dark": "c3e88d",
|
||||
"darkSecondary": "86e1fc",
|
||||
"light": "587539",
|
||||
"lightSecondary": "007197",
|
||||
"label": "Green",
|
||||
"gnome": "green"
|
||||
},
|
||||
"amber": {
|
||||
"dark": "ffc777",
|
||||
"darkSecondary": "ff966c",
|
||||
"light": "8c6c3e",
|
||||
"lightSecondary": "b15c00",
|
||||
"label": "Amber",
|
||||
"gnome": "yellow"
|
||||
},
|
||||
"orange": {
|
||||
"dark": "ff966c",
|
||||
"darkSecondary": "ff757f",
|
||||
"light": "b15c00",
|
||||
"lightSecondary": "c64343",
|
||||
"label": "Orange",
|
||||
"gnome": "orange"
|
||||
},
|
||||
"rose": {
|
||||
"dark": "ff757f",
|
||||
"darkSecondary": "c099ff",
|
||||
"light": "f52a65",
|
||||
"lightSecondary": "9854f1",
|
||||
"label": "Rose",
|
||||
"gnome": "red"
|
||||
},
|
||||
"slate": {
|
||||
"dark": "828bb8",
|
||||
"darkSecondary": "82aaff",
|
||||
"light": "6172b0",
|
||||
"lightSecondary": "2e7de9",
|
||||
"label": "Slate",
|
||||
"gnome": "slate"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
// The isolated screen model begins with both fixture outputs so changing
|
||||
// it below exercises the same reactive topology path as a real hotplug.
|
||||
Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"]
|
||||
|
||||
IpcHandler {
|
||||
target: "displays-test"
|
||||
|
||||
@@ -28,7 +32,14 @@ ShellRoot {
|
||||
canConfirm: Displays.canConfirm,
|
||||
secondsLeft: Displays.secondsLeft,
|
||||
lastError: Displays.lastError,
|
||||
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||
overridden: monitor ? Displays.isOverridden(monitor.name) : false,
|
||||
// The compositor being visually restored is not the service
|
||||
// being done: revert verification keeps its own readback
|
||||
// running for a few ticks, and busy blocks a new apply until
|
||||
// it settles. A caller that only watched `awaiting` raced
|
||||
// this and got a refusal with no error text.
|
||||
settled: !Displays.busy && !Displays.revertVerificationActive
|
||||
&& !Displays.awaitingConfirmation
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,6 +53,7 @@ ShellRoot {
|
||||
function transactionStatus(): string {
|
||||
return JSON.stringify({
|
||||
layout: Displays.currentLayout(),
|
||||
primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name),
|
||||
pending: Displays.pendingRequestedLayout,
|
||||
previous: Displays.pendingPreviousLayout,
|
||||
reverting: Displays.revertExpectedLayout,
|
||||
@@ -74,6 +86,32 @@ ShellRoot {
|
||||
Displays.parse(text, generation);
|
||||
}
|
||||
|
||||
// The restore-on-reconnect decision, without applying anything. The
|
||||
// caller injects a topology with injectReadback first, sets the stored
|
||||
// arrangement here, and reads back what Panama would do about it.
|
||||
// Both arguments are base64. `qs ipc call` splits an argument that
|
||||
// looks like a JSON array of several objects into one argument per
|
||||
// object, so a two-monitor fixture arrives as two arguments and the
|
||||
// call is rejected for arity. Encoding sidesteps the parsing entirely.
|
||||
//
|
||||
// Topology and stored arrangement are set in one call on purpose: a
|
||||
// real compositor query landing between two calls would replace the
|
||||
// injected topology, and the answer would be about this machine's
|
||||
// actual monitor instead of the fixture.
|
||||
function restorePlan(readbackB64: string, storedB64: string): string {
|
||||
Displays.parse(Qt.atob(readbackB64), 0);
|
||||
DesktopPreferences.set("displays", JSON.parse(Qt.atob(storedB64)));
|
||||
const plan = Displays.plannedRestore();
|
||||
return JSON.stringify({
|
||||
action: plan.action,
|
||||
layout: (plan.layout ?? []).map(record => ({
|
||||
name: record.name, mode: record.mode, scale: record.scale,
|
||||
transform: record.transform, x: record.x, y: record.y,
|
||||
primary: record.primary
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function expireApplyVerification(): void {
|
||||
Displays.verificationTimedOut();
|
||||
}
|
||||
@@ -83,7 +121,7 @@ ShellRoot {
|
||||
}
|
||||
|
||||
function refreshIdentityFixture(): string {
|
||||
const modes = Displays.normaliseModes([
|
||||
const modes = Displays.normalizeModes([
|
||||
"[email protected]",
|
||||
"[email protected]"
|
||||
]);
|
||||
@@ -141,5 +179,8 @@ ShellRoot {
|
||||
if (monitor) Displays.forget(monitor.name);
|
||||
}
|
||||
function refresh(): void { Displays.refresh(); }
|
||||
function setScreenModel(names: string): void {
|
||||
Displays.screenOverride = JSON.parse(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Coming from another desktop
|
||||
|
||||
Panama is Hyprland with a shell built to replace a GNOME session, so most of
|
||||
what you know still applies. The differences worth knowing on day one are
|
||||
below. Nothing here is a preference you have to accept: every one of them is
|
||||
adjustable in Settings.
|
||||
|
||||
## Windows arrange themselves
|
||||
|
||||
The biggest change. You do not drag windows into position. Open a second
|
||||
window and the screen splits; open a third and it splits again. This is
|
||||
called tiling, and after a day of it, dragging windows feels like making
|
||||
your own bed in a hotel.
|
||||
|
||||
Two things follow from it:
|
||||
|
||||
- **There is no minimise.** A minimised window is a window you have hidden
|
||||
from a layout that was managing it for you. Use `Super + Shift + X` to
|
||||
push a window to the scratchpad and `Super + X` to bring it back, which is
|
||||
the same idea without the layout losing track of it.
|
||||
- **Floating still exists.** `Super + Ctrl + C` makes a window float above
|
||||
the layout, for the dialog that was never meant to be tiled.
|
||||
|
||||
## Workspaces replace the taskbar
|
||||
|
||||
Instead of many windows on one screen, use many screens. `Alt + 1` through
|
||||
`Alt + 0` jump straight to a workspace; `Alt + Shift + 1` sends the window
|
||||
you are looking at to workspace one. `Super + backtick` shows all of them at
|
||||
once.
|
||||
|
||||
If you came from macOS, this is Spaces with the friction removed. If you came
|
||||
from Windows, it is virtual desktops that are actually worth using because
|
||||
switching is one key rather than three.
|
||||
|
||||
## The launcher is the menu
|
||||
|
||||
There is no applications menu to browse. `Super + Space` opens a search box
|
||||
that finds applications, files, clipboard history, emoji, and does
|
||||
arithmetic. Type three letters of what you want.
|
||||
|
||||
This replaces the Start menu, Spotlight, the GNOME overview's app grid, and
|
||||
the run dialog, and it is faster than all of them because it never asks you
|
||||
to aim at anything.
|
||||
|
||||
## What stayed the same
|
||||
|
||||
`Super + Tab` still switches windows. The bar is still along the top with a
|
||||
clock in the middle and status on the right. There is still a dock. Volume
|
||||
and brightness keys still work, and still work on the lock screen. Copy and
|
||||
paste are unchanged. Your files are where you left them.
|
||||
|
||||
GNOME Settings is still installed for the few panels Panama deliberately does
|
||||
not own, and Panama's own Settings hands off to it where that applies.
|
||||
@@ -0,0 +1,60 @@
|
||||
# The keyboard
|
||||
|
||||
Press `Super + /` at any time to see every shortcut this machine has. That
|
||||
list is generated from the live keymap, so it is never out of date, and it
|
||||
includes anything you have rebound.
|
||||
|
||||
This chapter is the shape behind that list, which is the part worth learning
|
||||
once.
|
||||
|
||||
## Three modifiers, three jobs
|
||||
|
||||
| Modifier | Acts on |
|
||||
|---|---|
|
||||
| `Super` | The window you are looking at |
|
||||
| `Alt` | Workspaces |
|
||||
| `Super + Ctrl` | The layout itself: splitting, floating, swapping |
|
||||
| `Super + Alt` | The application you already have open |
|
||||
|
||||
Almost every shortcut follows from this. `Super + H` moves focus left.
|
||||
`Alt + H` moves to the workspace on the left. `Super + Ctrl + H` swaps the
|
||||
window leftward in the layout.
|
||||
|
||||
## Directions
|
||||
|
||||
`H`, `J`, `K`, `L` mean left, down, up, right, as they do in vim. The arrow
|
||||
keys work everywhere the letters do, so you never have to learn them if you
|
||||
would rather not.
|
||||
|
||||
Add `Shift` to move the window rather than the focus. `Super + Shift + L`
|
||||
takes the current window and moves it to the right.
|
||||
|
||||
## Opening versus going to
|
||||
|
||||
`Super + T` opens a terminal. It opens another one every time, which on a
|
||||
tiling desktop is the normal way to work rather than an accident.
|
||||
|
||||
`Super + Alt + T` goes to a terminal you already have, wherever it is, and
|
||||
opens one only if there is none. The same pair works for the editor, browser,
|
||||
files, calculator and mail.
|
||||
|
||||
So the plain key means "give me one" and adding `Alt` means "take me to the
|
||||
one I have".
|
||||
|
||||
## The handful worth memorising first
|
||||
|
||||
| Keys | What it does |
|
||||
|---|---|
|
||||
| `Super + Space` | Find and open anything |
|
||||
| `Super + T` | Terminal (another one) |
|
||||
| `Super + Alt + T` | The terminal you already have |
|
||||
| `Super + Q` | Close the window |
|
||||
| `Super + backtick` | Every window, every workspace |
|
||||
| `Super + I` | Settings |
|
||||
| `Super + /` | Every other shortcut |
|
||||
|
||||
## Changing them
|
||||
|
||||
Settings has a Shortcuts page listing every bind, each of which can be
|
||||
reassigned. A rebind moves the shortcut and cannot change what it does, so
|
||||
there is no way to make a key do something unexpected by editing it.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Windows and workspaces
|
||||
|
||||
## How the split is chosen
|
||||
|
||||
A new window splits the space of the window that had focus. Whether it splits
|
||||
beside or below depends on which way that space is longer, which usually
|
||||
matches what you were about to do anyway.
|
||||
|
||||
When it does not, `Super + Ctrl + G` flips the split of the focused window,
|
||||
and `Super + Ctrl + Z` and `Super + Ctrl + V` decide the direction the *next*
|
||||
window will take before you open it.
|
||||
|
||||
## Resizing
|
||||
|
||||
`Super + Shift` plus `Y`, `O`, `B`, `M` adjusts width; `I`, `U`, `N` and
|
||||
comma adjust height. These are inverted from what their letters suggest, on
|
||||
purpose, and the reason is written in `config/dot/hypr/keybinds.lua` next to
|
||||
the binds.
|
||||
|
||||
For anything finer, hold `Super` and drag with the right mouse button.
|
||||
|
||||
## Fullscreen and floating
|
||||
|
||||
`Super + U` makes the focused window fullscreen. `Super + Ctrl + C` lifts it
|
||||
out of the layout to float, and `Super + Ctrl + Shift + C` pins it so it
|
||||
follows you between workspaces. A floating window can be moved by holding
|
||||
`Super` and dragging with the left button.
|
||||
|
||||
## Workspaces
|
||||
|
||||
Ten of them, on `Alt + 1` through `Alt + 0`. `Alt + H` and `Alt + L` step
|
||||
between neighbours, and `Alt + Shift` plus a number sends the current window
|
||||
somewhere without following it.
|
||||
|
||||
On a machine with more than one display, workspaces belong to whichever
|
||||
screen has focus. If you would rather pin workspaces one through ten to your
|
||||
main display and give the second screen its own, that is a switch on the
|
||||
Desktop settings page.
|
||||
|
||||
## Projects
|
||||
|
||||
A project is the set of windows you open together: which applications, which
|
||||
workspace each was on, and for a terminal, which directory it was in.
|
||||
|
||||
Arrange things the way you want them, then run **Save Layout as Project**
|
||||
from the launcher. **Open Project** lays it out again on free workspaces, so
|
||||
it never lands on top of what you are already doing. An application that
|
||||
refuses to open twice is moved into place rather than launched again.
|
||||
@@ -0,0 +1,56 @@
|
||||
# When something breaks
|
||||
|
||||
## Start here
|
||||
|
||||
Run **Check System Health** from the launcher, or open Settings and go to
|
||||
System Health. It reports what is actually running rather than what was
|
||||
installed, and it can repair several things itself.
|
||||
|
||||
From a terminal, the same check is `panama doctor`.
|
||||
|
||||
## The shell is gone
|
||||
|
||||
If the bar and dock disappear, the Quickshell process died. **Restart Shell**
|
||||
from the launcher brings it back. If the launcher is also gone, press
|
||||
`Super + Shift + R` for the fallback launcher, which depends on nothing but
|
||||
itself and exists for exactly this.
|
||||
|
||||
From a terminal: `qs -d` starts the shell again.
|
||||
|
||||
## A shortcut stopped working
|
||||
|
||||
Check `Super + /` first: if the shortcut shows a different chord than you
|
||||
expect, something rebound it. The Shortcuts settings page can reset any bind,
|
||||
or all of them at once.
|
||||
|
||||
If the whole keymap is missing from that list, the compositor config failed
|
||||
to load. `Hyprland --verify-config` says why without touching your session.
|
||||
|
||||
## The screen resolution is wrong
|
||||
|
||||
Settings has a Displays page. Every change there reverts itself after fifteen
|
||||
seconds unless you confirm it, so a mode your monitor cannot show cannot
|
||||
strand you. If you are already stranded, `hyprctl monitors` from a terminal
|
||||
shows what is applied.
|
||||
|
||||
## Something asked for a password and I do not know why
|
||||
|
||||
Panama's password prompt names the action that asked. If it was started by an
|
||||
agent or a script through `panama-sudo`, it also shows a stated reason,
|
||||
labelled as an unverified claim, beside the real action.
|
||||
|
||||
If you did not expect it at all, cancel. Nothing that needs privileges is
|
||||
harmed by being refused.
|
||||
|
||||
## After an update, something is not right
|
||||
|
||||
`panama migrate` applies repairs this machine has not had yet. It is safe to
|
||||
run at any time and does nothing when there is nothing to do.
|
||||
|
||||
If an update went badly, the Snapshots settings page can roll the system back
|
||||
where btrfs snapshots are configured.
|
||||
|
||||
## Reporting it
|
||||
|
||||
`panama doctor` produces a redacted summary suitable for pasting into an
|
||||
issue. It deliberately omits tokens, hostnames and account details.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Making it yours
|
||||
|
||||
## Settings
|
||||
|
||||
`Super + I`. Thirty-odd pages covering displays, sound, network, appearance,
|
||||
power, printers, users, sharing, storage, updates and the rest. The search
|
||||
box at the top searches the settings themselves rather than just page names,
|
||||
so if you know what you want to change you can type it.
|
||||
|
||||
## Appearance
|
||||
|
||||
Panama ships Tokyo Night Moon in a light and a dark variant, with eight
|
||||
accent colours. Changing either repaints the shell, the terminal, GTK
|
||||
applications, the lock screen and the system monitor at once.
|
||||
|
||||
The wallpaper is per-monitor if you want it to be.
|
||||
|
||||
## Applications
|
||||
|
||||
`panama apps` in a terminal offers the optional application categories the
|
||||
installer asked about, so a machine can acquire Slack in March without having
|
||||
wanted Discord in January.
|
||||
|
||||
`panama app` builds the handful of applications no repository carries. It is
|
||||
deliberately not part of installing, because a source build is slow and
|
||||
depends on an upstream that moves.
|
||||
|
||||
## Autostart and defaults
|
||||
|
||||
The Applications settings page controls which application opens which kind of
|
||||
file, and what starts when you log in.
|
||||
|
||||
## Going further
|
||||
|
||||
The compositor is configured in Lua under `~/.config/hypr`, and every file
|
||||
there explains what it does and why. `overrides.lua` is loaded last and is
|
||||
the intended place for per-machine changes, so you can adjust anything
|
||||
without editing a file the repository will update.
|
||||
|
||||
The shell is QML under `~/.config/quickshell`. Both directories are symlinks
|
||||
into the Panama repository, so an edit is a change to your checkout and
|
||||
`panama update` will offer to commit it.
|
||||
@@ -1,9 +1,9 @@
|
||||
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
|
||||
// case. Run by tests/quickshell/migrations-contract.sh.
|
||||
// case. Run by tests/quickshell/migrations-contract.
|
||||
//
|
||||
// Fixture steps rather than the real list: the real one is empty until the
|
||||
// first breaking schema change, and a mechanism that has never been run against
|
||||
// a failing step is not one to discover the behaviour of during an upgrade.
|
||||
// a failing step is not one to discover the behavior of during an upgrade.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
@@ -15,7 +15,11 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
implicitWidth: 350
|
||||
@@ -25,10 +29,18 @@ PanelWindow {
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
// Deliberately does not read Capture.recordingSeconds (or anything else
|
||||
// that ticks once a second): this array is a plain JS array, not an
|
||||
// identity-preserving model, so any dependency that changes every second
|
||||
// would make the whole thing re-derive every second, and the Repeater
|
||||
// below would destroy and recreate every row -- including one the user
|
||||
// might be hovering or about to click. The set of activities should only
|
||||
// change when an activity actually starts or stops. Elapsed time is
|
||||
// rendered by each row's own Text binding instead, further down.
|
||||
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 });
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", 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)
|
||||
@@ -38,11 +50,12 @@ PanelWindow {
|
||||
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);
|
||||
// Pure formatter, no ticking property read here -- callers decide what
|
||||
// seconds value to pass, and only they take on the per-second dependency.
|
||||
function formatElapsed(totalSeconds: int): string {
|
||||
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
@@ -164,7 +177,11 @@ PanelWindow {
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: activityRow.modelData.detail
|
||||
// Only this Text re-evaluates every second while
|
||||
// recording -- Capture.recordingSeconds is read
|
||||
// here, not in the parent `activities` array, so
|
||||
// the row itself is never torn down for a tick.
|
||||
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// How much of the Claude subscription is gone, beside the other vitals.
|
||||
//
|
||||
// One number: whichever window is closer to its limit, because that is the one
|
||||
// about to interrupt you.
|
||||
//
|
||||
// Hidden unless asked for AND the collector has real numbers. A bar indicator
|
||||
// reading "unknown" is worse than an empty space, and this is off by default:
|
||||
// it is a coding-tool readout, not something a general-purpose desktop shows
|
||||
// without being asked.
|
||||
//
|
||||
// The glyph and the number live in their own Row, the same shape VitalsField
|
||||
// uses. Anchoring them straight into Pill's layout Row instead made each child
|
||||
// centre against a Row whose height those same children determine, and they
|
||||
// came out sitting off-centre against the rest of the bar.
|
||||
//
|
||||
// Clickable, because a readout you cannot ask anything of is furniture. Left
|
||||
// click opens the settings that govern it; hovering says which window the
|
||||
// number belongs to and when it resets.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
visible: Settings.showAgentUsage && AgentUsage.available
|
||||
|
||||
onActivated: ShellState.openSettings("appearance")
|
||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||
|
||||
Row {
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F1719}" // md-robot-outline
|
||||
color: {
|
||||
if (AgentUsage.headline >= 90) return Theme.danger;
|
||||
if (AgentUsage.headline >= 75) return Theme.warn;
|
||||
return Theme.fgDim;
|
||||
}
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: AgentUsage.headline + "%"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.features: Theme.tabularFigures
|
||||
// Fixed and right-aligned so the bar does not shuffle sideways when
|
||||
// the number gains a digit, exactly as VitalsField does.
|
||||
horizontalAlignment: Text.AlignRight
|
||||
width: 30
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
// Bar {}
|
||||
// }
|
||||
//
|
||||
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather centre,
|
||||
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather center,
|
||||
// media + tray + status cluster right.
|
||||
|
||||
import Quickshell
|
||||
@@ -78,12 +78,14 @@ PanelWindow {
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
AgentUsageWidget {}
|
||||
|
||||
VitalsWidget {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// ── Centre ──────────────────────────────────────────────────────────
|
||||
// ── Center ──────────────────────────────────────────────────────────
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// The centre clock, kept in the same place and the same format GNOME had it:
|
||||
// The center 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.
|
||||
|
||||
@@ -35,7 +35,7 @@ Pill {
|
||||
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
|
||||
// Seconds tick once a second in the center of the bar; without tabular
|
||||
// figures the whole clock shifts sideways on every digit change.
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
|
||||
@@ -62,7 +62,7 @@ Pill {
|
||||
if (!root.wifiNetwork)
|
||||
return "\u{F092D}"; // md-wifi_strength_off
|
||||
|
||||
// NetworkManager reports strength 0-100; some backends normalise to
|
||||
// NetworkManager reports strength 0-100; some backends normalize 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;
|
||||
@@ -84,6 +84,15 @@ Pill {
|
||||
}
|
||||
}
|
||||
|
||||
// A tunnel that is up changes what every connection means, so it earns a
|
||||
// permanent glyph while active -- and its absence is the resting state,
|
||||
// same shape as Bluetooth below.
|
||||
StatusGlyph {
|
||||
visible: Vpn.anyActive
|
||||
glyph: "\u{F0582}" // md-vpn
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
StatusGlyph {
|
||||
glyph: {
|
||||
if (root.muted || root.volume <= 0)
|
||||
@@ -116,4 +125,50 @@ Pill {
|
||||
glyph: "\u{F03F2}" // md-cellphone-link
|
||||
color: Theme.cyan
|
||||
}
|
||||
|
||||
// Battery. Absent entirely on a desktop: `available` is false until a
|
||||
// battery has actually been read, so this is not a zero that looks like a
|
||||
// flat cell. Same shape as the graphics field in VitalsWidget, which gates
|
||||
// on both the preference and the hardware.
|
||||
//
|
||||
// One color for the icon and the number beside it: two different colors
|
||||
// would read as two indicators.
|
||||
readonly property color batteryColor: {
|
||||
if (Battery.critical) return Theme.danger;
|
||||
if (Battery.low) return Theme.warn;
|
||||
if (Battery.charging) return Theme.ok;
|
||||
return Theme.fg;
|
||||
}
|
||||
|
||||
StatusGlyph {
|
||||
visible: Settings.showBattery && Battery.available
|
||||
glyph: {
|
||||
if (Battery.charging)
|
||||
return "\u{F0084}"; // md-battery_charging
|
||||
const level = Math.round(Battery.percent / 10) * 10;
|
||||
if (level >= 100) return "\u{F0079}"; // md-battery
|
||||
if (level <= 0) return "\u{F008E}"; // md-battery_outline
|
||||
// md-battery_10 .. md-battery_90 are consecutive from F007A.
|
||||
return String.fromCodePoint(0xF007A + (level / 10) - 1);
|
||||
}
|
||||
color: root.batteryColor
|
||||
}
|
||||
|
||||
// The exact number, for the people who ask the icon to be more specific --
|
||||
// GNOME's "Show Battery Percentage", living under the same gates as the
|
||||
// icon it annotates.
|
||||
Text {
|
||||
visible: Settings.showBattery && Settings.showBatteryPercent && Battery.available
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Math.round(Battery.percent) + "%"
|
||||
color: root.batteryColor
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// One glyph + percentage pair inside VitalsWidget. Split out so the three
|
||||
// fields cannot drift apart in spacing, width or colour thresholds.
|
||||
// fields cannot drift apart in spacing, width or color thresholds.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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
|
||||
// Values are color-coded rather than graphed: at bar size a number you can
|
||||
// glance at beats a sparkline you have to squint at.
|
||||
|
||||
import QtQuick
|
||||
|
||||
@@ -36,7 +36,7 @@ Item {
|
||||
|
||||
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.
|
||||
// optically centered 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.
|
||||
@@ -59,7 +59,7 @@ Item {
|
||||
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.
|
||||
// in the bar that is always colored, so it is where the pair belongs.
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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
|
||||
// control on top, and below it the shutter dead-center with "Show Pointer" on
|
||||
// the left and the screenshot/screencast switch on the right.
|
||||
|
||||
import QtQuick
|
||||
@@ -13,7 +13,7 @@ Rectangle {
|
||||
|
||||
signal captureRequested
|
||||
|
||||
// Set by the overlay so the shutter can grey out when Window mode has
|
||||
// Set by the overlay so the shutter can gray out when Window mode has
|
||||
// nothing pickable (no Hyprland, or an empty workspace).
|
||||
property bool captureEnabled: true
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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
|
||||
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolor
|
||||
// them, so they'd be invisible on this dark overlay.
|
||||
|
||||
import QtQuick
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Selection mode: a GNOME-style rubber band over the frozen screen.
|
||||
//
|
||||
// Behaviours copied from GNOME 42: the overlay opens with a sensible centred
|
||||
// Behaviors copied from GNOME 42: the overlay opens with a sensible centered
|
||||
// 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.
|
||||
@@ -52,7 +52,7 @@ Item {
|
||||
root.resetSelection();
|
||||
}
|
||||
|
||||
// Centred, roughly the middle half of the screen.
|
||||
// Centered, roughly the middle half of the screen.
|
||||
function resetSelection(): void {
|
||||
if (root.width <= 0 || root.height <= 0)
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Every shortcut, on one keypress.
|
||||
//
|
||||
// This is a different job from the Shortcuts settings page, which answers "how
|
||||
// do I change this" and is worth opening a window for. This answers "what can
|
||||
// I press", which is a question you have while your hands are already on the
|
||||
// keyboard and which needs answering in under a second. So it is an overlay,
|
||||
// it is one key, and it closes on the same key.
|
||||
//
|
||||
// It reads the live keymap rather than a written-down copy: Keybinds.grouped()
|
||||
// comes from `hyprctl binds`, so a shortcut somebody rebound in Settings shows
|
||||
// its new chord here without anything being kept in sync. Categories come from
|
||||
// hypr/keybinds.lua's manifest -- see Keybinds.categoryManifest.
|
||||
//
|
||||
// Three columns, balanced by how many shortcuts each category holds. Not a
|
||||
// Flow: with about 120 binds across six wildly uneven categories -- Windows has
|
||||
// forty, Session has three -- a Flow wraps into as many columns as it likes and
|
||||
// the result is unreadable. The columns are computed instead.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
readonly property bool open: ShellState.cheatsheetOpen
|
||||
|
||||
readonly property int columnCount: 3
|
||||
|
||||
// Categories dealt into columns, longest first, each going to whichever
|
||||
// column is currently shortest. Sorting first is what stops the fortieth
|
||||
// window bind landing in a column that already has thirty workspace ones.
|
||||
readonly property var columns: {
|
||||
const buckets = [];
|
||||
for (let index = 0; index < root.columnCount; index++)
|
||||
buckets.push({ groups: [], weight: 0 });
|
||||
|
||||
const groups = Array.from(Keybinds.grouped())
|
||||
.sort((a, b) => b.binds.length - a.binds.length);
|
||||
|
||||
for (const group of groups) {
|
||||
let target = buckets[0];
|
||||
for (const bucket of buckets) {
|
||||
if (bucket.weight < target.weight)
|
||||
target = bucket;
|
||||
}
|
||||
target.groups.push(group);
|
||||
// Two lines of overhead per heading, so a column of many small
|
||||
// categories is not treated as shorter than it looks.
|
||||
target.weight += group.binds.length + 2;
|
||||
}
|
||||
return buckets.map(bucket => bucket.groups);
|
||||
}
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
|
||||
// this. The dimming is painted here rather than added to the overlay rule,
|
||||
// the way PolkitPrompt does it, because this is a card on a scrim rather
|
||||
// than a full-screen takeover.
|
||||
WlrLayershell.namespace: "qs-popover-cheatsheet"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: root.open
|
||||
? WlrKeyboardFocus.Exclusive
|
||||
: WlrKeyboardFocus.None
|
||||
|
||||
// Stays mapped for the length of the close animation, or it vanishes
|
||||
// instantly and only the opening is ever seen.
|
||||
property bool mapped: false
|
||||
visible: root.mapped
|
||||
|
||||
onOpenChanged: {
|
||||
if (root.open) {
|
||||
unmapTimer.stop();
|
||||
root.mapped = true;
|
||||
// The keymap can change while the session runs: a rebind in
|
||||
// Settings, or a compositor reload.
|
||||
Keybinds.refresh();
|
||||
} else {
|
||||
unmapTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: unmapTimer
|
||||
interval: Theme.durNormal
|
||||
onTriggered: root.mapped = false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
opacity: root.open ? 1 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
|
||||
|
||||
// Anywhere outside the card closes it, which is what every other
|
||||
// transient surface on this desktop does.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: ShellState.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(root.width - 120, 1240)
|
||||
// Sized from its content, capped at the screen. Deliberately NOT
|
||||
// computed from a child that fills it: that is a circular binding, and
|
||||
// it produced a card taller than the display with its contents running
|
||||
// off the bottom edge.
|
||||
height: Math.min(root.height - 120, header.height + body.contentHeight + 72)
|
||||
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
|
||||
opacity: root.open ? 1 : 0
|
||||
scale: root.open ? 1 : 0.98
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
|
||||
Behavior on scale { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: Theme.popoverRadius
|
||||
}
|
||||
|
||||
// Clicks on the card itself must not fall through to the scrim.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Item {
|
||||
id: header
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 28
|
||||
height: title.implicitHeight
|
||||
|
||||
Text {
|
||||
id: title
|
||||
text: "Keyboard shortcuts"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: title.verticalCenter
|
||||
text: Keybinds.loaded
|
||||
? Keybinds.binds.length + " shortcuts · Esc to close"
|
||||
: "Reading the keymap…"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
// Scrolls only when it has to. On a display tall enough for the whole
|
||||
// keymap this never moves, which is the common case and the one worth
|
||||
// optimising for -- a cheatsheet you have to scroll is a document.
|
||||
Flickable {
|
||||
id: body
|
||||
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: 18
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 28
|
||||
anchors.rightMargin: 28
|
||||
anchors.bottomMargin: 28
|
||||
|
||||
contentWidth: width
|
||||
contentHeight: columnRow.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Row {
|
||||
id: columnRow
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
|
||||
Repeater {
|
||||
model: root.columns
|
||||
|
||||
Column {
|
||||
required property var modelData
|
||||
width: (columnRow.width - columnRow.spacing * (root.columnCount - 1)) / root.columnCount
|
||||
spacing: 20
|
||||
|
||||
Repeater {
|
||||
model: parent.modelData
|
||||
|
||||
CheatsheetGroup {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
name: modelData.name
|
||||
binds: modelData.binds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escape closes, like every other dialog here. The same chord that opened
|
||||
// it also closes it, which ShellState.toggle handles.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// One category of shortcuts, as a column.
|
||||
//
|
||||
// Deliberately plain: a chord on the left, what it does on the right, and no
|
||||
// separators between rows. The cheatsheet is read at a glance while a key is
|
||||
// held, so anything that draws the eye away from the two columns is in the way.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
required property string name
|
||||
required property var binds
|
||||
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
text: root.name
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.6
|
||||
bottomPadding: 5
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.binds
|
||||
|
||||
Item {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
height: chord.implicitHeight + 5
|
||||
|
||||
// The chord is monospaced and tabular so a column of them lines up
|
||||
// rather than jittering with the width of each key name. This is
|
||||
// the one place in the shell where a monospaced face is correct:
|
||||
// it is showing keys, not prose.
|
||||
Text {
|
||||
id: chord
|
||||
width: 168
|
||||
text: modelData.chord
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.features: Theme.tabularFigures
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: chord.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: chord.verticalCenter
|
||||
text: modelData.description
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module qs.modules.cheatsheet
|
||||
Cheatsheet 1.0 Cheatsheet.qml
|
||||
CheatsheetGroup 1.0 CheatsheetGroup.qml
|
||||
@@ -20,8 +20,11 @@ PanelWindow {
|
||||
top: true
|
||||
right: true
|
||||
}
|
||||
// The gap ALONE, not the bar height plus the gap -- see the note in
|
||||
// QuickSettings.qml: exclusiveZone 0 already places this below the bar's
|
||||
// reserved zone, so adding the bar height counted it twice.
|
||||
margins {
|
||||
top: Theme.barHeight + Theme.barGap * 2
|
||||
top: Theme.barGap * 2
|
||||
right: Theme.barSideMargin
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// 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 and the panel below it never jumps. Days belonging to the neighboring
|
||||
// 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.
|
||||
// panel. Up to three source-colored markers show whether a date has events.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
@@ -134,7 +134,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Centred on the row rather than between the pill and the arrows,
|
||||
// Centered 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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// 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
|
||||
// dropping from the top center 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
|
||||
// Layer-shell centers a surface on whichever axis it is not anchored to, so
|
||||
// anchoring only `top` is what puts it in the middle.
|
||||
|
||||
import QtQuick
|
||||
@@ -28,7 +28,11 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 760
|
||||
@@ -87,7 +91,7 @@ PanelWindow {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// own center — the panel should look like it came from the bar.
|
||||
transform: Scale {
|
||||
id: openScale
|
||||
origin.x: surface.width / 2
|
||||
|
||||
@@ -22,10 +22,34 @@ PanelWindow {
|
||||
property var modelData: null
|
||||
screen: root.modelData
|
||||
|
||||
// Which edge this dock lives on, and everything that follows from it. The
|
||||
// bottom case is unchanged in every particular: same anchors, same
|
||||
// geometry, same slide -- so a machine that never touches the setting sees
|
||||
// exactly the dock it had.
|
||||
readonly property string position: Settings.dockPosition
|
||||
readonly property bool vertical: root.position === "left" || root.position === "right"
|
||||
|
||||
// Only on the screens asked for. An empty list means all of them, which is
|
||||
// what a single-monitor machine wants and what an unplugged display should
|
||||
// not be able to change.
|
||||
readonly property bool onThisScreen: {
|
||||
const wanted = Settings.dockScreens;
|
||||
if (!wanted || wanted.length === 0)
|
||||
return true;
|
||||
return wanted.indexOf(String(root.screen?.name ?? "")) >= 0;
|
||||
}
|
||||
|
||||
visible: root.onThisScreen
|
||||
|
||||
// A dock spans the edge it lives on, which means anchoring BOTH ends of
|
||||
// that edge: bottom+left+right across the screen, or top+bottom plus one
|
||||
// side down it. Anchoring only one end leaves the surface free to collapse
|
||||
// to its implicit size on that axis -- a side dock came out one pixel tall.
|
||||
anchors {
|
||||
top: root.vertical
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
left: root.position !== "right"
|
||||
right: root.position !== "left"
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
@@ -42,18 +66,31 @@ PanelWindow {
|
||||
// ── 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 edgeMargin: Theme.barGap
|
||||
readonly property int tooltipSpace: 34
|
||||
|
||||
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
|
||||
// Only the axis the dock is thin on gets an implicit size; the other is
|
||||
// spanned by the anchors above. Setting both would fight them.
|
||||
implicitHeight: root.vertical ? 0 : tooltipSpace + body.implicitHeight + edgeMargin
|
||||
implicitWidth: root.vertical ? tooltipSpace + body.implicitWidth + edgeMargin : 0
|
||||
|
||||
// ── Intellihide ─────────────────────────────────────────────────────────
|
||||
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
||||
// scope a per-screen bar to its own screen. Falls back to null when this
|
||||
// Dock is created standalone (no `screen` set) rather than via Variants.
|
||||
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Deliberately this instance's own monitor's active workspace, not the
|
||||
// globally-focused one -- with one Dock per screen, keying off the global
|
||||
// focus would make focusing an empty workspace on monitor A hide the dock
|
||||
// on monitor B even though B's own workspace is still busy.
|
||||
readonly property bool workspaceOccupied: {
|
||||
const ws = Hyprland.focusedWorkspace;
|
||||
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
|
||||
return !!ws && ws.toplevels.values.length > 0;
|
||||
}
|
||||
|
||||
@@ -83,15 +120,31 @@ PanelWindow {
|
||||
onTriggered: root.revealed = false
|
||||
}
|
||||
|
||||
// Other modules (the bar, the capture overlay) read this.
|
||||
onRevealedChanged: ShellState.dockRevealed = revealed
|
||||
// Other modules (the bar, the capture overlay) read this. It is one
|
||||
// shared flag but there is one Dock per monitor, so only the instance on
|
||||
// the currently-focused monitor is allowed to write it -- otherwise
|
||||
// whichever instance last changed reveal state would stomp the others,
|
||||
// and a reader would see an arbitrary monitor's value. This scopes the
|
||||
// flag to mean "is the dock revealed on the monitor the user is on",
|
||||
// which is what a capture overlay or the bar actually care about.
|
||||
// (A true per-monitor flag would need ShellState.dockRevealed itself to
|
||||
// become keyed by screen, which is out of scope here -- see the report.)
|
||||
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
|
||||
|
||||
onRevealedChanged: root._syncShellState()
|
||||
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
||||
|
||||
function _syncShellState(): void {
|
||||
if (root.isFocusedMonitorInstance)
|
||||
ShellState.dockRevealed = root.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;
|
||||
root._syncShellState();
|
||||
}
|
||||
|
||||
// ── Input region ────────────────────────────────────────────────────────
|
||||
@@ -114,22 +167,86 @@ PanelWindow {
|
||||
id: pointer
|
||||
}
|
||||
|
||||
// Revealed: the dock plus everything between it and the edge, so
|
||||
// crossing the gap does not count as leaving. Hidden: a sliver along
|
||||
// the edge the dock lives on, which is the only thing that can bring
|
||||
// it back -- every other click passes through to the window beneath.
|
||||
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
|
||||
|
||||
x: {
|
||||
if (!root.revealed)
|
||||
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
|
||||
// A horizontal dock is centred on its edge, so the region has
|
||||
// to start where the body starts -- starting at 0 puts it over
|
||||
// the left of the screen while the pointer that summoned the
|
||||
// dock is in the middle, and the hover drops on the very frame
|
||||
// the dock arrives. A vertical dock reaches from the body out
|
||||
// to its own edge, which is x 0 on the left and the body on
|
||||
// the right.
|
||||
if (!root.vertical)
|
||||
return body.x;
|
||||
return root.position === "right" ? body.x : 0;
|
||||
}
|
||||
y: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
||||
return body.y;
|
||||
}
|
||||
width: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? root.revealStripHeight : surface.width;
|
||||
return root.vertical
|
||||
? (root.position === "right" ? surface.width - body.x : body.x + body.width)
|
||||
: body.width;
|
||||
}
|
||||
height: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? surface.height : root.revealStripHeight;
|
||||
return root.vertical ? body.height : surface.height - body.y;
|
||||
}
|
||||
}
|
||||
|
||||
DockBody {
|
||||
id: body
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
// Slides off the bottom edge when hidden.
|
||||
y: root.revealed ? root.tooltipSpace : surface.height
|
||||
onContextMenuRequested: (anchorItem, entry) => {
|
||||
dockContextMenu.anchorItem = anchorItem;
|
||||
dockContextMenu.entry = entry;
|
||||
dockContextMenu.visible = true;
|
||||
}
|
||||
|
||||
vertical: root.vertical
|
||||
leftSide: root.position === "left"
|
||||
|
||||
// Both axes are computed rather than anchored. Anchoring the centre
|
||||
// on one axis and binding a position on the other looks tidier and
|
||||
// is a conflict: an anchored centre owns that coordinate, so the
|
||||
// binding beside it is fighting for the same value.
|
||||
//
|
||||
// Centred on the long axis; on the short one it sits a tooltip's
|
||||
// width in from the edge when revealed, and off-screen when not.
|
||||
x: {
|
||||
if (!root.vertical)
|
||||
return (surface.width - width) / 2;
|
||||
if (root.position === "left")
|
||||
return root.revealed ? root.tooltipSpace : -width;
|
||||
return root.revealed ? surface.width - width - root.tooltipSpace : surface.width;
|
||||
}
|
||||
y: {
|
||||
if (root.vertical)
|
||||
return (surface.height - height) / 2;
|
||||
return root.revealed ? root.tooltipSpace : surface.height;
|
||||
}
|
||||
opacity: root.revealed ? 1 : 0
|
||||
|
||||
Behavior on x {
|
||||
NumberAnimation {
|
||||
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
|
||||
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -149,4 +266,8 @@ PanelWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DockContextMenu {
|
||||
id: dockContextMenu
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,30 +111,52 @@ Rectangle {
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
|
||||
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
|
||||
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
|
||||
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
|
||||
|
||||
// The item the tooltip is currently describing, or null.
|
||||
property Item hoveredItem: null
|
||||
signal contextMenuRequested(Item anchorItem, var entry)
|
||||
|
||||
Row {
|
||||
id: row
|
||||
// Set by the Dock. A side dock runs the same strip down the screen instead
|
||||
// of across it.
|
||||
property bool vertical: false
|
||||
|
||||
// Which way a tooltip points on a side dock: away from the screen edge, so
|
||||
// it never opens off-screen.
|
||||
property bool leftSide: true
|
||||
|
||||
// Explicit rather than left to Grid's wrapping. This is always one line, so
|
||||
// saying how many cells it holds is both simpler to read and immune to
|
||||
// Grid's default column count quietly wrapping a long dock.
|
||||
readonly property int cellCount: 2 + (root.items ? root.items.length : 0)
|
||||
|
||||
// A Grid rather than a Row so one declaration serves both orientations.
|
||||
// Row and Column would each need their own children, and the cross-axis
|
||||
// anchors that centre items in a Row (verticalCenter) are the wrong axis in
|
||||
// a Column -- Grid centres through its own alignment properties instead,
|
||||
// which is the same result without the anchors positioners disallow.
|
||||
Grid {
|
||||
id: strip
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.dockGap
|
||||
rows: root.vertical ? root.cellCount : 1
|
||||
columns: root.vertical ? 1 : root.cellCount
|
||||
horizontalItemAlignment: Grid.AlignHCenter
|
||||
verticalItemAlignment: Grid.AlignVCenter
|
||||
|
||||
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.
|
||||
// Separator between the launcher and the apps, as in GNOME's dash. It
|
||||
// turns with the dock: a hairline across a column, down a row.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 1
|
||||
height: Theme.dockIconSize * 0.7
|
||||
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
|
||||
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.14)
|
||||
}
|
||||
@@ -146,10 +168,10 @@ Rectangle {
|
||||
id: dockItem
|
||||
required property var modelData
|
||||
app: modelData
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onEntered: root.hoveredItem = dockItem
|
||||
onExited: if (root.hoveredItem === dockItem)
|
||||
root.hoveredItem = null
|
||||
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,16 +194,26 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Centred on the hovered item along the dock's own axis, and placed just
|
||||
// outside the edge it lives on. Reads the item's position directly
|
||||
// (rather than mapToItem) so the binding re-evaluates when the strip
|
||||
// reflows.
|
||||
x: {
|
||||
if (!root.hoveredItem)
|
||||
return 0;
|
||||
const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
if (root.vertical)
|
||||
return root.leftSide ? root.width + 8 : -width - 8;
|
||||
const centre = strip.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
|
||||
}
|
||||
y: -height - 8
|
||||
y: {
|
||||
if (!root.hoveredItem)
|
||||
return -height - 8;
|
||||
if (!root.vertical)
|
||||
return -height - 8;
|
||||
const centre = strip.y + root.hoveredItem.y + root.hoveredItem.height / 2;
|
||||
return Math.max(4, Math.min(root.height - height - 4, centre - height / 2));
|
||||
}
|
||||
|
||||
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
|
||||
height: tipLabel.implicitHeight + 8
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// The dock's app menu. Desktop-entry actions stay first; the shell-owned
|
||||
// configuration route is deliberately last so it never displaces app actions.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.modules.bar
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PopupWindow {
|
||||
id: root
|
||||
|
||||
property Item anchorItem: null
|
||||
property var entry: null
|
||||
|
||||
anchor.item: root.anchorItem
|
||||
anchor.edges: Edges.Top | Edges.Left
|
||||
anchor.gravity: Edges.Top | Edges.Right
|
||||
anchor.margins.bottom: 8
|
||||
|
||||
implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240)
|
||||
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
|
||||
color: "transparent"
|
||||
visible: false
|
||||
grabFocus: true
|
||||
|
||||
Rectangle {
|
||||
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
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Column {
|
||||
id: menu
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
id: applicationActions
|
||||
model: root.entry ? root.entry.actions : []
|
||||
|
||||
delegate: TrayMenuRow {
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
label: modelData.name
|
||||
onActivated: {
|
||||
modelData.execute();
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
anchors.margins: 3
|
||||
visible: applicationActions.count > 0
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
TrayMenuRow {
|
||||
width: parent.width
|
||||
label: "Dock settings"
|
||||
onActivated: {
|
||||
ShellState.openSettings("desktop");
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
// behavior. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
|
||||
// underneath, grow-on-hover, click to focus / cycle / launch.
|
||||
|
||||
import Quickshell
|
||||
@@ -24,6 +24,7 @@ Item {
|
||||
// Emitted so DockBody can drive the single shared tooltip.
|
||||
signal entered
|
||||
signal exited
|
||||
signal contextMenuRequested
|
||||
|
||||
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
||||
// size so the row doesn't reflow.
|
||||
@@ -72,7 +73,7 @@ Item {
|
||||
}
|
||||
|
||||
// Last resort for an app with no resolvable icon: an initial, which is
|
||||
// still recognisable, unlike the theme's broken-icon placeholder.
|
||||
// still recognizable, unlike the theme's broken-icon placeholder.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.iconSource === ""
|
||||
@@ -115,7 +116,7 @@ Item {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
|
||||
onEntered: root.entered()
|
||||
onExited: root.exited()
|
||||
@@ -126,6 +127,10 @@ Item {
|
||||
root.launch();
|
||||
return;
|
||||
}
|
||||
if (mev.button === Qt.RightButton) {
|
||||
root.contextMenuRequested();
|
||||
return;
|
||||
}
|
||||
if (root.running)
|
||||
root.focusNext();
|
||||
else
|
||||
@@ -155,7 +160,7 @@ Item {
|
||||
return;
|
||||
|
||||
// Quickshell reports the address without the 0x prefix Hyprland's
|
||||
// window selector expects; normalise so either form works.
|
||||
// window selector expects; normalize 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}" })`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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
|
||||
// Shared by the toasts and the notification center so a notification looks the
|
||||
// same wherever you meet it.
|
||||
//
|
||||
// IconButton/ThemedIcon come from the quicksettings module — they belong in
|
||||
@@ -14,6 +14,7 @@ import Quickshell.Services.Notifications
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.modules.bar
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
@@ -21,7 +22,7 @@ Rectangle {
|
||||
|
||||
required property var notification
|
||||
|
||||
// The centre renders a denser card than a toast banner.
|
||||
// The center renders a denser card than a toast banner.
|
||||
property bool compact: false
|
||||
|
||||
signal dismissed
|
||||
@@ -35,7 +36,17 @@ Rectangle {
|
||||
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)
|
||||
// A real popover surface, the same one the date menu and clipboard panel
|
||||
// use, rather than a 6% foreground tint.
|
||||
//
|
||||
// A tint that faint has nothing behind it: the toast window is transparent,
|
||||
// so the card was sitting directly on whatever happened to be on screen and
|
||||
// the text competed with it. Notifications are the one surface someone reads
|
||||
// without having chosen to look at it, so it has to be legible over a
|
||||
// bright photo and a white document alike.
|
||||
color: hover.containsMouse
|
||||
? Theme.alpha(Theme.mix(Theme.bgPopover, Theme.fg, 0.06), Theme.toastAlpha)
|
||||
: Theme.alpha(Theme.bgPopover, Theme.toastAlpha)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
@@ -43,7 +54,7 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Critical notifications get a red edge rather than a colour wash, so the
|
||||
// Critical notifications get a red edge rather than a color wash, so the
|
||||
// text contrast never changes.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
@@ -117,11 +128,44 @@ Rectangle {
|
||||
onClicked: root.dismissed()
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: settingsMenuButton
|
||||
anchors.right: closeButton.left
|
||||
anchors.rightMargin: 2
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 6
|
||||
size: 24
|
||||
iconSize: 14
|
||||
tint: Theme.fgDim
|
||||
icon: "view-more-symbolic"
|
||||
iconFallback: "open-menu-symbolic"
|
||||
onClicked: settingsMenu.visible = !settingsMenu.visible
|
||||
}
|
||||
|
||||
Popover {
|
||||
id: settingsMenu
|
||||
anchorItem: settingsMenuButton
|
||||
|
||||
// A Column already measures itself from its children, and in Qt 6 both
|
||||
// implicit sizes are read-only on a positioner -- assigning them makes
|
||||
// the whole shell fail to load rather than just this menu.
|
||||
Column {
|
||||
TrayMenuRow {
|
||||
id: notificationSettings
|
||||
label: "Notification settings"
|
||||
onActivated: {
|
||||
ShellState.openSettings("notifications");
|
||||
settingsMenu.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.right: image.visible ? image.left : settingsMenuButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 12
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// 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
|
||||
|
||||
@@ -14,28 +13,25 @@ Item {
|
||||
// into a keyboard focus request on the layer surface.
|
||||
signal replyFocusChanged(bool focused)
|
||||
|
||||
// Mirrors the signal above so the dismiss timer below can read it.
|
||||
property bool replyFocused: false
|
||||
|
||||
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;
|
||||
}
|
||||
// "server decides", which is our default. Shared with Notifs.qml, which
|
||||
// gives a DND-hidden transient notification the same lifetime.
|
||||
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
Timer {
|
||||
// Hovering holds the banner open; the countdown restarts on leave.
|
||||
running: root.timeoutMs > 0 && !hover.hovered
|
||||
// Hovering, or actively typing a reply, holds the banner open; the
|
||||
// countdown restarts (from the top, same as hover) once both let go.
|
||||
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
|
||||
interval: root.timeoutMs
|
||||
onTriggered: Notifs.dropPopup(root.notification)
|
||||
}
|
||||
@@ -45,7 +41,10 @@ Item {
|
||||
width: parent.width
|
||||
notification: root.notification
|
||||
onDismissed: Notifs.dropPopup(root.notification)
|
||||
onReplyFocusChanged: focused => root.replyFocusChanged(focused)
|
||||
onReplyFocusChanged: focused => {
|
||||
root.replyFocused = focused;
|
||||
root.replyFocusChanged(focused);
|
||||
}
|
||||
|
||||
// Slide in from the right edge. Runs once, on creation.
|
||||
NumberAnimation on x {
|
||||
|
||||
@@ -17,7 +17,11 @@ PanelWindow {
|
||||
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
@@ -45,7 +49,17 @@ PanelWindow {
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
// Notifs.popups.slice() is a fresh array on every change (a new
|
||||
// arrival, a dismissal, a sibling toast timing out). Handing that
|
||||
// straight to Repeater would reset the model and rebuild every
|
||||
// delegate each time, blowing away whichever toast has a reply
|
||||
// field mid-typing. ScriptModel diffs by object identity
|
||||
// (Notification instances are unique QObjects), so only genuinely
|
||||
// added/removed notifications add/remove delegates — unrelated
|
||||
// toasts, and their slide-in animations, are untouched.
|
||||
model: ScriptModel {
|
||||
values: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
}
|
||||
|
||||
Toast {
|
||||
required property var modelData
|
||||
|
||||
@@ -34,7 +34,14 @@ PanelWindow {
|
||||
implicitWidth: root.desiredWidth
|
||||
implicitHeight: 64
|
||||
color: "transparent"
|
||||
mask: Region {}
|
||||
mask: Region {
|
||||
item: inputMask
|
||||
}
|
||||
|
||||
Item {
|
||||
id: inputMask
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-osd"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
@@ -145,6 +152,17 @@ PanelWindow {
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: event => {
|
||||
if (event.button !== Qt.RightButton)
|
||||
return;
|
||||
ShellState.openSettings("accessibility");
|
||||
OsdState.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextMetrics {
|
||||
|
||||
@@ -176,34 +176,34 @@ Item {
|
||||
height: aspect > 0 ? width / aspect : parent.height
|
||||
opacity: hasContent ? 1 : 0
|
||||
|
||||
// The capture context isn't ready the instant the layer surface is
|
||||
// told to become visible — it needs the surface to actually map,
|
||||
// which takes a frame or two. Calling captureFrame() before then
|
||||
// logs "no recording context is ready" and yields nothing, so
|
||||
// retry a few times and then give up quietly (the caption and app
|
||||
// icon are still shown, so a missing thumbnail is cosmetic).
|
||||
property int captureAttempts: 0
|
||||
// A ScreencopyView cannot tell us that its recording context is
|
||||
// ready before a capture. The enclosing overview gets that
|
||||
// context only after its first rendered frame, so defer the first
|
||||
// one-shot capture to that event. Retrying before then only emits
|
||||
// "no recording context is ready" warnings; if a capture later
|
||||
// cannot produce content, the app icon remains the fallback.
|
||||
property bool recordingReady: false
|
||||
|
||||
function tryCapture(): void {
|
||||
captureAttempts = 0;
|
||||
captureRetry.restart();
|
||||
}
|
||||
|
||||
// `shot`, not `parent`: Timer is a QtObject, so `parent` does not
|
||||
// resolve to the enclosing ScreencopyView.
|
||||
Timer {
|
||||
id: captureRetry
|
||||
interval: 80
|
||||
repeat: true
|
||||
running: false
|
||||
onTriggered: {
|
||||
if (shot.hasContent || shot.captureAttempts >= 6) {
|
||||
stop();
|
||||
if (shot.hasContent)
|
||||
return;
|
||||
if (!shot.recordingReady) {
|
||||
frameReady.restart();
|
||||
return;
|
||||
}
|
||||
shot.captureAttempts++;
|
||||
shot.captureFrame();
|
||||
}
|
||||
|
||||
FrameAnimation {
|
||||
id: frameReady
|
||||
running: false
|
||||
onTriggered: {
|
||||
running = false;
|
||||
if (shot.hasContent)
|
||||
return;
|
||||
shot.recordingReady = true;
|
||||
shot.tryCapture();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: tryCapture()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user