Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a69cacc006 | ||
|
|
28e387868f | ||
|
|
1f65e09865 | ||
|
|
dce00e45d1 | ||
|
|
0d1841cf86 | ||
|
|
ba4e5e6677 | ||
|
|
1ffd05f0e1 | ||
|
|
92c60c3ecd | ||
|
|
31fc1d3eaa | ||
|
|
8264ca6320 | ||
|
|
eab4280969 | ||
|
|
1c3cd7ac72 | ||
|
|
8652f92aae | ||
|
|
4abf3f8acd | ||
|
|
3f8539176a | ||
|
|
18ecc2244f | ||
|
|
94a4314730 | ||
|
|
27acbe7a95 | ||
|
|
fc8f226747 | ||
|
|
98e29eb8f3 | ||
|
|
00e11e0b8d | ||
|
|
ac25fd776c | ||
|
|
51e2c8418a | ||
|
|
d267da58ad | ||
|
|
20b41c3976 | ||
|
|
bfb37afd69 | ||
|
|
b361db8486 | ||
|
|
12b858371e | ||
|
|
77f625d7bb | ||
|
|
34e295d184 | ||
|
|
e19fb00276 | ||
|
|
37688f702a | ||
|
|
71f4f0d24b | ||
|
|
3839183401 | ||
|
|
68b6664111 | ||
|
|
803362ebad | ||
|
|
6a7bdcb835 | ||
|
|
37fd5e890e | ||
|
|
a19c6dc3ef | ||
|
|
6d71a77798 | ||
|
|
d8144a66c1 | ||
|
|
43d1e15858 | ||
|
|
1192ad64dc | ||
|
|
044139ed63 | ||
|
|
0d894afe3e | ||
|
|
54dfa977ee | ||
|
|
5f09c5ee38 | ||
|
|
f33da41cc6 | ||
|
|
9b338608ef | ||
|
|
cc7d91d09c | ||
|
|
ada0faf1d1 | ||
|
|
045a774847 | ||
|
|
beed44dd87 | ||
|
|
8105849151 | ||
|
|
bd2fd12de2 | ||
|
|
06c53d6c21 | ||
|
|
f9e5d3f470 | ||
|
|
88371d19f0 | ||
|
|
8b1205e4b8 | ||
|
|
be0e55214b | ||
|
|
9ffaf45a4d | ||
|
|
e1ff25fc66 | ||
|
|
6f0ce639d9 | ||
|
|
4ec8bd94d9 | ||
|
|
5a0643357f | ||
|
|
b30bf40407 | ||
|
|
aba2d16ffa | ||
|
|
b8f88a91f3 | ||
|
|
d5b6e62515 | ||
|
|
07db1068f1 | ||
|
|
ffce48964e | ||
|
|
b58371bb35 | ||
|
|
9bc68ba358 | ||
|
|
1f694b00b6 | ||
|
|
f8f5b25510 | ||
|
|
4d7a194300 | ||
|
|
cb7c09d208 | ||
|
|
7578348db1 | ||
|
|
5490fd285d | ||
|
|
50077a0c31 | ||
|
|
14fc4fc8a3 | ||
|
|
e723fabed3 | ||
|
|
c0eee076b4 | ||
|
|
31668619a4 | ||
|
|
9530e32326 | ||
|
|
153554b5df | ||
|
|
44124d72fa | ||
|
|
3d21e20041 |
@@ -0,0 +1,236 @@
|
|||||||
|
# Panama server role: headless installs and versioned containers
|
||||||
|
|
||||||
|
2026-08-25. Designed with Gib across two interview rounds; every decision below
|
||||||
|
was made explicitly, none inferred.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Panama becomes the one repo for every Fedora machine Gib runs. A desktop gets
|
||||||
|
what it gets today. A server — a Hetzner VPS, a future home server — gets the
|
||||||
|
same shell environment (bash profile, prompt, tmux-on-ssh, agents) minus
|
||||||
|
everything that needs a screen, plus rootless podman, systemd user units, and a
|
||||||
|
versioned catalog of compose services that `panama server enable` brings up.
|
||||||
|
The repo stays public; secrets never enter it.
|
||||||
|
|
||||||
|
## Decisions of record
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Repo | One public monorepo. No companion repo. `.gitignore` + a secrets contract keep it safe |
|
||||||
|
| Role | Asked first in the interview (`desktop` / `server`), persisted to `~/.local/state/panama/role`; `--server` on `boot`/`install` presets it |
|
||||||
|
| Layout | Top-level `server/`, services under `server/containers/<Name>/` (TitleCase, matching junior) |
|
||||||
|
| Tracked per service | `compose.yml`, `podman-<name>.service`, `.env.example`. Nothing else |
|
||||||
|
| `~/Server` | Real directories. Only `compose.yml` is symlinked into them; units symlink into `~/.config/systemd/user/`. Live `.env` and data stay outside the repo tree entirely |
|
||||||
|
| Data dirs | Standardized to `./data/`, applied per service at cutover (a stop/rename/start), never at import |
|
||||||
|
| Enablement | `panama server` subcommand family, not the interview |
|
||||||
|
| Units | Checked in per service (they legitimately differ), installed as symlinks so edits flow with `panama update` |
|
||||||
|
| Updates | `panama update` relinks and `daemon-reload`s but never restarts a running service; it names services whose definitions changed |
|
||||||
|
| Nightly image updates | junior's `update-containers.sh` + `podman-update.timer` adopted into the repo (it replaced watchtower for cause) |
|
||||||
|
| Codex | Installed everywhere, same pattern as Claude Code |
|
||||||
|
| Ports | 80/443/81 only. Anything else is opened by hand and documented in the service's folder |
|
||||||
|
| VPS bootstrap | `boot` run as root walks user-creation/keys/sshd-hardening, each step skipped when already true, then re-runs as the user |
|
||||||
|
| Out of scope | server.gib (docker; translate later), backup and verify-rootless (own design later), cutover of junior's live services (later, per service) |
|
||||||
|
|
||||||
|
## 1. Machine role
|
||||||
|
|
||||||
|
- New interview question, asked first: "What is this machine?" — `desktop` or
|
||||||
|
`server` via `gum choose`. `./install --server` (and `boot --server`) records
|
||||||
|
the answer without asking.
|
||||||
|
- Persisted to `~/.local/state/panama/role`, plain text, one word. This is the
|
||||||
|
first durable answer Panama keeps, and deliberately so: the transient-answers
|
||||||
|
principle exists to keep *personal* data out of durable paths, and a machine
|
||||||
|
role is a fact about the machine. `install`, `panama update`, `panama doctor`
|
||||||
|
and future migrations read it. A machine with no role file is a desktop —
|
||||||
|
every machine that predates this work is one.
|
||||||
|
- On role=server the interview skips: hardware probes (NVIDIA, Secure Boot,
|
||||||
|
firmware — and their bootstrap tools; only `gum` is bootstrapped), the
|
||||||
|
debloat question, and the extras checklist. It keeps: hostname, git
|
||||||
|
identity, gh sign-in, SSH key, user-content.
|
||||||
|
|
||||||
|
## 2. Stages by role
|
||||||
|
|
||||||
|
Role=desktop is exactly today's behavior. Role=server runs:
|
||||||
|
|
||||||
|
| Stage | Server behavior |
|
||||||
|
|---|---|
|
||||||
|
| `install-packages` | Core + server lists (§3). No flatpaks, no COPRs, no desktop repos. Claude Code and Codex install for both roles |
|
||||||
|
| `link-dotfiles` | Universal dot dirs only: `btop`, `nvim`, `ohmyposh`, `tmux`, `vim`, `panama`. Desktop-only (everything else: `hypr`, `quickshell`, `kitty`, `ghostty`, `espanso`, `gtk-*`, `uwsm`, `vicinae`, `wofi`, `xdg-desktop-portal`) is skipped via an explicit classification list in the stage — a new dot dir must be classified or the contract fails. Bash profile links as today; Firefox/wallpaper/cursor seeding skipped |
|
||||||
|
| `link-user` | Unchanged (SSH aliases and agent rules are exactly what a server wants) |
|
||||||
|
| `setup-identity` | Unchanged |
|
||||||
|
| `setup-server` (new) | §4 |
|
||||||
|
| `link-server` (new) | Creates `~/Server`, then delegates to `panama-server relink` so link repair has one owner |
|
||||||
|
| Skipped | `link-skills` (all three shipped skills are desktop-specific today; revisit when a server skill exists), `change-settings`, `link-vicinae-scripts`, `install-hardware` |
|
||||||
|
|
||||||
|
Migrations run on servers too — they are self-guarding no-ops where they do
|
||||||
|
not apply. `panama doctor` learns the role: on a server it reports service
|
||||||
|
health via `panama-server status` instead of probing desktop units.
|
||||||
|
|
||||||
|
## 3. Package restructure
|
||||||
|
|
||||||
|
`setup/packages/` gains `core-packages` (both roles) and `server-packages`;
|
||||||
|
`initial-packages` keeps what is desktop-only. Nothing is added or dropped for
|
||||||
|
desktops — the union of core + desktop lists equals today's set.
|
||||||
|
|
||||||
|
- **core-packages**: awk, bat, btop, curl, dnf5-plugins, eza, fontconfig, fzf,
|
||||||
|
gh, git-all, gum, jq, neovim, oh-my-posh, openssh, openssl, podman,
|
||||||
|
podman-compose, python3-dnf, ripgrep, tmux, unzip, wget, zoxide.
|
||||||
|
(`podman` moves here from development-packages; `podman-compose` is what
|
||||||
|
provides `podman compose` on junior — verified, 1.6.0.)
|
||||||
|
- **server-packages**: nvm (Node via nvm, as on desktop — agents and MCP
|
||||||
|
servers want it, and Codex installs through npm).
|
||||||
|
- **initial-packages** (desktop-only remainder): cargo, rustup, flatpak, fwupd,
|
||||||
|
kitty, ksshaskpass, libselinux-utils, pciutils, python3-neovim,
|
||||||
|
wireguard-tools, vim-X11 — plus everything else currently there that the
|
||||||
|
core list did not claim.
|
||||||
|
- Codex CLI: installed in `install-packages` beside the Claude Code step —
|
||||||
|
skip when `codex` is on PATH, `npm install -g @openai/codex` (after nvm/node
|
||||||
|
is available), soft-fail like Claude Code does.
|
||||||
|
- The package-hash mechanic in `install` is untouched; new files under
|
||||||
|
`-maxdepth 1` join the hash automatically.
|
||||||
|
|
||||||
|
## 4. `setup-server` stage
|
||||||
|
|
||||||
|
Idempotent, like every stage; each item checks before acting:
|
||||||
|
|
||||||
|
1. `loginctl enable-linger $USER`.
|
||||||
|
2. `/etc/sysctl.d/99-rootless-ports.conf` → `net.ipv4.ip_unprivileged_port_start=80`,
|
||||||
|
applied with `sysctl --system` (junior already has exactly this file).
|
||||||
|
3. firewalld: open 80, 443, 81 (NPM admin portal), permanent + runtime.
|
||||||
|
Nothing else — reverse-proxy philosophy, ports open only when unavoidable.
|
||||||
|
4. `podman network create nginx-bridge` if absent (every compose expects it,
|
||||||
|
`external: true`).
|
||||||
|
5. Nightly image updates: `update-containers.sh` is adopted into the repo as
|
||||||
|
`server/scripts/update-containers` (no `.sh`, per repo rule — junior's copy
|
||||||
|
is imported and its `ExecStart`/log paths updated), with
|
||||||
|
`server/systemd/podman-update.service` and `.timer` symlinked into
|
||||||
|
`~/.config/systemd/user/` and the timer enabled. Logs go to
|
||||||
|
`~/Server/logs/` as today.
|
||||||
|
|
||||||
|
## 5. The `panama server` command
|
||||||
|
|
||||||
|
`bin/panama-server`, surfaced as `panama server <verb>` (README's subcommand
|
||||||
|
table and its contract update with it):
|
||||||
|
|
||||||
|
- `list` — every service in `server/containers/`, with enabled/active state.
|
||||||
|
- `enable <Name>` — create `~/Server/<Name>/`; symlink `compose.yml`; seed
|
||||||
|
`~/Server/<Name>/.env` from `.env.example` when the example exists and the
|
||||||
|
live file does not; if the seeded `.env` still contains placeholder values,
|
||||||
|
stop there and say what to fill in — do **not** start; otherwise symlink the
|
||||||
|
unit, `daemon-reload`, `enable --now`.
|
||||||
|
- `disable <Name>` — `disable --now`, remove the unit symlink,
|
||||||
|
`daemon-reload`. `~/Server/<Name>` and its data are never touched.
|
||||||
|
- `status [<Name>]` — unit state plus `podman compose ps` per service.
|
||||||
|
- `relink` — for every service whose unit symlink exists: refresh both
|
||||||
|
symlinks, `daemon-reload`, and report services whose definitions changed
|
||||||
|
since the last run (so the operator restarts deliberately). Called by the
|
||||||
|
`link-server` stage and by `panama update` on role=server.
|
||||||
|
|
||||||
|
Live `.env` files live in `~/Server/<Name>/`, not in the repo tree — the repo
|
||||||
|
carries only `.env.example`. `.gitignore` still covers `.env` everywhere under
|
||||||
|
`server/` as belt and braces.
|
||||||
|
|
||||||
|
## 6. VPS bootstrap (`boot` as root)
|
||||||
|
|
||||||
|
`boot` gains a root path. When `EUID == 0`, before anything else, walk these
|
||||||
|
steps, each checked and skipped when already true:
|
||||||
|
|
||||||
|
1. Ask for a username (default `gib`). Create it with `wheel` if absent; if it
|
||||||
|
exists, use it.
|
||||||
|
2. Ensure the user has a password (needed for sudo) — `passwd` interactively
|
||||||
|
if none is set.
|
||||||
|
3. Copy root's `authorized_keys` only after OpenSSH parses every key line. The
|
||||||
|
destination directory and file are created as the target UID at `0700/0600`
|
||||||
|
and revalidated before hardening is available.
|
||||||
|
4. Offer sshd hardening (yes/no, default yes): atomically install
|
||||||
|
`/etc/ssh/sshd_config.d/00-panama.conf` with `PermitRootLogin no`,
|
||||||
|
`PasswordAuthentication no`, and `KbdInteractiveAuthentication no`. Run
|
||||||
|
`sshd -t` plus effective root/target `sshd -T -C` checks before reloading the
|
||||||
|
detected unit. Restore a prior regular file with metadata on failure. A
|
||||||
|
missing unit or unsupported existing drop-in leaves SSH unchanged and
|
||||||
|
bootstrap continues. The later binding transaction design owns the details.
|
||||||
|
5. Move/clone the checkout under the user's home, chown it, and re-exec
|
||||||
|
`install --server` as that user.
|
||||||
|
|
||||||
|
Run as a normal user, `boot` behaves exactly as today (plus passing `--server`
|
||||||
|
through).
|
||||||
|
|
||||||
|
## 7. Importing junior's services (the first population)
|
||||||
|
|
||||||
|
A one-time operation, performed against junior over SSH, that **never stops,
|
||||||
|
starts, or restarts anything**. For each dedicated compose dir:
|
||||||
|
|
||||||
|
1. Copy `compose.yml` into `server/containers/<Name>/`.
|
||||||
|
2. Take the **installed** unit (`~/.config/systemd/user/podman-<name>.service`)
|
||||||
|
as truth; diff against the copy in the service dir and flag any divergence.
|
||||||
|
3. Move inline secrets out: secret-bearing values in the repo copy of
|
||||||
|
`compose.yml` become `${VAR}` interpolations; extracted values are written
|
||||||
|
to `~/Server/<Name>/.env` **on junior** (inert until cutover — the live
|
||||||
|
compose still carries them inline); `.env.example` with placeholder values
|
||||||
|
is committed. Services already using `.env`/`env_file` get an
|
||||||
|
`.env.example` generated from the live `.env` with values blanked.
|
||||||
|
4. Verify without touching the stack: `podman compose config` rendered from
|
||||||
|
the repo copy (with the live `.env`) must match the render of the live
|
||||||
|
compose. Volume paths are kept **verbatim** at import (`volumes/`,
|
||||||
|
`volume/`, `pg_data/` and all) so this diff can be exact; the `data/`
|
||||||
|
standardization happens per service at cutover.
|
||||||
|
5. Commit. The secrets contract (§8) gates every one of these commits.
|
||||||
|
|
||||||
|
**Import list (23):** Adminer, Agentchat, Authentik, Beszel, CalCom,
|
||||||
|
ConvexMonorepo, Documenso, Gitea, Infisical, ISponsorBlockTV, LashAddict, N8n,
|
||||||
|
Nginx_Proxy_Manager, Penpot, Portainer, PostgreSQL, Rustdesk, Spoon, StPeteIT,
|
||||||
|
Tools, Uptime, UseSend, Vaultwarden. CalCom, Documenso, Penpot and N8n are
|
||||||
|
currently parked (dir present, unit not active) — imported anyway, noted as
|
||||||
|
parked in the catalog.
|
||||||
|
|
||||||
|
**Excluded:** Watchtower (retired — replaced by the update timer after it took
|
||||||
|
gitea down; its dir stays on junior until cleanup), Completeuphoria,
|
||||||
|
sierraandtyler, Bang (source checkouts deployed by CI from their own repos —
|
||||||
|
Panama owning a second copy of their compose would drift), `ci/`, `logs/`,
|
||||||
|
and junior's loose scripts other than `update-containers.sh` (§4).
|
||||||
|
|
||||||
|
**Cutover** (per service, later, not this work): stop → rename the volume dir
|
||||||
|
to `data/` and update the repo compose to match → replace live compose with
|
||||||
|
the symlink, installed unit with the symlink → `daemon-reload` → start →
|
||||||
|
verify. Ten minutes a service, done when Gib chooses, favoring quiet hours.
|
||||||
|
|
||||||
|
## 8. Contracts and docs
|
||||||
|
|
||||||
|
New `tests/server/`, same discipline as the rest:
|
||||||
|
|
||||||
|
- `compose-secrets-contract` — fails when any tracked file under `server/`
|
||||||
|
contains an inline secret-looking value (the `user-content-contract`
|
||||||
|
pattern), when `.gitignore` stops covering `.env` under `server/`, or when
|
||||||
|
`git ls-files` shows a tracked `.env`.
|
||||||
|
- `containers-shape-contract` — every `server/containers/<Name>/` carries
|
||||||
|
`compose.yml` + `podman-<name>.service`; every unit's `WorkingDirectory` is
|
||||||
|
`%h/Server/<Name>`; every `${VAR}` used in a compose file appears in that
|
||||||
|
service's `.env.example`.
|
||||||
|
- `panama-server-contract` — throwaway `HOME`, stubbed `systemctl`/`podman` on
|
||||||
|
PATH: `enable` creates the right symlinks and seeds `.env`; placeholder
|
||||||
|
values block start; `disable` leaves data alone; `relink` reports changed
|
||||||
|
definitions.
|
||||||
|
- `role-contract` — the interview asks role first, `--server` presets it, the
|
||||||
|
role file is written, stages filter correctly per role (via the stage
|
||||||
|
scripts' static/stubbed seams).
|
||||||
|
- `dotfile-classification-contract` — every dir in `config/dot/` appears in
|
||||||
|
exactly one of link-dotfiles' universal/desktop lists.
|
||||||
|
- README: layout section gains `server/`, the subcommand table gains
|
||||||
|
`panama server`, the pinned contract count moves — `readme-contract` and the
|
||||||
|
interview/link-dotfiles contracts that assert today's behavior update in the
|
||||||
|
same change.
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
1. **Role plumbing** — interview question, role file, `--server`, package
|
||||||
|
split, stage filters, `setup-server`, boot's root path.
|
||||||
|
2. **Server skeleton** — `server/` tree, `panama-server`, `link-server`,
|
||||||
|
update-timer adoption, contracts, README/docs.
|
||||||
|
3. **Import** — junior's 23 services into `server/containers/` per §7.
|
||||||
|
4. **Later, separately** — per-service cutover on junior; backup and
|
||||||
|
verify-rootless design; server.gib docker→podman translation; a real
|
||||||
|
end-to-end test on a fresh VPS.
|
||||||
|
|
||||||
|
Testing note: contracts for phases 1–2 run with stubs and throwaway HOMEs, but
|
||||||
|
per standing practice all suite runs batch at the end of the session with
|
||||||
|
Gib's go-ahead. Phase 3 touches junior only with read commands plus the inert
|
||||||
|
`.env` writes described in §7.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(coredumpctl list:*)",
|
||||||
|
"Bash(coredumpctl info:*)",
|
||||||
|
"Bash(journalctl:*)",
|
||||||
|
"Bash(rpm -q:*)",
|
||||||
|
"Bash(panama doctor:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
name: panama
|
||||||
|
description: Use when working on the Panama repository itself — editing anything under config/, setup/, bin/, tests/, or the Quickshell shell and Hyprland Lua it ships. Covers the live-desktop hot-reload rules, the contract-test discipline, the fixture seams, and where the real documentation lives.
|
||||||
|
---
|
||||||
|
|
||||||
|
# panama
|
||||||
|
|
||||||
|
You are editing a running desktop, not a codebase that gets deployed later. Every dotfile in this
|
||||||
|
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
|
||||||
|
drives every rule below.
|
||||||
|
|
||||||
|
Read before large work, in this order:
|
||||||
|
|
||||||
|
- `README.md` — layout, the `panama` command, how installing and updating work
|
||||||
|
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
|
||||||
|
thing to know first" and "Never use `hyprctl keyword`" before touching it
|
||||||
|
- `config/dot/quickshell/modules/settings/README.md` — adding a setting, setting ownership,
|
||||||
|
"Things that will bite you", and where state lives; the most load-bearing document here
|
||||||
|
- `docs/settings.md` — generated reference for every settings key (never edit by hand)
|
||||||
|
|
||||||
|
## The live-desktop covenant
|
||||||
|
|
||||||
|
Editing `config/dot/quickshell/**` hot-reloads the live shell on every save.
|
||||||
|
|
||||||
|
1. Every save must leave valid QML. An intermediate broken save is a broken desktop, not a broken
|
||||||
|
build. Convert a component and its body in ONE edit, never two.
|
||||||
|
2. After each batch of saves, check the journal for errors AND "Unable to assign" warnings:
|
||||||
|
`journalctl --user -u panama-quickshell.service --since '-2 minutes' --no-pager`
|
||||||
|
3. Every `.qml` file in `config/dot/quickshell/modules/*` must be registered in that directory's
|
||||||
|
`qmldir` in the same save batch. An unregistered component fails the whole configuration and
|
||||||
|
takes down the bar, dock, and settings together. `tests/quickshell/qmldir-registration-contract`
|
||||||
|
is pure file inspection — run it before the change lands.
|
||||||
|
4. Never `qs kill` from a copied configuration: Quickshell derives the shell ID from config
|
||||||
|
content, so a content-identical harness can share the live shell's ID and kill the desktop.
|
||||||
|
Harnesses use a distinct entry file, `qs -p`, the PID from `qs list --all`, and `kill` that PID.
|
||||||
|
5. Never `hyprctl keyword` — Lua-configured Hyprland refuses it, prints to stdout, and exits 0.
|
||||||
|
`hyprctl eval` also exits 0 on errors. The only proof a write landed is `hyprctl getoption`
|
||||||
|
reading it back. Batch `hyprctl reload` to one per verified change-set, and say when you do it.
|
||||||
|
6. IPC: annotate every parameter and return type or Quickshell silently skips registration; never
|
||||||
|
duplicate an `IpcHandler` target — one silently shadows the other.
|
||||||
|
7. In `config/dot/quickshell/config/PreferenceSchema.qml`, `readAs` describes the compositor's
|
||||||
|
ANSWER shape, not the setting's; a wrong one makes every successful write look rejected.
|
||||||
|
8. No continuously repainting animations (pulse, shimmer, spinners) — they peg the GPU on
|
||||||
|
high-refresh displays.
|
||||||
|
9. `hyprlock.conf`, `hypridle.conf`, `hyprpaper.conf`, `hyprtoolkit.conf` never moved to Lua. Do
|
||||||
|
not "fix" them. Do not install a notification daemon — the shell is one.
|
||||||
|
10. Scripts carry no `.sh` extension (shebang + `chmod +x`), with one deliberate exception:
|
||||||
|
`migrations/*.sh`, which `bin/panama-migrate` globs. No secrets under `user/` — it is
|
||||||
|
world-readable and `tests/setup/user-content-contract` greps for key material.
|
||||||
|
|
||||||
|
## The contract discipline
|
||||||
|
|
||||||
|
Every executable contract under `tests/` is classified in `tests/contracts.manifest`. The rules:
|
||||||
|
|
||||||
|
- `panama contracts <file>` names the contracts that mention a file; run those after touching it.
|
||||||
|
- During a desktop session, run `panama test --safe [pattern]`. `--safe` selects only contracts
|
||||||
|
classified as `hermetic`.
|
||||||
|
- A plain full run prompts in a terminal before any selected non-hermetic contract starts. In
|
||||||
|
automation, grant every required capability with repeatable flags, for example
|
||||||
|
`panama test --allow live-compositor --allow live-desktop keybinds`.
|
||||||
|
- Each contract has a 180-second outer timeout by default. Override it with a positive
|
||||||
|
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
|
||||||
|
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
|
||||||
|
- Contracts run directly too: `tests/setup/interview-contract`.
|
||||||
|
- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
|
||||||
|
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
|
||||||
|
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
|
||||||
|
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
|
||||||
|
- `README.md` pins the contract count and the `panama` subcommand list
|
||||||
|
(`tests/setup/readme-contract`); adding a contract or subcommand means updating it.
|
||||||
|
|
||||||
|
## Seams for testing
|
||||||
|
|
||||||
|
Helpers are built hermetic: validated inputs, JSON out, secrets on stdin only (never argv).
|
||||||
|
Contracts reach them through env seams — `PANAMA_NETWORK_HELPER`, `PANAMA_FINGERPRINT_FIXTURE`,
|
||||||
|
`PANAMA_EXTRAS_DIR`, and the `PANAMA_*_STATIC_ONLY` family that cuts a contract to its
|
||||||
|
file-inspection half. Prefer exercising a helper through its contract's stubbed PATH over running
|
||||||
|
it against the real system.
|
||||||
|
|
||||||
|
## Safety idioms (contract-enforced)
|
||||||
|
|
||||||
|
- Destructive actions are two-stage: `ConfirmAction` with a unique `actionId`, arbitrated through
|
||||||
|
`ShellState.armedConfirm` — one armed confirm app-wide. Danger tone marks only the confirming
|
||||||
|
press.
|
||||||
|
- Failures render through `ErrorRow`; honest empty states through `NotMeasuredRow` with a
|
||||||
|
`because:`; paragraph-length notes through `SettingsNote`.
|
||||||
|
- Stored user actions (custom shortcuts, gestures, window rules) are DATA — an enum kind and a
|
||||||
|
validated target resolved through whitelist tables in `config/dot/hypr/actions.lua`. Nothing in
|
||||||
|
`~/.config/panama/settings.json` may ever be an executable command; the contracts
|
||||||
|
(`tests/quickshell/settings-idiom-contract`, `tests/quickshell/keybind-rebind-contract`) pin all
|
||||||
|
of this.
|
||||||
|
|
||||||
|
## Root work
|
||||||
|
|
||||||
|
Never bare `sudo`. Load the `panama-sudo` skill first. Migrations already follow the rule.
|
||||||
|
|
||||||
|
## Initial bootstrap trust
|
||||||
|
|
||||||
|
Use the complete command in `README.md` for a new machine. It downloads `boot`
|
||||||
|
from the documented commit URL into a private temporary directory, enforces the
|
||||||
|
documented curl time and byte limits, verifies the committed blob with
|
||||||
|
`sha256sum -c`, and passes both pins to the verified script. Desktop and server
|
||||||
|
bootstrap use the same commit and digest. Never substitute a branch URL, pipe a
|
||||||
|
response into Bash, or invent a newer pin from an uncommitted file.
|
||||||
|
|
||||||
|
`boot` validates the full lowercase commit and SHA-256 before Git or install. A
|
||||||
|
fresh destination fetches only that revision and creates a tracked local `main`
|
||||||
|
after HEAD matches. An existing checkout must be clean and an ancestor of the
|
||||||
|
requested revision; it advances with fast-forward only. Dirty, divergent,
|
||||||
|
fetch-failed, or mismatched checkouts stop without reset or install. Once Panama
|
||||||
|
exists, use `panama update` for normal updates.
|
||||||
|
|
||||||
|
`boot --server` is the one exception where a new Fedora VPS may begin as root.
|
||||||
|
Before it offers SSH hardening, it copies a safe root key when possible or
|
||||||
|
verifies the target key. The target user's `.ssh` must be owned by that user at
|
||||||
|
`0700`, and `authorized_keys` must be owned by that user at `0600`. Without a
|
||||||
|
verified target key, SSH hardening is unavailable and the bootstrap continues.
|
||||||
|
Every non-comment key line must parse with `ssh-keygen`. Root-key destination
|
||||||
|
creation and writing run as the target UID, followed by the same owner, mode,
|
||||||
|
and key checks. Do not replace that with root writes or assume the user's
|
||||||
|
primary group matches the username.
|
||||||
|
|
||||||
|
Accepted hardening uses atomic same-directory `00-panama.conf` with exactly
|
||||||
|
`PermitRootLogin no`, `PasswordAuthentication no`, and
|
||||||
|
`KbdInteractiveAuthentication no`. A pre-existing symlink or non-regular
|
||||||
|
object makes hardening unavailable, as does a missing SSH unit. Panama runs
|
||||||
|
`sshd -t`, then checks effective root and target-user policy with `sshd -T -C`
|
||||||
|
before reloading the detected unit. Validation or reload failure restores a
|
||||||
|
prior regular file with its metadata before it retries validation and reload.
|
||||||
|
Failed recovery stops the handoff with instructions that distinguish a prior
|
||||||
|
file from no prior file. The fixture contracts also cover declined hardening
|
||||||
|
and interrupted preparation. `panama test --safe` never reloads a live daemon,
|
||||||
|
so it is not live-host proof.
|
||||||
+18
-4
@@ -1,5 +1,13 @@
|
|||||||
# Ignore bash environment variables.
|
# Ignore bash environment variables, and the lock its writer takes.
|
||||||
/config/bash/env
|
/config/bash/env
|
||||||
|
/config/bash/.env.lock
|
||||||
|
# Server secrets and state never enter the repo: live .env files belong in
|
||||||
|
# ~/Server/<Name>/ (only compose.yml is symlinked back here), and data/ is
|
||||||
|
# the bind-mount convention. Ignored anyway as a seatbelt -- the repo is
|
||||||
|
# public, and tests/server/compose-secrets-contract is the check that this
|
||||||
|
# line still exists.
|
||||||
|
/server/containers/**/.env
|
||||||
|
/server/containers/**/data/
|
||||||
# Personal espanso triggers (name, email), seeded per-machine by setup-identity.
|
# Personal espanso triggers (name, email), seeded per-machine by setup-identity.
|
||||||
/config/dot/espanso/match/identity.yml
|
/config/dot/espanso/match/identity.yml
|
||||||
# Ignore backups of old config files
|
# Ignore backups of old config files
|
||||||
@@ -21,14 +29,20 @@ __pycache__/
|
|||||||
|
|
||||||
# Generated from the color scheme; machine state, not configuration.
|
# Generated from the color scheme; machine state, not configuration.
|
||||||
/config/dot/gtk-3.0/settings.ini
|
/config/dot/gtk-3.0/settings.ini
|
||||||
|
/config/dot/gtk-3.0/bookmarks
|
||||||
/config/dot/gtk-4.0/settings.ini
|
/config/dot/gtk-4.0/settings.ini
|
||||||
/config/dot/tmux/current-theme.conf
|
/config/dot/tmux/current-theme.conf
|
||||||
/config/dot/hypr/hyprlock.conf
|
/config/dot/hypr/hyprlock.conf
|
||||||
|
# The Firefox chrome override sheet, rendered from the active theme. It sits
|
||||||
|
# beside custom.css because chrome/ is symlinked into the profile as one
|
||||||
|
# directory; custom.css imports it and stays the file a person edits.
|
||||||
|
/config/firefox/chrome/panama-theme.css
|
||||||
|
/config/firefox/chrome/panama-theme.css.tmp
|
||||||
|
|
||||||
# Build products of the Vicinae extension. The source is the repository's; the
|
# Build products of the Vicinae extension. The source and reviewed lockfile are
|
||||||
# dependency tree and the bundle it produces are machine state, rebuilt by
|
# in the repository; the dependency tree and bundle are machine state.
|
||||||
# `panama apps`.
|
|
||||||
/config/local/share/vicinae/extensions/*/node_modules/
|
/config/local/share/vicinae/extensions/*/node_modules/
|
||||||
/config/local/share/vicinae/extensions/*/dist/
|
/config/local/share/vicinae/extensions/*/dist/
|
||||||
/config/local/share/vicinae/extensions/*/build/
|
/config/local/share/vicinae/extensions/*/build/
|
||||||
/config/local/share/vicinae/extensions/*/package-lock.json
|
/config/local/share/vicinae/extensions/*/package-lock.json
|
||||||
|
!/config/local/share/vicinae/extensions/panama-search/package-lock.json
|
||||||
|
|||||||
@@ -1,24 +1,98 @@
|
|||||||
# Panama
|
# Panama
|
||||||
|
|
||||||
Formerly Sunhat. A personal config for Fedora, with the intention of helping a
|
Formerly Sunhat. A personal config for Fedora, with the intention of helping a
|
||||||
user set up their Fedora system with one command.
|
user set up their Fedora system with one checked command.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot)
|
(
|
||||||
|
set -euo pipefail
|
||||||
|
bootstrap_commit=0d1841cf86975d52485c94c0caa5d1b67e5c54a1
|
||||||
|
bootstrap_sha=fd8dcd70d1266da109972df346e3d85d1037f6f6e1942b0157064412ab892372
|
||||||
|
bootstrap_dir="$(umask 077; mktemp -d)"
|
||||||
|
trap 'rm -rf -- "$bootstrap_dir"' EXIT
|
||||||
|
bootstrap="$bootstrap_dir/boot"
|
||||||
|
curl --fail --location --connect-timeout 10 --max-time 30 --max-filesize 262144 \
|
||||||
|
--output "$bootstrap" \
|
||||||
|
"https://git.gbrown.org/gib/Panama/raw/commit/$bootstrap_commit/boot"
|
||||||
|
printf '%s %s\n' "$bootstrap_sha" "$bootstrap" >"$bootstrap_dir/boot.sha256"
|
||||||
|
sha256sum -c "$bootstrap_dir/boot.sha256"
|
||||||
|
PANAMA_BOOT_REVISION="$bootstrap_commit" PANAMA_BOOT_SHA256="$bootstrap_sha" \
|
||||||
|
bash "$bootstrap"
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
`boot` installs git if the machine lacks it, clones this repository to
|
`boot` installs git if the machine lacks it, creates a verified checkout at
|
||||||
`~/.local/share/Panama` (or `$PANAMA_PATH`), and hands off to `install`. It is
|
`~/.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
|
deliberately small enough to read first. The command downloads one immutable
|
||||||
identically:
|
`boot` blob into a private temporary directory, checks its SHA-256, then asks
|
||||||
|
that verified script to fetch the same commit. A clean existing checkout may
|
||||||
|
fast-forward to that exact revision. A dirty or divergent checkout, a failed
|
||||||
|
fetch, or a revision mismatch stops before `install`; Panama never resets local
|
||||||
|
work or falls back to whatever checkout happens to be present.
|
||||||
|
|
||||||
|
Once a machine exists, the command that keeps it current is `panama update` —
|
||||||
|
one command, and it never asks you anything:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama
|
panama update
|
||||||
~/.local/share/Panama/install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Both are safe to run again: an existing clone is fast-forwarded rather than
|
It pulls, applies any repairs this machine has not had, and runs the stages
|
||||||
replaced, and `install` is the upgrade path.
|
below that need no answers. `./install` remains what it is: how a machine is
|
||||||
|
built, and how you change an answer you gave.
|
||||||
|
|
||||||
|
The first question the interview asks is what the machine is: a **desktop**
|
||||||
|
gets everything below; a **server** — a VPS, a headless box — gets the same
|
||||||
|
shell environment minus everything that needs a screen, plus rootless podman
|
||||||
|
and the compose services in [`server/`](server/README.md). The answer is the
|
||||||
|
one thing Panama records durably (`~/.local/state/panama/role`), because
|
||||||
|
`panama update` asks nothing and still has to know. A fresh server skips the
|
||||||
|
question entirely. It uses the same immutable boot blob and pins as the desktop
|
||||||
|
command; only the final `--server` argument differs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
(
|
||||||
|
set -euo pipefail
|
||||||
|
bootstrap_commit=0d1841cf86975d52485c94c0caa5d1b67e5c54a1
|
||||||
|
bootstrap_sha=fd8dcd70d1266da109972df346e3d85d1037f6f6e1942b0157064412ab892372
|
||||||
|
bootstrap_dir="$(umask 077; mktemp -d)"
|
||||||
|
trap 'rm -rf -- "$bootstrap_dir"' EXIT
|
||||||
|
bootstrap="$bootstrap_dir/boot"
|
||||||
|
curl --fail --location --connect-timeout 10 --max-time 30 --max-filesize 262144 \
|
||||||
|
--output "$bootstrap" \
|
||||||
|
"https://git.gbrown.org/gib/Panama/raw/commit/$bootstrap_commit/boot"
|
||||||
|
printf '%s %s\n' "$bootstrap_sha" "$bootstrap" >"$bootstrap_dir/boot.sha256"
|
||||||
|
sha256sum -c "$bootstrap_dir/boot.sha256"
|
||||||
|
PANAMA_BOOT_REVISION="$bootstrap_commit" PANAMA_BOOT_SHA256="$bootstrap_sha" \
|
||||||
|
bash "$bootstrap" --server
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
That command also works from a brand-new VPS's **root** login. It creates or
|
||||||
|
reuses your sudo-enabled user, then copies a safe root key when it can or
|
||||||
|
verifies the target key before offering SSH hardening. A verified target key
|
||||||
|
means the target user owns `.ssh` with mode `0700` and `authorized_keys` with
|
||||||
|
mode `0600`. Every non-comment line in `authorized_keys` must be valid OpenSSH
|
||||||
|
key material that `ssh-keygen` can parse. Root-key destination writes run as
|
||||||
|
the target user, and Panama rechecks the resulting owner, modes, and keys. SSH
|
||||||
|
hardening is unavailable without a verified target key or installed SSH unit,
|
||||||
|
and the install continues without it. Declining hardening also leaves SSH
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
When you accept hardening, Panama uses an atomic same-directory drop-in named
|
||||||
|
`00-panama.conf`, whose early filename gives it safer precedence. A pre-existing
|
||||||
|
symlink or non-regular object makes hardening unavailable. The effective policy
|
||||||
|
is exactly `PermitRootLogin no`, `PasswordAuthentication no`, and
|
||||||
|
`KbdInteractiveAuthentication no`. Panama validates syntax with `sshd -t` and
|
||||||
|
checks `sshd -T -C` for both root and target-user contexts before it reloads
|
||||||
|
the detected SSH unit. If validation or reload fails, it restores the previous
|
||||||
|
drop-in regular file with its metadata and validates and reloads that restored
|
||||||
|
configuration. Recovery that cannot complete stops the handoff and prints the
|
||||||
|
right manual command for either a prior file or no prior file. The fixture
|
||||||
|
contracts test these branches. No real daemon reload runs under
|
||||||
|
`panama test --safe`, so that suite is not live-host proof.
|
||||||
|
|
||||||
|
After that, it hands off to a normal install as the new user.
|
||||||
|
|
||||||
`install` asks its questions first and then runs the stages in `setup/scripts/`
|
`install` asks its questions first and then runs the stages in `setup/scripts/`
|
||||||
in order, without stopping again:
|
in order, without stopping again:
|
||||||
@@ -28,11 +102,21 @@ in order, without stopping again:
|
|||||||
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
| `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 |
|
| `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-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
||||||
|
| `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
||||||
| `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) |
|
| `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 |
|
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
||||||
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||||
| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for |
|
| `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 |
|
| `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 |
|
||||||
|
| `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81), the `nginx-bridge` network, and the nightly image-update timer |
|
||||||
|
| `link-server` | Server role only: creates `~/Server` and refreshes the links of whatever services this machine has enabled |
|
||||||
|
|
||||||
|
A server runs the shared stages plus its own two, and skips the rest —
|
||||||
|
`link-skills`, `change-settings`, `link-vicinae-scripts` and
|
||||||
|
`install-hardware` are desktop concerns. `link-dotfiles` links only the
|
||||||
|
universal dot dirs there (bash, nvim, tmux, btop and tmux theming, the hook
|
||||||
|
samples), and `install-packages` takes a short path: the core tools, nvm,
|
||||||
|
Bun, Claude Code and Codex — no third-party repos, no desktop, no flatpaks.
|
||||||
|
|
||||||
Then `panama migrate` applies any repairs this machine has not had yet. That is
|
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
|
the half of an upgrade installing cannot do: the stages above only ever add, so
|
||||||
@@ -73,19 +157,37 @@ the comments of the components themselves, because knowing what a thing was
|
|||||||
modelled on explains why it behaves the way it does.
|
modelled on explains why it behaves the way it does.
|
||||||
|
|
||||||
GNOME is not gone from the machine: `gnome-control-center` is a declared
|
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
|
dependency, and two rows in Panama's own Settings still open it. Adding an
|
||||||
deliberately does not own — Online Accounts, Color, Sound, Network, Keyboard,
|
online account goes through the provider's dialog, because the OAuth sign-in
|
||||||
Privacy, Wellbeing, Accessibility, and System for users, date and time, region
|
runs inside a library Fedora ships without a binding anything else can call.
|
||||||
and remote desktop. The allow-list in
|
And Digital wellbeing — screen time and break reminders — is the one panel of
|
||||||
[`services/SystemSettings.qml`](config/dot/quickshell/services/SystemSettings.qml)
|
GNOME's that still does something Panama does not. Everything else it used to
|
||||||
is what decides; anything not on it is a panel Panama owns itself.
|
hand over is a page here now, and
|
||||||
|
[`tests/quickshell/gnome-handoff-contract`](tests/quickshell/gnome-handoff-contract)
|
||||||
|
fails the build if a door back opens onto a panel Panama owns, because those
|
||||||
|
rows kept working perfectly long after they stopped being true.
|
||||||
|
|
||||||
|
Displays is one of the panels it owns, and the only one where a wrong answer
|
||||||
|
can leave you unable to see well enough to undo it. So every change there is
|
||||||
|
applied as one complete layout, read back from the compositor, and reverted
|
||||||
|
after fifteen seconds unless you keep it — resolution, scale, rotation,
|
||||||
|
position and primary display, and now color profile, bit depth, SDR
|
||||||
|
brightness and saturation, and mirroring with them. Two of those opt out of
|
||||||
|
part of that, for reasons rather than convenience. A per-display variable
|
||||||
|
refresh rate override is applied but never verified, because `hyprctl` reports
|
||||||
|
whether adaptive sync is live this instant rather than what was asked for. And
|
||||||
|
a mirrored display's position is not asserted at all: the compositor stacks it
|
||||||
|
on the display it mirrors and ignores the coordinates the rule carried, so
|
||||||
|
holding it to them would make Keep permanently unavailable. Monitor brightness
|
||||||
|
sits outside the transaction entirely — it is the panel's own backlight over
|
||||||
|
DDC, and the buttons on the bezel change it behind our back.
|
||||||
|
|
||||||
| Piece | What it is |
|
| Piece | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
| `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/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
||||||
| `config/containers/` | Container definitions systemd runs as units — currently the speech-to-text server behind dictation |
|
| `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/vicinae/` | Raycast-style launcher, themed. Its commands live in `config/local/share/vicinae/` — script commands (settings deep links, power actions, projects, 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/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/wofi/` | Fallback launcher, in case the shell fails to start |
|
||||||
| `config/dot/xdg-desktop-portal/` | Portal backend routing |
|
| `config/dot/xdg-desktop-portal/` | Portal backend routing |
|
||||||
@@ -93,7 +195,11 @@ is what decides; anything not on it is a panel Panama owns itself.
|
|||||||
New machines get a welcome card on first start, teaching the handful of keys
|
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
|
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
|
shortcut the machine has, read from the live keymap so a rebind appears there
|
||||||
without anything being kept in sync. Settings carries a
|
without anything being kept in sync. Rebinding is Settings' job, and so is
|
||||||
|
adding a shortcut of your own or assigning the four-finger touchpad swipes: an
|
||||||
|
entry names an application, a shell action or a window move rather than
|
||||||
|
carrying a command, so a hand-edited settings file can move a key but cannot
|
||||||
|
make one run something arbitrary. Settings carries a
|
||||||
[manual](config/dot/quickshell/manual/) written for the person using the
|
[manual](config/dot/quickshell/manual/) written for the person using the
|
||||||
desktop rather than the person building it, opening with a chapter for people
|
desktop rather than the person building it, opening with a chapter for people
|
||||||
arriving from GNOME, macOS or Windows.
|
arriving from GNOME, macOS or Windows.
|
||||||
@@ -117,33 +223,50 @@ migrations/ One repair per file, for machines installed before a change
|
|||||||
config/
|
config/
|
||||||
bash/ .bashrc, aliases, env (env is gitignored)
|
bash/ .bashrc, aliases, env (env is gitignored)
|
||||||
copy/ Files copied verbatim over / (needs sudo)
|
copy/ Files copied verbatim over / (needs sudo)
|
||||||
dot/ Symlinked into ~/.config
|
dot/ Symlinked into ~/.config; quickshell/manual/ is the manual
|
||||||
|
Settings renders
|
||||||
firefox/ Vendored Firefox chrome, linked into the browser profile
|
firefox/ Vendored Firefox chrome, linked into the browser profile
|
||||||
containers/ Quadlets, linked into ~/.config/containers/systemd
|
containers/ Quadlets, linked into ~/.config/containers/systemd
|
||||||
local/ Icons, the cursor theme, and the launcher's commands and
|
local/ Icons, the cursor theme, and the launcher's commands and
|
||||||
extensions, linked into ~/.local/share
|
extensions, linked into ~/.local/share
|
||||||
old/ Backups of whatever was replaced (gitignored)
|
old/ Backups of whatever was replaced (gitignored)
|
||||||
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
|
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
|
||||||
|
server/ The server role: compose services (one directory per
|
||||||
|
service), the nightly image updater, and its units. See
|
||||||
|
server/README.md
|
||||||
|
skills/ Agent skills for operating this desktop, linked into
|
||||||
|
~/.claude/skills
|
||||||
setup/
|
setup/
|
||||||
apps/ Applications built from source, one file each
|
apps/ Applications built from source, one file each
|
||||||
lib/ Shared by more than one stage; the extras catalog reader
|
lib/ Shared by more than one stage; the extras catalog reader
|
||||||
packages/ One package per line; extras/ holds the optional categories
|
packages/ One package per line; extras/ holds the optional categories
|
||||||
scripts/ Run in order by ./install
|
scripts/ Run in order by ./install
|
||||||
tests/ Contracts. See below
|
tests/ Contracts. See below
|
||||||
manual/ The user manual, rendered in Settings
|
|
||||||
docs/ Settings reference, and the design specs behind the work
|
docs/ Settings reference, and the design specs behind the work
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
156 of them, under `tests/`. Run the lot, or a subset by pattern:
|
188 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||||
|
contract by the capabilities it needs. Run the hermetic set, or grant a
|
||||||
|
specific external capability when automation needs it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
panama test # everything
|
panama test --safe
|
||||||
panama test dock # just the ones matching "dock"
|
panama test --allow live-host updates
|
||||||
|
panama test --allow live-compositor keybinds
|
||||||
|
PANAMA_TEST_TIMEOUT_SECONDS=300 panama test --safe
|
||||||
tests/setup/interview-contract # or one directly; they are plain executables
|
tests/setup/interview-contract # or one directly; they are plain executables
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--safe` means hermetic contracts only. A plain full run prompts in a terminal
|
||||||
|
before it starts any non-hermetic contract. Automation cannot answer that
|
||||||
|
prompt, so it must grant every required capability with a repeatable `--allow`.
|
||||||
|
Each contract has a 180-second outer timeout by default. Set
|
||||||
|
`PANAMA_TEST_TIMEOUT_SECONDS` to a positive integer to override it. When a
|
||||||
|
contract fails, the runner prints its captured stdout and stderr. Successful
|
||||||
|
stdout stays quiet. Successful stderr is surfaced as a warning.
|
||||||
|
|
||||||
They are called contracts rather than unit tests because that is what they are:
|
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
|
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
|
accident. Most read or measure the real thing — launching a shell to measure a
|
||||||
@@ -156,6 +279,8 @@ been ones that source text looked fine for.
|
|||||||
tests/setup/ The installer: the interview, package lists, hardware, extras
|
tests/setup/ The installer: the interview, package lists, hardware, extras
|
||||||
tests/quickshell/ The shell and its settings pages
|
tests/quickshell/ The shell and its settings pages
|
||||||
tests/hypr/ The compositor config
|
tests/hypr/ The compositor config
|
||||||
|
tests/server/ The server role: the service catalog's shape, the secrets
|
||||||
|
rule that keeps a public repo safe, and panama-server
|
||||||
```
|
```
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
@@ -168,21 +293,44 @@ name it; **Open Project** lays it out again.
|
|||||||
Workspaces are recorded as positions rather than numbers, and opening a project
|
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
|
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
|
application that refuses to open twice — Slack, Thunderbird, the browser — is
|
||||||
moved into place rather than launched again. Saved layouts are listed on the
|
moved into place rather than launched again. Saved layouts are listed on
|
||||||
Desktop settings page, which is also where they are removed.
|
Shell › Workspaces in Settings, which is also where they are removed.
|
||||||
|
|
||||||
## The `panama` command
|
## The `panama` command
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
panama update # review, commit and sync this repo
|
panama update # bring this machine up to date; asks nothing
|
||||||
|
panama sync # review, commit and push your changes to this repo
|
||||||
panama edit # open it in Neovim
|
panama edit # open it in Neovim
|
||||||
panama doctor # what is actually running, not what was installed
|
panama doctor # what is actually running, not what was installed
|
||||||
panama test # every contract, or a subset by pattern
|
panama diagnose # hand the health summary and recent errors to your agent
|
||||||
panama upgrade # re-run ./install from anywhere
|
panama test # every contract, prompting before non-hermetic work
|
||||||
|
panama test --safe # hermetic contracts only
|
||||||
|
panama test --allow live-host updates # grant one capability to automation
|
||||||
|
panama contracts <file> # which contracts mention a file, and can they be run
|
||||||
|
panama migrate # apply repairs this machine has not had yet
|
||||||
|
panama upgrade # re-run ./install from anywhere, interview and all
|
||||||
panama apps # choose applications to install, by category
|
panama apps # choose applications to install, by category
|
||||||
panama app # applications no repository carries; build one by name
|
panama app # applications no repository carries; build one by name
|
||||||
|
panama server # the services a server runs: list, enable, disable, status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`panama update` and `panama sync` are separate verbs on purpose. One acts on
|
||||||
|
the machine, the other on the repository. A single command that chose between
|
||||||
|
them by checking whether the working tree happened to be dirty would do a
|
||||||
|
different job depending on state nobody can see — and, worse, would never
|
||||||
|
update a machine belonging to somebody who had left a file edited.
|
||||||
|
|
||||||
|
`panama update` stashes uncommitted work across the pull and restores it
|
||||||
|
afterwards. If restoring conflicts it resets the checkout and leaves the work
|
||||||
|
in the stash, saying so at the end of the run: every dotfile here is a symlink
|
||||||
|
into this repository, so a conflict marker is not a thing to fix at leisure. It
|
||||||
|
is live in `~/.config` the moment it is written.
|
||||||
|
|
||||||
|
Only two things still need `./install`: a machine that does not exist yet, and
|
||||||
|
an answer you want to change. Adding a package to a list you already have is
|
||||||
|
`panama update`; adding an optional category is `panama apps`.
|
||||||
|
|
||||||
`panama apps` is the optional-application catalog, opened after the fact. The
|
`panama apps` is the optional-application catalog, opened after the fact. The
|
||||||
interview offers the same categories during `./install`, whole; this picks a
|
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
|
category and then the applications inside it, so a machine can acquire Slack in
|
||||||
@@ -195,7 +343,7 @@ readable, and an indented line belongs to the entry above it — which is how OB
|
|||||||
carries its sixteen plugin extensions as one thing to tick.
|
carries its sixteen plugin extensions as one thing to tick.
|
||||||
|
|
||||||
Hooks are the extension point: drop a script at `~/.config/panama/hooks/theme-set`
|
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
|
and it runs whenever the color scheme changes, with the scheme and accent as
|
||||||
arguments. Same for `post-upgrade` and `post-migrate`, and a `<name>.d/`
|
arguments. Same for `post-upgrade` and `post-migrate`, and a `<name>.d/`
|
||||||
directory beside each so several things can react without fighting over one
|
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
|
file. A broken hook is reported and stepped over, never fatal. Samples are
|
||||||
@@ -221,6 +369,8 @@ 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
|
Adding one is adding a file to `setup/apps/`, and the file has to say why the
|
||||||
exception exists.
|
exception exists.
|
||||||
|
|
||||||
None of the scripts in this repository carry a `.sh` extension. A shebang and
|
No script in this repository carries a `.sh` extension, with one deliberate
|
||||||
the executable bit already select the interpreter, and the extension only
|
exception: each repair in `migrations/` is named `<timestamp>.sh`, because
|
||||||
|
`panama-migrate` finds them by globbing exactly that. Everywhere else 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.
|
becomes something to keep in sync — which it did not stay.
|
||||||
|
|||||||
+693
-83
@@ -5,15 +5,24 @@
|
|||||||
# Author: Gabriel Brown
|
# Author: Gabriel Brown
|
||||||
#
|
#
|
||||||
# Commands:
|
# Commands:
|
||||||
# update Commit & sync local changes (or just pull if clean)
|
# update Bring this machine up to date: pull, then install --upgrade
|
||||||
|
# sync Review, commit & push local changes to this repo
|
||||||
# edit Open the Panama repo in Neovim
|
# edit Open the Panama repo in Neovim
|
||||||
# doctor Report what is actually running on this machine
|
# doctor Report what is actually running on this machine
|
||||||
# test Run every contract under tests/
|
# diagnose Hand this machine's health and recent errors to your agent
|
||||||
# upgrade Re-run the installer from anywhere
|
# test Run contracts classified by tests/contracts.manifest
|
||||||
|
# contracts Name the contracts that mention a given file
|
||||||
|
# upgrade Re-run the installer from anywhere, interview included
|
||||||
|
# migrate Apply repairs this machine has not had yet
|
||||||
# apps Choose applications to install, by category
|
# apps Choose applications to install, by category
|
||||||
# app Build and install an application that no repository packages
|
# app Build and install an application that no repository packages
|
||||||
# help Show this help
|
# help Show this help
|
||||||
#
|
#
|
||||||
|
# update and sync are deliberately separate verbs. One acts on the machine, the
|
||||||
|
# other on the repository, and a single command that guessed between them by
|
||||||
|
# looking at whether the tree happened to be dirty would do a different job
|
||||||
|
# depending on state nobody can see.
|
||||||
|
#
|
||||||
# Designed to grow: add new subcommands as cmd_<name> functions and
|
# Designed to grow: add new subcommands as cmd_<name> functions and
|
||||||
# register them in the dispatcher / usage block below.
|
# register them in the dispatcher / usage block below.
|
||||||
|
|
||||||
@@ -48,7 +57,7 @@ header(){ printf '\n%s%s%s\n' "${MAGENTA}${BOLD}" "$*" "$RESET"; }
|
|||||||
# Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no.
|
# Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no.
|
||||||
confirm() {
|
confirm() {
|
||||||
local prompt="$1" reply
|
local prompt="$1" reply
|
||||||
printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET"
|
printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET" >&2
|
||||||
read -r reply || true
|
read -r reply || true
|
||||||
[[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
|
[[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
|
||||||
}
|
}
|
||||||
@@ -64,15 +73,34 @@ ${BOLD}Usage:${RESET}
|
|||||||
$PROGRAM <command> [options]
|
$PROGRAM <command> [options]
|
||||||
|
|
||||||
${BOLD}Commands:${RESET}
|
${BOLD}Commands:${RESET}
|
||||||
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
|
${GREEN}update${RESET} Bring this machine up to date. Pulls, then runs the stages
|
||||||
clean it simply runs 'git pull'.
|
that need no questions asked. The routine command; safe to
|
||||||
|
re-run, and it never asks you anything.
|
||||||
|
${GREEN}sync${RESET} Review, commit & push your changes to this repo. Uncommitted
|
||||||
|
work is committed before anything is fetched, so a moved
|
||||||
|
upstream is a rebase rather than a stash conflict.
|
||||||
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
||||||
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
||||||
than what was installed. Takes --summary for one line per check.
|
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
|
${GREEN}diagnose${RESET} Hand the health summary, the recent journal errors and
|
||||||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
whatever you say is wrong to your coding agent, in a terminal.
|
||||||
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
|
Needs an agent chosen on Settings › System › Agents.
|
||||||
idempotent and this is the documented upgrade path.
|
${GREEN}test${RESET} Run contracts classified by tests/contracts.manifest. Give it a
|
||||||
|
pattern to run a subset. --safe selects hermetic contracts only.
|
||||||
|
Plain terminal runs prompt before non-hermetic work. Automation
|
||||||
|
must grant each required capability with a repeatable --allow.
|
||||||
|
Each non-hermetic contract announces its exact capabilities
|
||||||
|
before it starts.
|
||||||
|
Failures print captured stdout/stderr. Successful stdout stays
|
||||||
|
quiet; successful stderr is a warning. The default outer timeout
|
||||||
|
is 180 seconds. Set PANAMA_TEST_TIMEOUT_SECONDS to a positive
|
||||||
|
integer to override it.
|
||||||
|
${GREEN}contracts${RESET} Name the contracts that mention a given file, each labeled
|
||||||
|
with manifest capabilities. A heuristic over the text of tests/, so it
|
||||||
|
answers "what should I run" rather than "what covers this".
|
||||||
|
${GREEN}upgrade${RESET} Re-run ./install from anywhere, interview and all. For a new
|
||||||
|
machine, or to change an answer you gave. Routine updates are
|
||||||
|
'$PROGRAM update', which asks nothing.
|
||||||
${GREEN}migrate${RESET} Apply repairs this machine has not had yet. The half of an
|
${GREEN}migrate${RESET} Apply repairs this machine has not had yet. The half of an
|
||||||
upgrade that ./install cannot do, because installing only ever
|
upgrade that ./install cannot do, because installing only ever
|
||||||
adds. Safe to re-run; nothing is applied twice.
|
adds. Safe to re-run; nothing is applied twice.
|
||||||
@@ -81,6 +109,8 @@ ${BOLD}Commands:${RESET}
|
|||||||
install.
|
install.
|
||||||
${GREEN}app${RESET} Build and install an application that neither dnf nor
|
${GREEN}app${RESET} Build and install an application that neither dnf nor
|
||||||
Flathub carries. With no name, lists what is available.
|
Flathub carries. With no name, lists what is available.
|
||||||
|
${GREEN}server${RESET} The compose services a server machine runs: list, enable,
|
||||||
|
disable, status, relink. See 'panama server help'.
|
||||||
${GREEN}help${RESET} Show this help (also -h, --help).
|
${GREEN}help${RESET} Show this help (also -h, --help).
|
||||||
|
|
||||||
${BOLD}Options:${RESET}
|
${BOLD}Options:${RESET}
|
||||||
@@ -89,9 +119,18 @@ ${BOLD}Options:${RESET}
|
|||||||
|
|
||||||
${BOLD}Examples:${RESET}
|
${BOLD}Examples:${RESET}
|
||||||
$PROGRAM update
|
$PROGRAM update
|
||||||
|
$PROGRAM update --packages
|
||||||
|
$PROGRAM sync
|
||||||
$PROGRAM edit
|
$PROGRAM edit
|
||||||
$PROGRAM doctor --summary
|
$PROGRAM doctor --summary
|
||||||
|
$PROGRAM diagnose
|
||||||
|
$PROGRAM diagnose the bar disappears after unplugging the monitor
|
||||||
$PROGRAM test dock
|
$PROGRAM test dock
|
||||||
|
$PROGRAM test --safe
|
||||||
|
$PROGRAM test --allow live-host updates
|
||||||
|
$PROGRAM test --allow live-compositor keybinds
|
||||||
|
PANAMA_TEST_TIMEOUT_SECONDS=300 $PROGRAM test --safe
|
||||||
|
$PROGRAM contracts config/dot/quickshell/services/Displays.qml
|
||||||
$PROGRAM upgrade
|
$PROGRAM upgrade
|
||||||
$PROGRAM apps
|
$PROGRAM apps
|
||||||
$PROGRAM app
|
$PROGRAM app
|
||||||
@@ -112,25 +151,116 @@ require_git_repo() {
|
|||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Command: update
|
# Command: update
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The routine command: bring THIS MACHINE up to date. Pull, then hand the rest
|
||||||
|
# to `install --upgrade`, which asks nothing.
|
||||||
|
#
|
||||||
|
# Most of an update needs no stage at all. Every dotfile is a symlink into this
|
||||||
|
# checkout, so an edit to an existing config/dot/** file is live the moment the
|
||||||
|
# pull returns -- there is nothing to apply. Stages earn their place when a pull
|
||||||
|
# brings something structural: a new dotfile directory to link, a file under
|
||||||
|
# config/copy/ to place as root, a new package, a gsettings change. Running the
|
||||||
|
# cheap ones every time is idempotent and takes seconds; working out which were
|
||||||
|
# needed is guesswork with a silent failure mode.
|
||||||
|
#
|
||||||
|
# Uncommitted work never blocks an update. It is stashed across the pull and
|
||||||
|
# restored afterwards -- and if restoring conflicts, the tree is reset rather
|
||||||
|
# than left holding conflict markers, because on this repository those markers
|
||||||
|
# are not something you fix at your leisure. They are live in ~/.config the
|
||||||
|
# instant they are written, and a half-merged .qml is a shell that will not
|
||||||
|
# parse.
|
||||||
cmd_update() {
|
cmd_update() {
|
||||||
require_git_repo
|
require_git_repo
|
||||||
cd "$PANAMA_DIR"
|
cd "$PANAMA_DIR"
|
||||||
|
|
||||||
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
|
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
|
||||||
|
|
||||||
# Any changes in the working tree? (modified, staged, or untracked)
|
local stashed=0 conflict_stash=""
|
||||||
if [[ -z "$(git status --porcelain)" ]]; then
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
info "Working tree is clean — pulling latest changes."
|
info "Local changes; stashing them across the pull."
|
||||||
if git pull --ff-only; then
|
if git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null; then
|
||||||
ok "Already in sync."
|
stashed=1
|
||||||
else
|
else
|
||||||
err "git pull failed."
|
err "Could not stash local changes, so the pull would overwrite them."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --ff-only on purpose. A diverged branch is something to resolve
|
||||||
|
# deliberately, not something an update command should merge on your behalf.
|
||||||
|
# It is a warning rather than an error: the stages below are still worth
|
||||||
|
# running against whatever is checked out.
|
||||||
|
if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
||||||
|
info "Pulling..."
|
||||||
|
if git pull --ff-only; then
|
||||||
|
ok "Checkout is current."
|
||||||
|
else
|
||||||
|
warn "Could not fast-forward — continuing with what is checked out."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "No upstream configured for this branch; nothing to pull."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( stashed )); then
|
||||||
|
if git stash pop >/dev/null 2>&1; then
|
||||||
|
ok "Local changes restored."
|
||||||
|
else
|
||||||
|
# A conflicted pop keeps the stash entry -- git says so itself, and the
|
||||||
|
# contract proves it -- so resetting here loses nothing. The work stays
|
||||||
|
# in the stash, where nothing is reading it, instead of in your live
|
||||||
|
# config as merge markers.
|
||||||
|
git reset --hard HEAD >/dev/null 2>&1
|
||||||
|
conflict_stash="$(git stash list --format='%gd: %gs' 2>/dev/null | head -1)"
|
||||||
|
warn "Your local changes conflict with what was pulled; they stay stashed."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local installer="$PANAMA_DIR/install"
|
||||||
|
if [[ ! -x "$installer" ]]; then
|
||||||
|
err "The installer is missing from $installer"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local rc=0
|
||||||
|
"$installer" --upgrade "$@" || rc=$?
|
||||||
|
|
||||||
|
# Repeated at the very end rather than only where it happened. A warning
|
||||||
|
# printed before twenty minutes of dnf output is a warning nobody read.
|
||||||
|
if [[ -n "$conflict_stash" ]]; then
|
||||||
|
echo
|
||||||
|
warn "Your local changes were NOT restored — they conflicted with the pull."
|
||||||
|
printf ' They are safe at %s%s%s\n' "$BOLD" "$conflict_stash" "$RESET"
|
||||||
|
printf ' Restore them with: %sgit stash pop%s\n' "$BOLD" "$RESET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: sync
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The other half of what `update` used to mean: commit and push MY EDITS.
|
||||||
|
# Panama is a working tree people edit in place -- every dotfile is a symlink
|
||||||
|
# into it -- so "I changed something, put it upstream" is a daily action and
|
||||||
|
# deserves its own verb rather than sharing one with "update my machine".
|
||||||
|
#
|
||||||
|
# The commit happens BEFORE anything is fetched, which is why there is no stash
|
||||||
|
# in here. By the time upstream is consulted the work is a commit, so a moved
|
||||||
|
# upstream is a rebase over committed history -- recoverable, ordinary, and
|
||||||
|
# nothing like a stash pop conflicting into a live config.
|
||||||
|
cmd_sync() {
|
||||||
|
require_git_repo
|
||||||
|
cd "$PANAMA_DIR"
|
||||||
|
|
||||||
|
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
|
||||||
|
|
||||||
|
if [[ -z "$(git status --porcelain)" ]]; then
|
||||||
|
ok "Nothing to commit — the working tree is clean."
|
||||||
|
info "To update this machine, run: ${BOLD}${PROGRAM} update${RESET}"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Show what changed
|
|
||||||
header "Changed files"
|
header "Changed files"
|
||||||
git -c color.status=always status --short
|
git -c color.status=always status --short
|
||||||
|
|
||||||
@@ -154,7 +284,6 @@ cmd_update() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Commit message
|
|
||||||
local msg
|
local msg
|
||||||
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
|
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
|
||||||
read -r msg || true
|
read -r msg || true
|
||||||
@@ -163,46 +292,35 @@ cmd_update() {
|
|||||||
warn "No message given — using: ${BOLD}${msg}${RESET}"
|
warn "No message given — using: ${BOLD}${msg}${RESET}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Is the local branch up to date with its upstream?
|
|
||||||
info "Checking whether the repo is up to date..."
|
|
||||||
if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
|
||||||
git fetch --quiet
|
|
||||||
local local_rev remote_rev base_rev
|
|
||||||
local_rev=$(git rev-parse @)
|
|
||||||
remote_rev=$(git rev-parse '@{u}')
|
|
||||||
base_rev=$(git merge-base @ '@{u}')
|
|
||||||
|
|
||||||
if [[ "$local_rev" == "$remote_rev" ]]; then
|
|
||||||
ok "Repo is up to date."
|
|
||||||
elif [[ "$local_rev" == "$base_rev" ]]; then
|
|
||||||
warn "Repo is behind upstream — stashing, pulling, then re-applying."
|
|
||||||
info "Stashing local changes..."
|
|
||||||
git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null
|
|
||||||
|
|
||||||
if ! git pull --ff-only; then
|
|
||||||
err "git pull failed — restoring your changes."
|
|
||||||
git stash pop || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Re-applying stashed changes..."
|
|
||||||
if ! git stash pop; then
|
|
||||||
err "Conflict while re-applying changes. Resolve it, then commit manually."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "Local branch has diverged from upstream — committing locally only."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "No upstream configured for this branch — committing locally only."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Commit everything
|
|
||||||
info "Committing changes..."
|
info "Committing changes..."
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "$msg"
|
git commit -m "$msg"
|
||||||
ok "Committed: ${BOLD}${msg}${RESET}"
|
ok "Committed: ${BOLD}${msg}${RESET}"
|
||||||
|
|
||||||
|
if ! git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
||||||
|
warn "No upstream configured for this branch — committed locally only."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only now, with the work safely committed, is it worth looking upstream.
|
||||||
|
info "Checking whether upstream has moved..."
|
||||||
|
git fetch --quiet
|
||||||
|
local remote_rev base_rev
|
||||||
|
remote_rev=$(git rev-parse '@{u}')
|
||||||
|
base_rev=$(git merge-base @ '@{u}')
|
||||||
|
|
||||||
|
if [[ "$base_rev" != "$remote_rev" ]]; then
|
||||||
|
warn "Upstream has moved — rebasing your commit onto it."
|
||||||
|
if ! git pull --rebase; then
|
||||||
|
err "The rebase stopped on a conflict."
|
||||||
|
err "Resolve it, then: git rebase --continue"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Rebased onto upstream."
|
||||||
|
else
|
||||||
|
ok "Upstream has not moved."
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if confirm "Push the changes now?"; then
|
if confirm "Push the changes now?"; then
|
||||||
info "Pushing..."
|
info "Pushing..."
|
||||||
@@ -247,64 +365,552 @@ cmd_doctor() {
|
|||||||
exec "$doctor" "$@"
|
exec "$doctor" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: diagnose
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The by-hand rung of the escalation ladder. Every other rung starts from an
|
||||||
|
# event -- a crash, a failed reload, a red check -- and this one starts from a
|
||||||
|
# person who can tell that something is wrong but not what.
|
||||||
|
#
|
||||||
|
# It gathers the two things anybody would be asked for first anyway (what the
|
||||||
|
# health check says, what the journal has been complaining about) and whatever
|
||||||
|
# words follow the command, then hands the lot to the configured agent. The free
|
||||||
|
# text is the valuable part: "the bar disappears after unplugging the monitor"
|
||||||
|
# is a symptom no collector reports, and it is the difference between an agent
|
||||||
|
# reading a health summary and an agent looking for something.
|
||||||
|
cmd_diagnose() {
|
||||||
|
local launcher="$PANAMA_DIR/bin/panama-agent"
|
||||||
|
if [[ ! -x "$launcher" ]]; then
|
||||||
|
err "The agent launcher is missing from $launcher"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local complaint="$*"
|
||||||
|
|
||||||
|
local health="(the health check did not run)"
|
||||||
|
local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
|
||||||
|
if [[ -x "$doctor" ]]; then
|
||||||
|
health="$("$doctor" --summary 2>&1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bounded twice, and not out of tidiness. journalctl counts entries, not
|
||||||
|
# lines, and thirty entries on this machine came to 2,430 lines and a quarter
|
||||||
|
# of a megabyte -- one multi-line traceback each. The prompt leaves as a
|
||||||
|
# single argv element, which the kernel caps at 128KB, so an unbounded excerpt
|
||||||
|
# turns this command into "Argument list too long" rather than a diagnosis.
|
||||||
|
local errors="(nothing at error level in this boot's user journal)"
|
||||||
|
if command -v journalctl >/dev/null 2>&1; then
|
||||||
|
local recent
|
||||||
|
recent="$(journalctl --user -b -p err -n 30 --no-pager --output=short 2>/dev/null \
|
||||||
|
| cut -c 1-300 | tail -80)" || true
|
||||||
|
[[ -n "${recent// }" ]] && errors="$recent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local complaint_section="Nothing in particular was reported; this was run to look around."
|
||||||
|
[[ -n "${complaint// }" ]] && complaint_section="$complaint"
|
||||||
|
|
||||||
|
local prompt
|
||||||
|
prompt="$(cat <<PROMPT
|
||||||
|
Something is wrong with this Panama machine and I would like to know what.
|
||||||
|
|
||||||
|
What I noticed:
|
||||||
|
|
||||||
|
$complaint_section
|
||||||
|
|
||||||
|
What panama doctor --summary says:
|
||||||
|
|
||||||
|
$health
|
||||||
|
|
||||||
|
The last error-level lines in this boot's user journal:
|
||||||
|
|
||||||
|
$errors
|
||||||
|
|
||||||
|
Panama is checked out at $PANAMA_DIR and every dotfile in ~/.config is a symlink
|
||||||
|
into it, so anything you find is a tracked file here rather than a copy. Start
|
||||||
|
by reading: work out what is actually broken and say so before changing
|
||||||
|
anything. If a check is red, 'panama doctor' with no arguments has the long form
|
||||||
|
of it. Root work goes through panama-sudo, which shows me your reason.
|
||||||
|
PROMPT
|
||||||
|
)"
|
||||||
|
|
||||||
|
# exec: from here on the agent's terminal is the process, and this shell has
|
||||||
|
# nothing left to do that the agent is not doing better.
|
||||||
|
exec "$launcher" --prompt "$prompt"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Contract manifest
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The manifest is the runtime authority for every collected contract. An absent
|
||||||
|
# manifest is unsafe: this command must never infer that unclassified tests are
|
||||||
|
# hermetic.
|
||||||
|
CONTRACT_MANIFEST="tests/contracts.manifest"
|
||||||
|
CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network privileged)
|
||||||
|
|
||||||
|
contract_paths() {
|
||||||
|
local candidate
|
||||||
|
while IFS= read -r candidate; do
|
||||||
|
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||||||
|
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
|
||||||
|
done < <(find "$PANAMA_DIR/tests" -type f \
|
||||||
|
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
contract_manifest_entries() {
|
||||||
|
local line capabilities path
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
[[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue
|
||||||
|
IFS=$' \t' read -r capabilities path <<<"$line"
|
||||||
|
printf '%s\t%s\n' "$path" "$capabilities"
|
||||||
|
done < "$PANAMA_DIR/$CONTRACT_MANIFEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_contract_manifest() {
|
||||||
|
[[ -r "$PANAMA_DIR/$CONTRACT_MANIFEST" ]] || {
|
||||||
|
err "Contract manifest is missing or unreadable: $PANAMA_DIR/$CONTRACT_MANIFEST"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_contract_manifest() {
|
||||||
|
require_contract_manifest || return 1
|
||||||
|
|
||||||
|
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
|
||||||
|
local line capabilities path extra previous_comment="" previous_was_comment=0
|
||||||
|
local previous_path="" capability discovered
|
||||||
|
local -a capability_list=() findings=()
|
||||||
|
local -A expected_contracts=() manifest_paths=()
|
||||||
|
|
||||||
|
while IFS= read -r discovered; do
|
||||||
|
expected_contracts["$discovered"]=1
|
||||||
|
done < <(contract_paths)
|
||||||
|
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*# ]]; then
|
||||||
|
previous_comment="${line#*#}"
|
||||||
|
previous_comment="${previous_comment#"${previous_comment%%[![:space:]]*}"}"
|
||||||
|
previous_comment="${previous_comment%"${previous_comment##*[![:space:]]}"}"
|
||||||
|
previous_was_comment=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*$ ]]; then
|
||||||
|
previous_comment=""
|
||||||
|
previous_was_comment=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
IFS=$' \t' read -r capabilities path extra <<<"$line"
|
||||||
|
if [[ -z "${capabilities:-}" || -z "${path:-}" || -n "${extra:-}" ]]; then
|
||||||
|
findings+=("manifest line is not exactly two fields: $line")
|
||||||
|
previous_comment=""
|
||||||
|
previous_was_comment=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$previous_path" && "$path" < "$previous_path" ]]; then
|
||||||
|
findings+=('paths are not lexicographically sorted')
|
||||||
|
fi
|
||||||
|
previous_path="$path"
|
||||||
|
|
||||||
|
if [[ -n "${manifest_paths[$path]:-}" ]]; then
|
||||||
|
findings+=("duplicate path $path")
|
||||||
|
fi
|
||||||
|
manifest_paths["$path"]=1
|
||||||
|
|
||||||
|
local -A line_capabilities=()
|
||||||
|
if [[ "$capabilities" == ,* || "$capabilities" == *, || "$capabilities" == *,,* ]]; then
|
||||||
|
findings+=("empty capability on $path")
|
||||||
|
fi
|
||||||
|
IFS=',' read -r -a capability_list <<<"$capabilities"
|
||||||
|
for capability in "${capability_list[@]}"; do
|
||||||
|
[[ -n "$capability" ]] || continue
|
||||||
|
if [[ -n "${line_capabilities[$capability]:-}" ]]; then
|
||||||
|
findings+=("duplicate capability $capability on $path")
|
||||||
|
fi
|
||||||
|
line_capabilities["$capability"]=1
|
||||||
|
is_contract_capability "$capability" \
|
||||||
|
|| findings+=("unknown capability $capability on $path")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "${line_capabilities[hermetic]:-}" && ${#line_capabilities[@]} -ne 1 ]]; then
|
||||||
|
findings+=("hermetic must appear alone on $path")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$capabilities" != hermetic ]]; then
|
||||||
|
if (( previous_was_comment != 1 )); then
|
||||||
|
findings+=("$path is non-hermetic but lacks a directly preceding comment")
|
||||||
|
elif [[ -z "$previous_comment" ]]; then
|
||||||
|
findings+=("$path is non-hermetic but lacks a non-empty directly preceding comment")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
previous_comment=""
|
||||||
|
previous_was_comment=0
|
||||||
|
done < "$manifest"
|
||||||
|
|
||||||
|
for discovered in "${!expected_contracts[@]}"; do
|
||||||
|
[[ -n "${manifest_paths[$discovered]:-}" ]] \
|
||||||
|
|| findings+=("missing contract $discovered")
|
||||||
|
done
|
||||||
|
for path in "${!manifest_paths[@]}"; do
|
||||||
|
[[ -n "${expected_contracts[$path]:-}" ]] \
|
||||||
|
|| findings+=("stale manifest path $path")
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( ${#findings[@]} > 0 )); then
|
||||||
|
err "Contract manifest validation failed with ${#findings[@]} finding(s):"
|
||||||
|
printf ' - %s\n' "${findings[@]}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_usage() {
|
||||||
|
err "Usage: ${BOLD}$PROGRAM test [--safe] [--allow <capability>] [pattern]${RESET}"
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
is_contract_capability() {
|
||||||
|
local capability="$1" known
|
||||||
|
for known in "${CONTRACT_CAPABILITIES[@]}"; do
|
||||||
|
[[ "$capability" == "$known" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Command: test
|
# Command: test
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# The contracts are the main safety net in this repository and had no entry
|
# The contracts are the main safety net in this repository. The manifest is the
|
||||||
# point: 121 executables with no runner and no mention in the README, which is
|
# single list of what the runner executes and which external boundaries each
|
||||||
# most of the way to not having them.
|
# contract reaches.
|
||||||
#
|
#
|
||||||
# Each runs in its own process and a failure does not stop the rest, because the
|
# 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
|
# 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.
|
# that broke. The exit code is what a caller can act on.
|
||||||
cmd_test() {
|
#
|
||||||
local pattern="${1:-}"
|
# --safe runs only contracts the manifest classifies as hermetic and reports
|
||||||
local -a suite=()
|
# each external capability it skipped. A plain terminal run asks before any
|
||||||
|
# selected non-hermetic work. Automation must grant every required capability
|
||||||
|
# with repeatable --allow flags. Non-hermetic contracts announce their exact
|
||||||
|
# capability list before execution. Each contract gets an outer timeout, 180
|
||||||
|
# seconds by default. PANAMA_TEST_TIMEOUT_SECONDS accepts a positive integer
|
||||||
|
# override. Failures include captured stdout and stderr. Successful stdout
|
||||||
|
# stays quiet, while successful stderr is surfaced as a warning.
|
||||||
|
PANAMA_ACTIVE_CONTRACT_PID=""
|
||||||
|
PANAMA_CONTRACT_CAPTURE_DIR=""
|
||||||
|
|
||||||
# Executables, plus the Python suites. Those are unittest files rather than
|
cleanup_contract_capture() {
|
||||||
# executables, and collecting only what has the executable bit would skip them
|
if [[ -n "$PANAMA_CONTRACT_CAPTURE_DIR" && -d "$PANAMA_CONTRACT_CAPTURE_DIR" ]]; then
|
||||||
# without saying so -- which is how all three came to be run by nothing at all.
|
rm -rf -- "$PANAMA_CONTRACT_CAPTURE_DIR" || true
|
||||||
# A runner with a blind spot is worse than no runner, because it reports PASS.
|
fi
|
||||||
while IFS= read -r path; do
|
PANAMA_CONTRACT_CAPTURE_DIR=""
|
||||||
[[ -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
|
terminate_active_contract() {
|
||||||
err "No contracts match '${pattern}'"
|
local pid="$PANAMA_ACTIVE_CONTRACT_PID"
|
||||||
exit 1
|
PANAMA_ACTIVE_CONTRACT_PID=""
|
||||||
|
[[ "$pid" =~ ^[1-9][0-9]*$ && "$pid" != "$$" ]] || return 0
|
||||||
|
|
||||||
|
# GNU timeout owns a process group whose ID is its PID. Signal that complete
|
||||||
|
# group so a contract cannot leave descendants behind, with a direct-PID
|
||||||
|
# fallback for implementations that do not create the group.
|
||||||
|
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_contract_signal() {
|
||||||
|
local signal_status="$1"
|
||||||
|
trap - INT TERM
|
||||||
|
terminate_active_contract
|
||||||
|
cleanup_contract_capture
|
||||||
|
trap - EXIT
|
||||||
|
exit "$signal_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_contract_capture() {
|
||||||
|
local capture_dir=""
|
||||||
|
if ! capture_dir="$(mktemp -d)"; then
|
||||||
|
err 'Could not create contract capture directory.'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$capture_dir" || ! -d "$capture_dir" ]]; then
|
||||||
|
err 'Could not create contract capture directory.'
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
PANAMA_CONTRACT_CAPTURE_DIR="$capture_dir"
|
||||||
|
trap cleanup_contract_capture EXIT
|
||||||
|
trap 'handle_contract_signal 130' INT
|
||||||
|
trap 'handle_contract_signal 143' TERM
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_test() {
|
||||||
|
local timeout_seconds="${PANAMA_TEST_TIMEOUT_SECONDS:-180}"
|
||||||
|
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || {
|
||||||
|
err 'PANAMA_TEST_TIMEOUT_SECONDS must be a positive integer.'
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_contract_manifest || return 1
|
||||||
|
cmd_test_impl "$timeout_seconds" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_test_impl() {
|
||||||
|
local timeout_seconds="$1"
|
||||||
|
shift
|
||||||
|
local pattern="" safe=0 arg capability capabilities rel path
|
||||||
|
local -A grants=() manifest_capabilities=() skipped_counts=() missing_grants=() selected_capabilities=()
|
||||||
|
local -a suite=() missing_capability_list=() selected_capability_list=() capability_list=()
|
||||||
|
|
||||||
|
# Position-independent: flags can precede or follow the optional pattern.
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
arg="$1"
|
||||||
|
shift
|
||||||
|
case "$arg" in
|
||||||
|
--safe) safe=1 ;;
|
||||||
|
--allow)
|
||||||
|
(( $# > 0 )) || { test_usage; return 2; }
|
||||||
|
capability="$1"
|
||||||
|
shift
|
||||||
|
is_contract_capability "$capability" || {
|
||||||
|
err "Unknown contract capability: $capability"
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
[[ "$capability" != hermetic ]] || {
|
||||||
|
err 'hermetic contracts do not need --allow.'
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
grants["$capability"]=1
|
||||||
|
;;
|
||||||
|
--*) test_usage; return 2 ;;
|
||||||
|
*)
|
||||||
|
[[ -z "$pattern" ]] || { test_usage; return 2; }
|
||||||
|
pattern="$arg"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
(( safe == 0 || ${#grants[@]} == 0 )) || {
|
||||||
|
err '--safe cannot be combined with --allow.'
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r rel capabilities; do
|
||||||
|
manifest_capabilities["$rel"]="$capabilities"
|
||||||
|
[[ -z "$pattern" || "$rel" == *"$pattern"* ]] || continue
|
||||||
|
if (( safe )) && [[ "$capabilities" != hermetic ]]; then
|
||||||
|
IFS=',' read -r -a capability_list <<<"$capabilities"
|
||||||
|
for capability in "${capability_list[@]}"; do
|
||||||
|
(( ++skipped_counts["$capability"] ))
|
||||||
|
done
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
suite+=("$rel")
|
||||||
|
done < <(contract_manifest_entries)
|
||||||
|
|
||||||
|
if (( ${#suite[@]} == 0 )); then
|
||||||
|
if (( safe )) && (( ${#skipped_counts[@]} > 0 )); then
|
||||||
|
err "Every contract matching '${pattern}' needs an external capability; --safe skipped all of them."
|
||||||
|
else
|
||||||
|
err "No contracts match '${pattern}'"
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( safe )); then
|
||||||
|
for capability in "${CONTRACT_CAPABILITIES[@]}"; do
|
||||||
|
[[ "$capability" == hermetic ]] && continue
|
||||||
|
printf 'Skipped %d %s contract(s).\n' "${skipped_counts[$capability]:-0}" "$capability"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
for rel in "${suite[@]}"; do
|
||||||
|
capabilities="${manifest_capabilities[$rel]}"
|
||||||
|
[[ "$capabilities" == hermetic ]] && continue
|
||||||
|
IFS=',' read -r -a capability_list <<<"$capabilities"
|
||||||
|
for capability in "${capability_list[@]}"; do
|
||||||
|
selected_capabilities["$capability"]=1
|
||||||
|
[[ -n "${grants[$capability]:-}" ]] || missing_grants["$capability"]=1
|
||||||
|
done
|
||||||
|
done
|
||||||
|
for capability in "${CONTRACT_CAPABILITIES[@]}"; do
|
||||||
|
[[ "$capability" == hermetic ]] && continue
|
||||||
|
[[ -n "${selected_capabilities[$capability]:-}" ]] && selected_capability_list+=("$capability")
|
||||||
|
[[ -n "${missing_grants[$capability]:-}" ]] && missing_capability_list+=("$capability")
|
||||||
|
done
|
||||||
|
if (( ${#missing_capability_list[@]} > 0 )); then
|
||||||
|
if [[ -t 0 && -t 2 ]]; then
|
||||||
|
confirm "Run ${#suite[@]} contract(s) requiring: ${selected_capability_list[*]}?" || {
|
||||||
|
warn 'No contracts were run.'
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
err "Selected contracts require: ${missing_capability_list[*]}."
|
||||||
|
for capability in "${missing_capability_list[@]}"; do
|
||||||
|
printf ' Automation: pass --allow %s\n' "$capability" >&2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local capture_dir stdout_file stderr_file name run_status index=0 final_status=0
|
||||||
|
local -a failed=() runner=()
|
||||||
|
prepare_contract_capture || return 1
|
||||||
|
capture_dir="$PANAMA_CONTRACT_CAPTURE_DIR"
|
||||||
|
|
||||||
info "Running ${#suite[@]} contract(s)"
|
info "Running ${#suite[@]} contract(s)"
|
||||||
local -a failed=()
|
for index in "${!suite[@]}"; do
|
||||||
local path name
|
rel="${suite[$index]}"
|
||||||
local -a runner
|
path="$PANAMA_DIR/$rel"
|
||||||
for path in "${suite[@]}"; do
|
name="${rel#tests/}"
|
||||||
name="${path#"$PANAMA_DIR"/tests/}"
|
stdout_file="$capture_dir/$index.stdout"
|
||||||
|
stderr_file="$capture_dir/$index.stderr"
|
||||||
if [[ "$path" == *_test.py ]]; then
|
if [[ "$path" == *_test.py ]]; then
|
||||||
runner=(python3 "$path")
|
runner=(python3 "$path")
|
||||||
else
|
else
|
||||||
runner=("$path")
|
runner=("$path")
|
||||||
fi
|
fi
|
||||||
if "${runner[@]}" >/dev/null 2>&1; then
|
capabilities="${manifest_capabilities[$rel]}"
|
||||||
ok "$name"
|
if [[ "$capabilities" != hermetic ]]; then
|
||||||
else
|
info "Running $name [$capabilities]"
|
||||||
err "$name"
|
|
||||||
failed+=("$name")
|
|
||||||
fi
|
fi
|
||||||
|
run_status=0
|
||||||
|
timeout --signal=TERM --kill-after=5 "$timeout_seconds" \
|
||||||
|
"${runner[@]}" >"$stdout_file" 2>"$stderr_file" &
|
||||||
|
PANAMA_ACTIVE_CONTRACT_PID=$!
|
||||||
|
wait "$PANAMA_ACTIVE_CONTRACT_PID" || run_status=$?
|
||||||
|
PANAMA_ACTIVE_CONTRACT_PID=""
|
||||||
|
if (( run_status == 0 )); then
|
||||||
|
ok "$name"
|
||||||
|
if [[ -s "$stderr_file" ]]; then
|
||||||
|
warn "$name wrote to stderr:"
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( run_status == 124 || run_status == 137 )); then
|
||||||
|
err "$name timed out after ${timeout_seconds}s"
|
||||||
|
else
|
||||||
|
err "$name failed (exit $run_status)"
|
||||||
|
fi
|
||||||
|
[[ -s "$stdout_file" ]] && {
|
||||||
|
printf '%s stdout:\n' "$name" >&2
|
||||||
|
cat "$stdout_file" >&2
|
||||||
|
}
|
||||||
|
[[ -s "$stderr_file" ]] && {
|
||||||
|
printf '%s stderr:\n' "$name" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
}
|
||||||
|
failed+=("$name")
|
||||||
done
|
done
|
||||||
|
|
||||||
header "Result"
|
header "Result"
|
||||||
if (( ${#failed[@]} == 0 )); then
|
if (( ${#failed[@]} == 0 )); then
|
||||||
ok "${#suite[@]} contract(s) passed"
|
ok "${#suite[@]} contract(s) passed"
|
||||||
return 0
|
else
|
||||||
fi
|
|
||||||
err "${#failed[@]} of ${#suite[@]} failed:"
|
err "${#failed[@]} of ${#suite[@]} failed:"
|
||||||
printf ' %s\n' "${failed[@]}" >&2
|
printf ' %s\n' "${failed[@]}" >&2
|
||||||
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
|
final_status=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup_contract_capture
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
return "$final_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: contracts
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# "I changed this file -- what should I run?" The suite is large enough that
|
||||||
|
# running all of it or guessing from contract names are both poor answers.
|
||||||
|
#
|
||||||
|
# This is a grep, and says so. A contract that names the file, or a
|
||||||
|
# parent-trimmed suffix of it, or just its basename, is a contract worth
|
||||||
|
# running; one that reaches the file through a harness or a generated artifact
|
||||||
|
# is not found, which is why the empty answer says "coverage may be indirect"
|
||||||
|
# rather than "nothing covers this". Naming a file the suite does not mention is
|
||||||
|
# a real answer -- exit 1 so a script can tell the difference -- but it is a
|
||||||
|
# statement about this search, not about the file.
|
||||||
|
#
|
||||||
|
# Each hit is labeled from tests/contracts.manifest, so the output also answers
|
||||||
|
# which boundary the matching contract reaches.
|
||||||
|
cmd_contracts() {
|
||||||
|
local target="${1:-}"
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
err "Which file? Usage: ${BOLD}$PROGRAM contracts <file>${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Absolute, relative to where you are standing, or repo-relative -- all three
|
||||||
|
# are how somebody refers to a file in this tree, and readlink resolves the
|
||||||
|
# symlinked dotfile in ~/.config back into the checkout it points at.
|
||||||
|
local absolute=""
|
||||||
|
if [[ -e "$target" ]]; then
|
||||||
|
absolute="$(readlink -f "$target")"
|
||||||
|
elif [[ -e "$PANAMA_DIR/$target" ]]; then
|
||||||
|
absolute="$(readlink -f "$PANAMA_DIR/$target")"
|
||||||
|
else
|
||||||
|
err "No such file: '$target'"
|
||||||
|
printf 'Give a path, absolute or relative to here or to %s.\n' "$PANAMA_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local path
|
||||||
|
case "$absolute" in
|
||||||
|
"$PANAMA_DIR"/*) path="${absolute#"$PANAMA_DIR"/}" ;;
|
||||||
|
*)
|
||||||
|
err "'$target' is outside the Panama repo (${PANAMA_DIR})."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The repo-relative path, then each parent trimmed off in turn, ending at the
|
||||||
|
# basename. Contracts refer to their subject every one of these ways: by the
|
||||||
|
# full path from the repo root, by the path from the shell directory, and by
|
||||||
|
# name alone.
|
||||||
|
local -a patterns=()
|
||||||
|
local suffix="$path"
|
||||||
|
while :; do
|
||||||
|
patterns+=(-e "$suffix")
|
||||||
|
[[ "$suffix" == */* ]] || break
|
||||||
|
suffix="${suffix#*/}"
|
||||||
|
done
|
||||||
|
|
||||||
|
validate_contract_manifest || return 1
|
||||||
|
local -A manifest_capabilities=()
|
||||||
|
local capabilities
|
||||||
|
while IFS=$'\t' read -r rel capabilities; do
|
||||||
|
manifest_capabilities["$rel"]="$capabilities"
|
||||||
|
done < <(contract_manifest_entries)
|
||||||
|
|
||||||
|
# The same collection `test` runs, so anything named here is something the
|
||||||
|
# runner would actually execute.
|
||||||
|
local -a hits=()
|
||||||
|
local candidate rel
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
candidate="$PANAMA_DIR/$rel"
|
||||||
|
grep -qF "${patterns[@]}" "$candidate" 2>/dev/null || continue
|
||||||
|
hits+=("$rel")
|
||||||
|
done < <(contract_paths)
|
||||||
|
|
||||||
|
if (( ${#hits[@]} == 0 )); then
|
||||||
|
printf 'No contract mentions %s — coverage may be indirect (a harness or a generated artifact); nothing verified.\n' "$path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for rel in "${hits[@]}"; do
|
||||||
|
[[ -n "${manifest_capabilities[$rel]:-}" ]] || {
|
||||||
|
err "Contract has no manifest capability label: $rel"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
printf '%s [%s]\n' "$rel" "${manifest_capabilities[$rel]}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Command: upgrade
|
# Command: upgrade
|
||||||
@@ -539,13 +1145,17 @@ main() {
|
|||||||
local cmd="${1:-}"
|
local cmd="${1:-}"
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
update) shift; cmd_update "$@" ;;
|
update) shift; cmd_update "$@" ;;
|
||||||
|
sync) shift; cmd_sync "$@" ;;
|
||||||
edit) shift; cmd_edit "$@" ;;
|
edit) shift; cmd_edit "$@" ;;
|
||||||
doctor) shift; cmd_doctor "$@" ;;
|
doctor) shift; cmd_doctor "$@" ;;
|
||||||
|
diagnose) shift; cmd_diagnose "$@" ;;
|
||||||
test) shift; cmd_test "$@" ;;
|
test) shift; cmd_test "$@" ;;
|
||||||
|
contracts) shift; cmd_contracts "$@" ;;
|
||||||
upgrade) shift; cmd_upgrade "$@" ;;
|
upgrade) shift; cmd_upgrade "$@" ;;
|
||||||
migrate) shift; cmd_migrate "$@" ;;
|
migrate) shift; cmd_migrate "$@" ;;
|
||||||
app) shift; cmd_app "$@" ;;
|
app) shift; cmd_app "$@" ;;
|
||||||
apps) shift; cmd_apps "$@" ;;
|
apps) shift; cmd_apps "$@" ;;
|
||||||
|
server) shift; exec "$PANAMA_DIR/bin/panama-server" "$@" ;;
|
||||||
help|-h|--help|"") usage ;;
|
help|-h|--help|"") usage ;;
|
||||||
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
|
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
Executable
+195
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Hand a prompt to whichever coding agent this machine has chosen.
|
||||||
|
#
|
||||||
|
# Every rung of the escalation ladder ends here: a crash toast, a failed shell
|
||||||
|
# reload, a red health check, `panama diagnose`. They gather facts; this decides
|
||||||
|
# which binary runs them and puts it in a terminal you can watch and interrupt.
|
||||||
|
#
|
||||||
|
# Two settings decide everything, and both are read at press time rather than at
|
||||||
|
# start time, so choosing an agent in Settings takes effect on the next crash
|
||||||
|
# without restarting anything:
|
||||||
|
#
|
||||||
|
# preferredAgent none | claude | codex ("none" is the default: silence)
|
||||||
|
# agentAutoApprove true -> the agent starts in its own "don't stop to ask"
|
||||||
|
# mode; false -> its normal prompting mode, untouched.
|
||||||
|
#
|
||||||
|
# "none" exits 0 without a word. It is not an error to have no agent; it is the
|
||||||
|
# shipped state, and a rung that shouted about it would be a rung that gets
|
||||||
|
# turned off.
|
||||||
|
#
|
||||||
|
# panama-agent open the agent on the repo
|
||||||
|
# panama-agent --prompt "text" open it with something to work on
|
||||||
|
#
|
||||||
|
# Environment seams, for the contract and for a second checkout:
|
||||||
|
#
|
||||||
|
# PANAMA_PATH the repository; also the agent's working directory
|
||||||
|
# PANAMA_AGENT_SETTINGS the settings file to read (default: the real one)
|
||||||
|
#
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adapted from Omarchy's bin/omarchy-agent (https://github.com/basecamp/omarchy)
|
||||||
|
#
|
||||||
|
# Copyright (c) David Heinemeier Hansson
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in all
|
||||||
|
# copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
# SOFTWARE.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||||
|
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
|
||||||
|
|
||||||
|
# A fixed window class rather than the per-binary default, so one Hyprland rule
|
||||||
|
# can catch every agent window regardless of which agent is chosen.
|
||||||
|
readonly WINDOW_CLASS="panama-agent"
|
||||||
|
|
||||||
|
# THE INHERITED PATH IS NOT THE USER'S PATH. Every rung except `panama diagnose`
|
||||||
|
# reaches this script from the Quickshell shell, which systemd starts with
|
||||||
|
# neither PANAMA_PATH nor ~/.local/bin -- and ~/.local/bin is where both agents
|
||||||
|
# install themselves. Trusting PATH here meant a perfectly well installed agent
|
||||||
|
# reporting itself as missing, into the stderr of a detached process nobody will
|
||||||
|
# ever read: the whole ladder failing silently, which is the exact failure it
|
||||||
|
# exists to prevent.
|
||||||
|
#
|
||||||
|
# So the binary is resolved rather than named. PATH first, because a user who
|
||||||
|
# put an agent somewhere else meant it; then the XDG user bin directory, which
|
||||||
|
# is where the installers actually put them.
|
||||||
|
resolve_agent() {
|
||||||
|
local name="$1" found
|
||||||
|
found="$(command -v "$name" 2>/dev/null)" && { printf '%s' "$found"; return 0; }
|
||||||
|
[[ -x "$HOME/.local/bin/$name" ]] && { printf '%s' "$HOME/.local/bin/$name"; return 0; }
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The same repair, for the agent's own sake rather than this script's: an agent
|
||||||
|
# launched from a notification click would otherwise run every shell command it
|
||||||
|
# is asked to with a PATH unlike the one the user gets in a terminal. Applied
|
||||||
|
# just before the spawn rather than here, so resolve_agent above is answering
|
||||||
|
# the question the caller actually asked -- "can this be found from where I was
|
||||||
|
# started" -- instead of one this script has already fixed for itself.
|
||||||
|
repair_path() {
|
||||||
|
case ":$PATH:" in
|
||||||
|
*":$HOME/.local/bin:"*) ;;
|
||||||
|
*) PATH="$PATH:$HOME/.local/bin" ;;
|
||||||
|
esac
|
||||||
|
export PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: panama-agent [--prompt "text"]
|
||||||
|
|
||||||
|
Opens the agent named by preferredAgent in a terminal, in the Panama checkout.
|
||||||
|
With no agent chosen, exits silently: choose one on Settings > System > Agents.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt=""
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--prompt)
|
||||||
|
prompt="${2:?--prompt needs a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'panama-agent: unexpected argument: %s\n' "$1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Same shape as panama-idle's reader: a missing file, a missing key and an
|
||||||
|
# explicit null all mean "the default", because all three describe a machine
|
||||||
|
# that has never been asked the question.
|
||||||
|
read_setting() {
|
||||||
|
local key="$1" fallback="$2"
|
||||||
|
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
|
||||||
|
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
|
||||||
|
jq -r --arg k "$key" --arg d "$fallback" \
|
||||||
|
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
|
||||||
|
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
agent="$(read_setting preferredAgent none)"
|
||||||
|
|
||||||
|
# The shipped state. Nothing to launch, nothing to say.
|
||||||
|
[[ -n "$agent" && "$agent" != "none" ]] || exit 0
|
||||||
|
|
||||||
|
auto_approve="$(read_setting agentAutoApprove true)"
|
||||||
|
|
||||||
|
case "$agent" in
|
||||||
|
claude | codex) ;;
|
||||||
|
*)
|
||||||
|
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
|
||||||
|
printf 'Choose one on Settings > System > Agents.\n' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Resolved to a path before argv is built, so kitty is never asked to repeat a
|
||||||
|
# PATH lookup this script has already done more carefully than kitty could.
|
||||||
|
if ! agent_bin="$(resolve_agent "$agent")"; then
|
||||||
|
printf 'panama-agent: %s is not installed.\n' "$agent" >&2
|
||||||
|
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2
|
||||||
|
printf 'Install it, or choose another agent on Settings > System > Agents.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The per-agent launch table. VERIFIED against the installed binaries' --help on
|
||||||
|
# 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same
|
||||||
|
# from release to release, so re-run --help before changing them.
|
||||||
|
#
|
||||||
|
# claude --permission-mode auto "auto" is one of acceptEdits/auto/
|
||||||
|
# bypassPermissions/manual/dontAsk/plan
|
||||||
|
# codex --approve-for-me routes approvals through automatic review
|
||||||
|
# inside the workspace-write sandbox
|
||||||
|
#
|
||||||
|
# With agentAutoApprove off, no mode flag is passed at all: the agent's own
|
||||||
|
# configured default is a choice the user already made, and overriding it with
|
||||||
|
# an explicit "prompt me" would be this script having an opinion it was told not
|
||||||
|
# to have.
|
||||||
|
declare -a argv=("$agent_bin")
|
||||||
|
case "$agent" in
|
||||||
|
claude) [[ "$auto_approve" == "true" ]] && argv+=(--permission-mode auto) ;;
|
||||||
|
codex) [[ "$auto_approve" == "true" ]] && argv+=(--approve-for-me) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# One argv element, after the option terminator. Both CLIs take the prompt as a
|
||||||
|
# trailing positional, and `--` is what stops a prompt beginning with a dash --
|
||||||
|
# or one that happens to read like a subcommand -- from being parsed as flags.
|
||||||
|
[[ -n "$prompt" ]] && argv+=(-- "$prompt")
|
||||||
|
|
||||||
|
# The checkout, not $HOME: the skills the prompts point at, the repository the
|
||||||
|
# agent is being asked about, and .claude/settings.json's pre-approved read-only
|
||||||
|
# diagnostics all live here. An agent started anywhere else finds none of them.
|
||||||
|
cd "$PANAMA_PATH"
|
||||||
|
|
||||||
|
repair_path
|
||||||
|
|
||||||
|
# setsid so the agent outlives whatever spawned it -- a notification handler, a
|
||||||
|
# crash watcher, a terminal that is about to close.
|
||||||
|
exec setsid kitty \
|
||||||
|
--directory "$PANAMA_PATH" \
|
||||||
|
--class "$WINDOW_CLASS" \
|
||||||
|
-e "${argv[@]}"
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# "Something crashed" -> an agent already reading the core dump.
|
||||||
|
#
|
||||||
|
# Reached by clicking the crash notification panama-crash-watch sends, or run by
|
||||||
|
# hand against any PID in `coredumpctl list`. It gathers the four facts
|
||||||
|
# systemd-coredump recorded and points at the skill that says what to do with
|
||||||
|
# them; the method lives in the skill so it is edited in one place and works
|
||||||
|
# whichever agent is configured.
|
||||||
|
#
|
||||||
|
# panama-agent-crash <pid> [comm] [exe] [signal]
|
||||||
|
#
|
||||||
|
# The skill is named AND given as an absolute path. A harness with a skill
|
||||||
|
# mechanism follows the name; one without still has a file to read. That is the
|
||||||
|
# whole reason this ladder works for more than one agent.
|
||||||
|
#
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adapted from Omarchy's bin/omarchy-agent-crash
|
||||||
|
# (https://github.com/basecamp/omarchy)
|
||||||
|
#
|
||||||
|
# Copyright (c) David Heinemeier Hansson
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in all
|
||||||
|
# copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
# SOFTWARE.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||||
|
|
||||||
|
pid="${1:-}"
|
||||||
|
if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
|
||||||
|
printf 'Not a PID: %s\n' "${pid:-<missing>}" >&2
|
||||||
|
printf 'Usage: panama-agent-crash <pid> [comm] [exe] [signal] (see: coredumpctl list)\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
comm="${2:-unknown}"
|
||||||
|
exe="${3:-unknown}"
|
||||||
|
signal="${4:-unknown}"
|
||||||
|
|
||||||
|
skill="$PANAMA_PATH/skills/diagnose-crash/SKILL.md"
|
||||||
|
|
||||||
|
# Looked up live so a PID typed by hand still gets a timestamp. A core that has
|
||||||
|
# already been rotated away costs only the timestamp, so this is allowed to
|
||||||
|
# fail: the other four facts are enough to start on.
|
||||||
|
when="$(coredumpctl list "$pid" --no-pager --no-legend 2>/dev/null | tail -1 | cut -d' ' -f1-4)" || true
|
||||||
|
when="${when:-unknown}"
|
||||||
|
[[ -n "${when// }" ]] || when="unknown"
|
||||||
|
|
||||||
|
prompt="$(
|
||||||
|
cat <<PROMPT
|
||||||
|
A process crashed on this Panama machine and I want to know why.
|
||||||
|
|
||||||
|
What systemd-coredump recorded:
|
||||||
|
process: $comm
|
||||||
|
PID: $pid
|
||||||
|
binary: $exe
|
||||||
|
signal: $signal
|
||||||
|
time: $when
|
||||||
|
|
||||||
|
Use the diagnose-crash skill. It covers how to investigate, what to rule out
|
||||||
|
first, and what to report. If your harness has no skill mechanism, read the
|
||||||
|
skill file directly and follow it instead:
|
||||||
|
|
||||||
|
$skill
|
||||||
|
PROMPT
|
||||||
|
)"
|
||||||
|
|
||||||
|
exec "$PANAMA_PATH/bin/panama-agent" --prompt "$prompt"
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# "The shell would not reload" -> an agent already holding the error.
|
||||||
|
#
|
||||||
|
# Quickshell keeps the old shell running when a reload fails, which is what
|
||||||
|
# makes this rung possible at all: the desktop that just refused the new code is
|
||||||
|
# still there to notify you about it, and still there to click. shell.qml's
|
||||||
|
# onReloadFailed sends that notification; this builds the prompt behind it.
|
||||||
|
#
|
||||||
|
# panama-agent-reload "<what Quickshell said>"
|
||||||
|
#
|
||||||
|
# The failure string on its own is usually one line naming one file. The journal
|
||||||
|
# around it is where the rest is -- the QML warnings that preceded the fatal
|
||||||
|
# one, the property that was already undefined two saves ago -- so both go in.
|
||||||
|
#
|
||||||
|
# Environment seams, for the contract:
|
||||||
|
#
|
||||||
|
# PANAMA_PATH the repository
|
||||||
|
# PANAMA_RELOAD_UNIT the unit to read (default panama-quickshell.service)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||||
|
UNIT="${PANAMA_RELOAD_UNIT:-panama-quickshell.service}"
|
||||||
|
|
||||||
|
summary="${1:-}"
|
||||||
|
if [[ -z "${summary// }" ]]; then
|
||||||
|
printf 'Usage: panama-agent-reload "<the reload failure>"\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read a generous window and filter it down, rather than asking journalctl for
|
||||||
|
# forty lines and hoping they were the relevant ones. A failed reload usually
|
||||||
|
# arrives after a burst of unrelated shell chatter.
|
||||||
|
#
|
||||||
|
# Each line is truncated because the prompt leaves as one argv element and the
|
||||||
|
# kernel caps that at 128KB; a single Quickshell backtrace can be most of it.
|
||||||
|
context=""
|
||||||
|
if command -v journalctl >/dev/null 2>&1; then
|
||||||
|
context="$(journalctl --user -u "$UNIT" -n 400 --no-pager --output=cat 2>/dev/null \
|
||||||
|
| grep -iE 'quickshell|\.qml|qml:|panama' \
|
||||||
|
| tail -40 \
|
||||||
|
| cut -c 1-300)" || true
|
||||||
|
fi
|
||||||
|
[[ -n "${context// }" ]] || context="(nothing in the journal for $UNIT)"
|
||||||
|
|
||||||
|
prompt="$(
|
||||||
|
cat <<PROMPT
|
||||||
|
The Panama shell refused to reload on this machine. The old shell is still
|
||||||
|
running, so the desktop is up, but the change that was just saved is not live.
|
||||||
|
|
||||||
|
What Quickshell reported:
|
||||||
|
|
||||||
|
$summary
|
||||||
|
|
||||||
|
The last relevant lines from $UNIT:
|
||||||
|
|
||||||
|
$context
|
||||||
|
|
||||||
|
The shell lives in config/dot/quickshell in this repository, symlinked into
|
||||||
|
~/.config/quickshell -- so the file that failed to parse is a tracked file here,
|
||||||
|
not a copy. Find what broke the reload and say what it is. Read before you
|
||||||
|
write: a bad guess saved into this tree is live in the desktop immediately.
|
||||||
|
PROMPT
|
||||||
|
)"
|
||||||
|
|
||||||
|
exec "$PANAMA_PATH/bin/panama-agent" --prompt "$prompt"
|
||||||
+73
-3
@@ -16,10 +16,18 @@
|
|||||||
# few minutes for something the user can do nothing about. The first one is
|
# 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
|
# news; the fortieth is why people turn notifications off. The health page
|
||||||
# carries the running count for anyone who wants it.
|
# carries the running count for anyone who wants it.
|
||||||
|
#
|
||||||
|
# When an agent has been chosen, the notification stops being a dead end. It
|
||||||
|
# carries the diagnosis command as data in a `panama-exec` hint, which the shell
|
||||||
|
# runs on click. Command-as-data rather than a libnotify action, because an
|
||||||
|
# action would tie the click to this process still being alive to hear it, and
|
||||||
|
# this process is a `journalctl -f` that outlives nothing in particular. The
|
||||||
|
# hint survives a shell restart and never blocks the watcher.
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
|
||||||
|
|
||||||
# systemd-coredump's MESSAGE_ID. Matching on this rather than on text keeps
|
# 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
|
# working when the wording changes and never matches a program that merely
|
||||||
@@ -37,6 +45,27 @@ for _ in $(seq 1 60); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Read per crash rather than once at startup, so choosing an agent in Settings
|
||||||
|
# takes effect on the next crash instead of on the next login. This service runs
|
||||||
|
# for the life of the session; nothing restarts it when a preference changes.
|
||||||
|
read_setting() {
|
||||||
|
local key="$1" fallback="$2"
|
||||||
|
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
|
||||||
|
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
|
||||||
|
jq -r --arg k "$key" --arg d "$fallback" \
|
||||||
|
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
|
||||||
|
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# What to call the agent in a sentence aimed at a person.
|
||||||
|
agent_label() {
|
||||||
|
case "$1" in
|
||||||
|
claude) printf 'Claude Code' ;;
|
||||||
|
codex) printf 'Codex' ;;
|
||||||
|
*) printf '%s' "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
declare -A reported=()
|
declare -A reported=()
|
||||||
|
|
||||||
# -f from now, not from the boot: a session that starts after a crash should
|
# -f from now, not from the boot: a session that starts after a crash should
|
||||||
@@ -46,9 +75,15 @@ journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev
|
|||||||
| while IFS= read -r line; do
|
| while IFS= read -r line; do
|
||||||
[[ -n "$line" ]] || continue
|
[[ -n "$line" ]] || continue
|
||||||
|
|
||||||
uid="$(jq -r '.COREDUMP_UID // empty' <<<"$line" 2>/dev/null)"
|
# One jq per entry rather than one per field: the fields are read
|
||||||
exe="$(jq -r '.COREDUMP_EXE // empty' <<<"$line" 2>/dev/null)"
|
# together, and the click payload needs all of them.
|
||||||
comm="$(jq -r '.COREDUMP_COMM // empty' <<<"$line" 2>/dev/null)"
|
IFS=$'\t' read -r uid exe comm pid signal < <(
|
||||||
|
jq -r '[(.COREDUMP_UID // ""),
|
||||||
|
(.COREDUMP_EXE // ""),
|
||||||
|
(.COREDUMP_COMM // ""),
|
||||||
|
(.COREDUMP_PID // ""),
|
||||||
|
(.COREDUMP_SIGNAL_NAME // "")] | @tsv' <<<"$line" 2>/dev/null
|
||||||
|
)
|
||||||
|
|
||||||
# Another user's crash is not this session's business, and reporting it
|
# Another user's crash is not this session's business, and reporting it
|
||||||
# would leak what they are running.
|
# would leak what they are running.
|
||||||
@@ -63,11 +98,46 @@ journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev
|
|||||||
else
|
else
|
||||||
program="$comm"
|
program="$comm"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Never announce our own machinery. A crash watcher that notifies about
|
||||||
|
# the crash watcher, or about the agent it just launched to investigate
|
||||||
|
# the last crash, is a loop with a toast in it.
|
||||||
|
[[ "$program" == panama-crash-* || "$program" == panama-agent* ]] && continue
|
||||||
|
|
||||||
[[ -z "${reported[$program]:-}" ]] || continue
|
[[ -z "${reported[$program]:-}" ]] || continue
|
||||||
reported[$program]=1
|
reported[$program]=1
|
||||||
|
|
||||||
|
# The toast can only offer a diagnosis if there is something to diagnose
|
||||||
|
# with. No agent, or the offer switched off, and it stays exactly the
|
||||||
|
# actionless notification it has always been.
|
||||||
|
agent="$(read_setting preferredAgent none)"
|
||||||
|
offer="$(read_setting crashDiagnoseOffer true)"
|
||||||
|
|
||||||
|
if [[ -n "$agent" && "$agent" != "none" && "$offer" != "false" && "$pid" =~ ^[0-9]+$ ]]; then
|
||||||
|
# By absolute path, not by name. The shell runs this hint, and the
|
||||||
|
# shell is started by systemd -- whose environment does not carry
|
||||||
|
# the repository's bin directory on PATH, so a bare name would
|
||||||
|
# click into "command not found".
|
||||||
|
exec_command="$(printf '%q %q %q %q %q' \
|
||||||
|
"$PANAMA_PATH/bin/panama-agent-crash" \
|
||||||
|
"$pid" "${comm:-$program}" "${exe:-unknown}" "${signal:-unknown}")"
|
||||||
|
|
||||||
|
# The hint is data, not privilege. Any process on this session bus
|
||||||
|
# could send one, and running it grants nothing a local process
|
||||||
|
# could not already do for itself.
|
||||||
|
# Same urgency as the plain report, deliberately. Making the
|
||||||
|
# clickable one critical would let a crash break through Do Not
|
||||||
|
# Disturb, which is a louder desktop than anybody asked for in
|
||||||
|
# exchange for an offer that keeps until it is read anyway.
|
||||||
|
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
||||||
|
--hint="string:panama-exec:$exec_command" \
|
||||||
|
"$program stopped unexpectedly" \
|
||||||
|
"Click to diagnose with $(agent_label "$agent")." \
|
||||||
|
2>/dev/null || true
|
||||||
|
else
|
||||||
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
||||||
"$program stopped unexpectedly" \
|
"$program stopped unexpectedly" \
|
||||||
"It crashed and was not able to recover. System Health has the details." \
|
"It crashed and was not able to recover. System Health has the details." \
|
||||||
2>/dev/null || true
|
2>/dev/null || true
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
+31
-10
@@ -38,28 +38,37 @@ is_laptop() {
|
|||||||
[[ "$PORTABLE_CHASSIS" == *" $chassis "* ]]
|
[[ "$PORTABLE_CHASSIS" == *" $chassis "* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
# The first battery, or nothing. Named rather than assumed to be BAT0: the
|
# The first SYSTEM battery, or nothing. Named rather than assumed to be BAT0:
|
||||||
# second battery in a ThinkPad is BAT1, and a machine with only BAT1 exists.
|
# the second battery in a ThinkPad is BAT1, and a machine with only BAT1
|
||||||
|
# exists. The scope check is what keeps a desktop a desktop: a wireless mouse
|
||||||
|
# or a game controller publishes type=Battery with scope=Device, and counting
|
||||||
|
# one turned a tower into a "laptop" whose battery readout was the mouse's.
|
||||||
battery_path() {
|
battery_path() {
|
||||||
local supply type
|
local supply type scope
|
||||||
for supply in "$SYS"/class/power_supply/*; do
|
for supply in "$SYS"/class/power_supply/*; do
|
||||||
[[ -r "$supply/type" ]] || continue
|
[[ -r "$supply/type" ]] || continue
|
||||||
type="$(cat "$supply/type" 2>/dev/null)"
|
type="$(cat "$supply/type" 2>/dev/null)"
|
||||||
if [[ "$type" == "Battery" ]]; then
|
[[ "$type" == "Battery" ]] || continue
|
||||||
|
scope="$(cat "$supply/scope" 2>/dev/null || echo System)"
|
||||||
|
[[ "$scope" == "Device" ]] && continue
|
||||||
printf '%s\n' "$supply"
|
printf '%s\n' "$supply"
|
||||||
return 0
|
return 0
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
has_battery() { battery_path >/dev/null; }
|
has_battery() { battery_path >/dev/null; }
|
||||||
|
|
||||||
# On wall power. A machine with no mains supply at all is a desktop, and a
|
# On wall power. A machine with no mains supply at all and no system battery
|
||||||
# desktop is always on wall power -- answering "no" there would make every
|
# is a desktop, and a desktop is always on wall power -- answering "no" there
|
||||||
# battery-aware timing apply to a machine that cannot run out of power.
|
# would make every battery-aware timing apply to a machine that cannot run
|
||||||
|
# out of power. But "no Mains" alone is not "desktop": hardware charged only
|
||||||
|
# over USB-PD exposes type=USB supplies and no Mains at all, and reading that
|
||||||
|
# as permanently-on-AC meant its battery timings never engaged while it ran
|
||||||
|
# down. When no Mains exists but a system battery does, the battery's own
|
||||||
|
# status is the answer: Discharging means battery, everything else means fed.
|
||||||
on_ac() {
|
on_ac() {
|
||||||
local supply type online found=1
|
local supply type online found=1 battery status
|
||||||
for supply in "$SYS"/class/power_supply/*; do
|
for supply in "$SYS"/class/power_supply/*; do
|
||||||
[[ -r "$supply/type" ]] || continue
|
[[ -r "$supply/type" ]] || continue
|
||||||
type="$(cat "$supply/type" 2>/dev/null)"
|
type="$(cat "$supply/type" 2>/dev/null)"
|
||||||
@@ -70,16 +79,28 @@ on_ac() {
|
|||||||
done
|
done
|
||||||
# Mains exists and none of it is online: genuinely on battery.
|
# Mains exists and none of it is online: genuinely on battery.
|
||||||
(( found == 0 )) && return 1
|
(( found == 0 )) && return 1
|
||||||
|
if battery="$(battery_path)"; then
|
||||||
|
status="$(cat "$battery/status" 2>/dev/null || echo Unknown)"
|
||||||
|
[[ "$status" == "Discharging" ]] && return 1
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ACPI first, logind second. Some platforms expose the lid only as an evdev
|
||||||
|
# switch with no /proc/acpi/button entry; logind watches the switch either
|
||||||
|
# way, so its LidClosed property is the fallback that keeps clamshell
|
||||||
|
# detection honest there. No logind (a container, a test tree) means the
|
||||||
|
# fallback quietly answers open, which is the safe direction.
|
||||||
lid_closed() {
|
lid_closed() {
|
||||||
local state
|
local state
|
||||||
for state in "$ACPI"/button/lid/*/state; do
|
for state in "$ACPI"/button/lid/*/state; do
|
||||||
[[ -r "$state" ]] || continue
|
[[ -r "$state" ]] || continue
|
||||||
grep -qi closed "$state" && return 0
|
grep -qi closed "$state" && return 0
|
||||||
done
|
|
||||||
return 1
|
return 1
|
||||||
|
done
|
||||||
|
[[ -d "$ACPI/button/lid" ]] && return 1
|
||||||
|
busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
|
||||||
|
org.freedesktop.login1.Manager LidClosed 2>/dev/null | grep -q 'b true'
|
||||||
}
|
}
|
||||||
|
|
||||||
# A connected output that is not the built-in panel. eDP, LVDS and DSI are the
|
# A connected output that is not the built-in panel. eDP, LVDS and DSI are the
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ cmd_run() {
|
|||||||
|
|
||||||
if (( failed )); then
|
if (( failed )); then
|
||||||
warn "Re-running 'panama migrate' is safe and will retry from the failure."
|
warn "Re-running 'panama migrate' is safe and will retry from the failure."
|
||||||
|
warn "If it keeps failing, hand it to an agent: panama diagnose"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
ok "This machine now matches the checkout."
|
ok "This machine now matches the checkout."
|
||||||
|
|||||||
Executable
+323
@@ -0,0 +1,323 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# panama-server – the compose services a server machine runs.
|
||||||
|
#
|
||||||
|
# The repository carries the catalog: server/containers/<Name>/ holds a
|
||||||
|
# compose.yml, a podman-<name>.service user unit, and an .env.example naming
|
||||||
|
# what the service needs told. The machine carries the rest: ~/Server/<Name>/
|
||||||
|
# is a real directory owning the .env (secrets never sit inside the checkout,
|
||||||
|
# gitignored or not) and the bind-mounted data. Only the tracked files are
|
||||||
|
# symlinked, so a `git clean` in the repo can never reach a database.
|
||||||
|
#
|
||||||
|
# list every service in the catalog, with its state here
|
||||||
|
# enable <Name> link it into ~/Server, seed .env, enable the unit
|
||||||
|
# disable <Name> stop it and remove the unit; data and .env stay put
|
||||||
|
# status [<Name>] what is actually running
|
||||||
|
# relink refresh the symlinks for everything enabled here
|
||||||
|
#
|
||||||
|
# Verbs act on the unit and the links, never on data: there is deliberately no
|
||||||
|
# verb here that deletes ~/Server/<Name> or anything in it.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PROGRAM="panama server"
|
||||||
|
|
||||||
|
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
|
||||||
|
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
|
||||||
|
|
||||||
|
# Overridable so the contract can point this at a fixture catalog and a
|
||||||
|
# throwaway HOME rather than the machine's real services.
|
||||||
|
SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/containers}"
|
||||||
|
TARGET_DIR="${PANAMA_SERVER_TARGET:-$HOME/Server}"
|
||||||
|
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||||
|
STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/server-definitions"
|
||||||
|
|
||||||
|
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
|
||||||
|
BOLD=$(tput bold); RESET=$(tput sgr0)
|
||||||
|
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); BLUE=$(tput setaf 4)
|
||||||
|
else
|
||||||
|
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
|
||||||
|
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
|
||||||
|
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
|
||||||
|
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
${BOLD}$PROGRAM${RESET} – manage the compose services in server/containers/
|
||||||
|
|
||||||
|
${BOLD}Usage:${RESET}
|
||||||
|
$PROGRAM list Every service in the catalog, and its state here
|
||||||
|
$PROGRAM enable <Name> Link a service into ~/Server, seed its .env from
|
||||||
|
.env.example, and enable its unit. Stops short of
|
||||||
|
starting when the .env still holds CHANGE_ME
|
||||||
|
placeholders, and says which.
|
||||||
|
$PROGRAM disable <Name> Stop the unit and unlink it. ~/Server/<Name>,
|
||||||
|
its .env and its data are never touched.
|
||||||
|
$PROGRAM status [<Name>] What is actually running, per service
|
||||||
|
$PROGRAM relink Refresh every enabled service's symlinks after a
|
||||||
|
pull, and name the ones whose definitions changed
|
||||||
|
(nothing is restarted for you)
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── The catalog ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
services() {
|
||||||
|
local dir
|
||||||
|
for dir in "$SERVER_DIR"/*/; do
|
||||||
|
[[ -d "$dir" ]] || continue
|
||||||
|
basename "$dir"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
require_service() {
|
||||||
|
local name="$1"
|
||||||
|
if [[ ! -d "$SERVER_DIR/$name" ]]; then
|
||||||
|
err "No such service: '$name'"
|
||||||
|
printf 'The catalog has:\n' >&2
|
||||||
|
services | sed 's/^/ /' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The one unit file a service directory carries. Its filename is its identity
|
||||||
|
# -- podman-<name>.service, exactly what lands in the systemd user directory --
|
||||||
|
# so nothing here invents a name that could drift from the file's.
|
||||||
|
unit_path_for() {
|
||||||
|
local name="$1" unit
|
||||||
|
for unit in "$SERVER_DIR/$name"/*.service; do
|
||||||
|
[[ -e "$unit" ]] || continue
|
||||||
|
printf '%s' "$unit"
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
unit_name_for() {
|
||||||
|
local unit
|
||||||
|
unit="$(unit_path_for "$1")" || return 1
|
||||||
|
basename "$unit"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installed means our symlink is in the systemd user directory. A regular file
|
||||||
|
# of the same name is a pre-Panama install done by hand -- reported, never
|
||||||
|
# silently replaced.
|
||||||
|
unit_installed() {
|
||||||
|
local unit_name
|
||||||
|
unit_name="$(unit_name_for "$1")" || return 1
|
||||||
|
[[ -L "$UNIT_DIR/$unit_name" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
definition_hash() {
|
||||||
|
local name="$1" unit
|
||||||
|
unit="$(unit_path_for "$name")" || unit=/dev/null
|
||||||
|
cat "$SERVER_DIR/$name/compose.yml" "$unit" 2>/dev/null | sha256sum | cut -d' ' -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
recorded_hash() {
|
||||||
|
[[ -r "$STATE_FILE" ]] || return 0
|
||||||
|
awk -v name="$1" '$1 == name {print $2}' "$STATE_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_hash() {
|
||||||
|
local name="$1" hash="$2"
|
||||||
|
mkdir -p "$(dirname "$STATE_FILE")"
|
||||||
|
{ [[ -r "$STATE_FILE" ]] && awk -v name="$name" '$1 != name' "$STATE_FILE"; \
|
||||||
|
printf '%s %s\n' "$name" "$hash"; } >"$STATE_FILE.tmp"
|
||||||
|
mv "$STATE_FILE.tmp" "$STATE_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Replace path with a symlink to src. An existing regular file is moved aside
|
||||||
|
# with its name intact plus a suffix, because the only regular file that can be
|
||||||
|
# here is a pre-Panama one somebody wrote by hand -- worth keeping to diff.
|
||||||
|
link_file() {
|
||||||
|
local src="$1" path="$2"
|
||||||
|
if [[ -L "$path" ]]; then
|
||||||
|
[[ "$(readlink -f "$path")" == "$(readlink -f "$src")" ]] && return 0
|
||||||
|
rm "$path"
|
||||||
|
elif [[ -e "$path" ]]; then
|
||||||
|
mv "$path" "$path.pre-panama"
|
||||||
|
warn "Kept the existing $(basename "$path") as $(basename "$path").pre-panama"
|
||||||
|
fi
|
||||||
|
ln -s "$src" "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Verbs ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local name unit_name state enabled
|
||||||
|
for name in $(services); do
|
||||||
|
unit_name="$(unit_name_for "$name")" || { printf '%-24s %s\n' "$name" "no unit file"; continue; }
|
||||||
|
if unit_installed "$name"; then
|
||||||
|
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
|
||||||
|
enabled="enabled"
|
||||||
|
[[ "$state" == active ]] && state="${GREEN}active${RESET}" || state="${YELLOW}${state:-unknown}${RESET}"
|
||||||
|
printf '%-24s %s, %b\n' "$name" "$enabled" "$state"
|
||||||
|
elif [[ -e "$UNIT_DIR/$unit_name" ]]; then
|
||||||
|
printf '%-24s %s\n' "$name" "installed by hand (not Panama's symlink)"
|
||||||
|
else
|
||||||
|
printf '%-24s %s\n' "$name" "-"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_enable() {
|
||||||
|
local name="${1:-}"
|
||||||
|
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM enable <Name>"; exit 1; }
|
||||||
|
require_service "$name"
|
||||||
|
|
||||||
|
local repo_dir="$SERVER_DIR/$name" live_dir="$TARGET_DIR/$name"
|
||||||
|
[[ -f "$repo_dir/compose.yml" ]] || { err "$name has no compose.yml in the catalog"; exit 1; }
|
||||||
|
local unit_path unit_name
|
||||||
|
unit_path="$(unit_path_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
|
||||||
|
unit_name="$(basename "$unit_path")"
|
||||||
|
|
||||||
|
mkdir -p "$live_dir"
|
||||||
|
link_file "$repo_dir/compose.yml" "$live_dir/compose.yml"
|
||||||
|
ok "Linked compose.yml → $live_dir/compose.yml"
|
||||||
|
|
||||||
|
# The .env lives with the machine, not the checkout. Seeded once from the
|
||||||
|
# example and never overwritten -- it is where the person's secrets go.
|
||||||
|
if [[ -f "$repo_dir/.env.example" && ! -e "$live_dir/.env" ]]; then
|
||||||
|
cp "$repo_dir/.env.example" "$live_dir/.env"
|
||||||
|
chmod 600 "$live_dir/.env"
|
||||||
|
ok "Seeded $live_dir/.env from .env.example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Refusing to start on placeholders, and saying which: a service brought up
|
||||||
|
# with CHANGE_ME as its database password does not fail loudly, it runs --
|
||||||
|
# and what it runs is a service whose password is CHANGE_ME.
|
||||||
|
if [[ -e "$live_dir/.env" ]] && grep -q 'CHANGE_ME' "$live_dir/.env"; then
|
||||||
|
warn "$live_dir/.env still has placeholder values:"
|
||||||
|
grep -n 'CHANGE_ME' "$live_dir/.env" | sed 's/^/ /'
|
||||||
|
printf 'Fill them in, then run: %s enable %s\n' "$PROGRAM" "$name"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
link_file "$unit_path" "$UNIT_DIR/$unit_name"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
if systemctl --user enable --now "$unit_name"; then
|
||||||
|
record_hash "$name" "$(definition_hash "$name")"
|
||||||
|
ok "$name enabled and started ($unit_name)"
|
||||||
|
else
|
||||||
|
err "$unit_name did not start; see: systemctl --user status $unit_name"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_disable() {
|
||||||
|
local name="${1:-}"
|
||||||
|
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM disable <Name>"; exit 1; }
|
||||||
|
require_service "$name"
|
||||||
|
local unit_name
|
||||||
|
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
|
||||||
|
|
||||||
|
systemctl --user disable --now "$unit_name" 2>/dev/null || true
|
||||||
|
if [[ -L "$UNIT_DIR/$unit_name" ]]; then
|
||||||
|
rm "$UNIT_DIR/$unit_name"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
fi
|
||||||
|
ok "$name disabled. ~/Server/$name, its .env and its data were not touched."
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
local name="${1:-}"
|
||||||
|
if [[ -n "$name" ]]; then
|
||||||
|
require_service "$name"
|
||||||
|
local unit_name
|
||||||
|
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file"; exit 1; }
|
||||||
|
systemctl --user status --no-pager "$unit_name" || true
|
||||||
|
if [[ -d "$TARGET_DIR/$name" ]]; then
|
||||||
|
( cd "$TARGET_DIR/$name" && podman compose ps 2>/dev/null ) || true
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The role's health summary: what is enabled here, and is it actually up.
|
||||||
|
local any=0 unit_name state
|
||||||
|
for name in $(services); do
|
||||||
|
unit_installed "$name" || continue
|
||||||
|
any=1
|
||||||
|
unit_name="$(unit_name_for "$name")"
|
||||||
|
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
|
||||||
|
if [[ "$state" == active ]]; then
|
||||||
|
ok "$name"
|
||||||
|
else
|
||||||
|
err "$name is ${state:-unknown}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
(( any )) || info "No services enabled on this machine yet. See: $PROGRAM list"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_relink() {
|
||||||
|
local name unit_path unit_name changed=() unmanaged=() refreshed=0
|
||||||
|
for name in $(services); do
|
||||||
|
unit_path="$(unit_path_for "$name")" || continue
|
||||||
|
unit_name="$(basename "$unit_path")"
|
||||||
|
|
||||||
|
if [[ -e "$UNIT_DIR/$unit_name" && ! -L "$UNIT_DIR/$unit_name" ]]; then
|
||||||
|
unmanaged+=("$name")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
unit_installed "$name" || continue
|
||||||
|
|
||||||
|
link_file "$unit_path" "$UNIT_DIR/$unit_name"
|
||||||
|
mkdir -p "$TARGET_DIR/$name"
|
||||||
|
link_file "$SERVER_DIR/$name/compose.yml" "$TARGET_DIR/$name/compose.yml"
|
||||||
|
refreshed=$((refreshed + 1))
|
||||||
|
|
||||||
|
# Changed since the last time this machine looked, which is what makes a
|
||||||
|
# pull actionable: the restart is deliberately yours to run, so the least
|
||||||
|
# this can do is say which services are running a definition that is no
|
||||||
|
# longer what the repository says.
|
||||||
|
local now
|
||||||
|
now="$(definition_hash "$name")"
|
||||||
|
if [[ "$(recorded_hash "$name")" != "$now" ]]; then
|
||||||
|
changed+=("$name")
|
||||||
|
record_hash "$name" "$now"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
systemctl --user daemon-reload 2>/dev/null || true
|
||||||
|
info "Relinked $refreshed enabled service(s)"
|
||||||
|
if (( ${#unmanaged[@]} > 0 )); then
|
||||||
|
warn "Installed by hand, left alone: ${unmanaged[*]}"
|
||||||
|
fi
|
||||||
|
if (( ${#changed[@]} > 0 )); then
|
||||||
|
warn "Definitions changed; restart each when ready:"
|
||||||
|
for name in "${changed[@]}"; do
|
||||||
|
printf ' %s: systemctl --user restart %s\n' "$name" "$(unit_name_for "$name")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Dispatcher ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if [[ ! -d "$SERVER_DIR" ]]; then
|
||||||
|
err "No service catalog at $SERVER_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
local cmd="${1:-}"
|
||||||
|
case "$cmd" in
|
||||||
|
list) shift; cmd_list "$@" ;;
|
||||||
|
enable) shift; cmd_enable "$@" ;;
|
||||||
|
disable) shift; cmd_disable "$@" ;;
|
||||||
|
status) shift; cmd_status "$@" ;;
|
||||||
|
relink) shift; cmd_relink "$@" ;;
|
||||||
|
help|-h|--help|"") usage ;;
|
||||||
|
*)
|
||||||
|
err "Unknown command: '$cmd'"
|
||||||
|
echo
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -2,58 +2,588 @@
|
|||||||
|
|
||||||
# Panama's front door: the one command a fresh Fedora machine needs.
|
# 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)
|
# Download this file from the documented commit URL, verify its documented
|
||||||
|
# SHA-256, then pass both immutable values as PANAMA_BOOT_REVISION and
|
||||||
|
# PANAMA_BOOT_SHA256 when invoking it.
|
||||||
#
|
#
|
||||||
# Deliberately dumb, because a copy of this script leaves the repository the
|
# Deliberately dumb, because a copy of this script leaves the repository the
|
||||||
# moment somebody curls it -- nothing here can be fixed by re-running
|
# 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,
|
# ./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
|
# hand off. Everything with judgment in it lives in `install`, which is also
|
||||||
# where re-runs and upgrades already work.
|
# where re-runs and upgrades already work.
|
||||||
|
#
|
||||||
|
# The one exception to dumb is the root path below, which cannot live in
|
||||||
|
# `install`: a fresh VPS hands you a root login and nothing else, and the user
|
||||||
|
# that `install` needs to exist is exactly what has not been created yet.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ ! "${PANAMA_BOOT_REVISION:-}" =~ ^[0-9a-f]{40}$ ]]; then
|
||||||
|
echo "boot: PANAMA_BOOT_REVISION must be a full lowercase commit" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! "${PANAMA_BOOT_SHA256:-}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||||
|
echo "boot: PANAMA_BOOT_SHA256 must be a lowercase SHA-256" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
actual_boot_sha="$(sha256sum "${BASH_SOURCE[0]}" | cut -d' ' -f1)"
|
||||||
|
if [[ "$actual_boot_sha" != "$PANAMA_BOOT_SHA256" ]]; then
|
||||||
|
echo "boot: downloaded boot file does not match PANAMA_BOOT_SHA256" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
REPO_URL="https://git.gbrown.org/gib/Panama.git"
|
REPO_URL="https://git.gbrown.org/gib/Panama.git"
|
||||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
export PANAMA_PATH
|
export PANAMA_PATH
|
||||||
|
BOOTSTRAP_USER=""
|
||||||
|
|
||||||
# Root would put the clone and every dotfile in root's home and run the
|
checkout_command() {
|
||||||
# desktop setup for the wrong user. sudo is used inside where it is needed.
|
if [[ -n "$BOOTSTRAP_USER" ]]; then
|
||||||
if [[ "$(id -u)" -eq 0 ]]; then
|
runuser -u "$BOOTSTRAP_USER" -- "$@"
|
||||||
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2
|
else
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Git's index hints are performance promises, not trust evidence. In
|
||||||
|
# particular, assume-unchanged and skip-worktree can make porcelain status
|
||||||
|
# report a clean checkout whose files no longer match HEAD. Compare every
|
||||||
|
# tracked blob and Git mode with the verified commit before handing control to
|
||||||
|
# any file in the worktree.
|
||||||
|
checkout_matches_verified_commit() (
|
||||||
|
local checkout="$1" listing="" entry metadata mode type expected path actual
|
||||||
|
local link_target_with_sentinel link_target
|
||||||
|
|
||||||
|
trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1
|
||||||
|
umask 077
|
||||||
|
if ! (set -o noclobber; : >"$listing") 2>/dev/null; then
|
||||||
|
listing=""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Panama assumes Fedora's repositories, package names, and GNOME base install.
|
checkout_command git -C "$checkout" ls-tree -rz --full-tree \
|
||||||
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
|
"$PANAMA_BOOT_REVISION" >"$listing" || exit 1
|
||||||
echo "This looks like something other than Fedora; Panama only supports Fedora Workstation." >&2
|
while IFS= read -r -d '' entry; do
|
||||||
|
[[ "$entry" == *$'\t'* ]] || exit 1
|
||||||
|
metadata="${entry%%$'\t'*}"
|
||||||
|
path="${entry#*$'\t'}"
|
||||||
|
read -r mode type expected <<<"$metadata"
|
||||||
|
[[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
|
||||||
|
&& ! -x "$checkout/$path" ]] || exit 1 ;;
|
||||||
|
100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
|
||||||
|
&& -x "$checkout/$path" ]] || exit 1 ;;
|
||||||
|
120000)
|
||||||
|
[[ -L "$checkout/$path" ]] || exit 1
|
||||||
|
# hash-object given a pathname follows a symlink. Git's 120000 blob is
|
||||||
|
# the link text itself, including any trailing newlines, so preserve
|
||||||
|
# those bytes with a sentinel and hash stdin instead.
|
||||||
|
link_target_with_sentinel="$(
|
||||||
|
readlink -n -- "$checkout/$path" && printf .
|
||||||
|
)" || exit 1
|
||||||
|
[[ "$link_target_with_sentinel" == *. ]] || exit 1
|
||||||
|
link_target="${link_target_with_sentinel%.}"
|
||||||
|
actual="$(
|
||||||
|
printf '%s' "$link_target" \
|
||||||
|
| checkout_command git -C "$checkout" hash-object --stdin
|
||||||
|
)" || exit 1
|
||||||
|
[[ "$actual" == "$expected" ]] || exit 1
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \
|
||||||
|
|| exit 1
|
||||||
|
[[ "$actual" == "$expected" ]] || exit 1
|
||||||
|
done <"$listing"
|
||||||
|
)
|
||||||
|
|
||||||
|
prepare_panama_checkout() {
|
||||||
|
local checkout="$1" actual_head checkout_status
|
||||||
|
|
||||||
|
if [[ -e "$checkout" && ! -d "$checkout/.git" ]]; then
|
||||||
|
printf 'boot: %s exists but is not a Panama Git checkout\n' "$checkout" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "$checkout/.git" ]]; then
|
||||||
|
checkout_status="$(checkout_command git -C "$checkout" status --porcelain)" || {
|
||||||
|
printf 'boot: could not inspect the existing checkout at %s\n' "$checkout" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if [[ -n "$checkout_status" ]]; then
|
||||||
|
printf 'boot: existing checkout at %s has local changes\n' "$checkout" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Panama is already cloned at $checkout; fetching the verified revision"
|
||||||
|
checkout_command git -C "$checkout" fetch origin "$PANAMA_BOOT_REVISION" || return 1
|
||||||
|
if ! checkout_command git -C "$checkout" merge-base --is-ancestor \
|
||||||
|
HEAD "$PANAMA_BOOT_REVISION"; then
|
||||||
|
echo "boot: existing checkout cannot fast-forward to the verified revision" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
checkout_command git -C "$checkout" merge --ff-only "$PANAMA_BOOT_REVISION" || return 1
|
||||||
|
else
|
||||||
|
checkout_command mkdir -p "$(dirname "$checkout")" || return 1
|
||||||
|
checkout_command git init "$checkout" || return 1
|
||||||
|
checkout_command git -C "$checkout" remote add origin "$REPO_URL" || return 1
|
||||||
|
checkout_command git -C "$checkout" fetch --depth=1 origin \
|
||||||
|
"$PANAMA_BOOT_REVISION" || return 1
|
||||||
|
checkout_command git -C "$checkout" checkout --detach \
|
||||||
|
"$PANAMA_BOOT_REVISION" || return 1
|
||||||
|
|
||||||
|
actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1
|
||||||
|
if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then
|
||||||
|
echo "boot: fetched checkout does not match PANAMA_BOOT_REVISION" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
checkout_command git -C "$checkout" checkout -b main || return 1
|
||||||
|
checkout_command git -C "$checkout" config branch.main.remote origin || return 1
|
||||||
|
checkout_command git -C "$checkout" config branch.main.merge refs/heads/main || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1
|
||||||
|
if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then
|
||||||
|
echo "boot: checkout HEAD does not match PANAMA_BOOT_REVISION" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
SERVER=0
|
||||||
|
INSTALL_ARGS=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--server) SERVER=1; INSTALL_ARGS+=(--server) ;;
|
||||||
|
*)
|
||||||
|
printf 'boot: unknown argument: %s\n' "$arg" >&2
|
||||||
|
printf 'usage: boot [--server]\n' >&2
|
||||||
|
exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Keep the worktree comparison at the last possible boundary. Checkout
|
||||||
|
# preparation may invoke several commands and return to the caller; performing
|
||||||
|
# the byte/mode/link check here ensures a change in that interval is rejected
|
||||||
|
# before any tracked file is executed.
|
||||||
|
verified_install_handoff() {
|
||||||
|
local use_tty="$1"
|
||||||
|
if ! checkout_matches_verified_commit "$PANAMA_PATH"; then
|
||||||
|
echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$BOOTSTRAP_USER" ]]; then
|
||||||
|
if (( use_tty )); then
|
||||||
|
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
|
||||||
|
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||||
|
fi
|
||||||
|
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
|
||||||
|
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||||
|
fi
|
||||||
|
if (( use_tty )); then
|
||||||
|
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||||
|
fi
|
||||||
|
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The public bootstrap contract runs this branch as an ordinary user with a
|
||||||
|
# stubbed root identity. Keep its filesystem adapter unavailable to a real root
|
||||||
|
# shell so it cannot redirect a real installation by accident.
|
||||||
|
BOOT_ROOT="${PANAMA_BOOT_FIXTURE_ROOT:-}"
|
||||||
|
if [[ -n "$BOOT_ROOT" && "$EUID" -eq 0 ]]; then
|
||||||
|
echo "boot: PANAMA_BOOT_FIXTURE_ROOT is test-only" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
system_path() {
|
||||||
|
local path="$1"
|
||||||
|
[[ "$path" == /* ]] || return 2
|
||||||
|
printf '%s%s\n' "$BOOT_ROOT" "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_authorized_keys() {
|
||||||
|
local keys="$1" line saw_key=0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! ssh-keygen -l -f /dev/stdin >/dev/null 2>&1 <<<"$line"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
saw_key=1
|
||||||
|
done <"$keys"
|
||||||
|
(( saw_key ))
|
||||||
|
}
|
||||||
|
|
||||||
|
safe_authorized_keys() {
|
||||||
|
local username="$1" user_home="$2" uid ssh_dir keys
|
||||||
|
uid="$(id -u "$username")" || return 1
|
||||||
|
[[ "$uid" =~ ^[0-9]+$ && "$uid" != 0 && "$user_home" == /* ]] || return 1
|
||||||
|
ssh_dir="$user_home/.ssh"
|
||||||
|
keys="$ssh_dir/authorized_keys"
|
||||||
|
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
|
||||||
|
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
|
||||||
|
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
|
||||||
|
valid_authorized_keys "$keys"
|
||||||
|
}
|
||||||
|
|
||||||
|
safe_root_authorized_keys() {
|
||||||
|
local keys
|
||||||
|
keys="$(system_path /root/.ssh/authorized_keys)" || return 1
|
||||||
|
[[ -f "$keys" && ! -L "$keys" ]] || return 1
|
||||||
|
[[ "$(stat -Lc '%u:%a' "$keys")" == '0:600' ]] || return 1
|
||||||
|
valid_authorized_keys "$keys"
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_ssh_unit() {
|
||||||
|
local unit
|
||||||
|
for unit in sshd.service ssh.service; do
|
||||||
|
systemctl cat "$unit" >/dev/null 2>&1 && {
|
||||||
|
printf '%s\n' "$unit"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_ssh_dropin() {
|
||||||
|
local restore
|
||||||
|
if (( ssh_had_prior )); then
|
||||||
|
[[ -n "$ssh_backup" && -f "$ssh_backup" && ! -L "$ssh_backup" ]] || return 1
|
||||||
|
restore="$(mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.restore)" || return 1
|
||||||
|
if ! cp -a -- "$ssh_backup" "$restore"; then
|
||||||
|
remove_ssh_artifact "$restore" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! mv -f -- "$restore" "$ssh_dropin"; then
|
||||||
|
remove_ssh_artifact "$restore" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
remove_ssh_artifact "$ssh_dropin"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_ssh_transaction_traps() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
[[ -n "${ssh_saved_exit_trap:-}" ]] && eval "$ssh_saved_exit_trap"
|
||||||
|
[[ -n "${ssh_saved_int_trap:-}" ]] && eval "$ssh_saved_int_trap"
|
||||||
|
[[ -n "${ssh_saved_term_trap:-}" ]] && eval "$ssh_saved_term_trap"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_ssh_artifact() {
|
||||||
|
local artifact="$1"
|
||||||
|
[[ -n "$artifact" && ( -e "$artifact" || -L "$artifact" ) ]] || return 0
|
||||||
|
if rm -f -- "$artifact"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'SSH transaction cleanup failed. Retained artifact: %s\n' "$artifact" >&2
|
||||||
|
printf ' rm -f -- %q\n' "$artifact" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
print_ssh_recovery() {
|
||||||
|
if (( ssh_had_prior )); then
|
||||||
|
printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2
|
||||||
|
printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2
|
||||||
|
else
|
||||||
|
printf 'SSH rollback needs manual recovery. No prior drop-in existed.\n' >&2
|
||||||
|
printf ' rm -f -- %q\n' "$ssh_dropin" >&2
|
||||||
|
fi
|
||||||
|
printf ' sshd -t\n' >&2
|
||||||
|
printf ' systemctl reload %s\n' "$ssh_unit" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
policy_is_no() {
|
||||||
|
local policy="$1" setting="$2"
|
||||||
|
awk -v setting="$setting" '
|
||||||
|
$1 == setting { count += 1; if ($2 != "no") bad = 1 }
|
||||||
|
END { exit count != 1 || bad }
|
||||||
|
' <<<"$policy"
|
||||||
|
}
|
||||||
|
|
||||||
|
effective_ssh_policy_is_hardened() {
|
||||||
|
local username="$1" root_policy target_policy context
|
||||||
|
context='host=localhost,addr=127.0.0.1'
|
||||||
|
root_policy="$(sshd -T -C "user=root,$context")" || return 1
|
||||||
|
policy_is_no "$root_policy" permitrootlogin || return 1
|
||||||
|
policy_is_no "$root_policy" passwordauthentication || return 1
|
||||||
|
policy_is_no "$root_policy" kbdinteractiveauthentication || return 1
|
||||||
|
|
||||||
|
target_policy="$(sshd -T -C "user=$username,$context")" || return 1
|
||||||
|
policy_is_no "$target_policy" passwordauthentication || return 1
|
||||||
|
policy_is_no "$target_policy" kbdinteractiveauthentication
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_ssh_transaction() {
|
||||||
|
local reload_restored="$1" rollback_failed=0
|
||||||
|
restore_ssh_dropin || rollback_failed=1
|
||||||
|
sshd -t || rollback_failed=1
|
||||||
|
if (( reload_restored )); then
|
||||||
|
systemctl reload "$ssh_unit" || rollback_failed=1
|
||||||
|
fi
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
if (( rollback_failed )); then
|
||||||
|
print_ssh_recovery
|
||||||
|
else
|
||||||
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_ssh_transaction_exit() {
|
||||||
|
if [[ "$ssh_transaction_state" == preparing \
|
||||||
|
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
|
||||||
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
|
elif [[ "$ssh_transaction_state" == activating || "$ssh_transaction_state" == activated ]]; then
|
||||||
|
restore_ssh_dropin || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_ssh_transaction_signal() {
|
||||||
|
local signal_status="$1"
|
||||||
|
trap - INT TERM
|
||||||
|
if [[ "$ssh_transaction_state" == preparing \
|
||||||
|
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
|
else
|
||||||
|
rollback_ssh_transaction 1 || true
|
||||||
|
fi
|
||||||
|
exit "$signal_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
harden_server_ssh() {
|
||||||
|
local username="$1" user_home="$2" sshd_dir ssh_dropin harden ssh_unit
|
||||||
|
local ssh_candidate="" ssh_backup="" ssh_had_prior=0
|
||||||
|
local ssh_transaction_state=""
|
||||||
|
local ssh_saved_exit_trap ssh_saved_int_trap ssh_saved_term_trap
|
||||||
|
sshd_dir="$(system_path /etc/ssh/sshd_config.d)" || return 1
|
||||||
|
ssh_dropin="$sshd_dir/00-panama.conf"
|
||||||
|
|
||||||
|
if [[ -L "$ssh_dropin" || ( -e "$ssh_dropin" && ! -f "$ssh_dropin" ) ]]; then
|
||||||
|
printf 'SSH hardening unavailable: %s is not a regular file\n' "$ssh_dropin" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ssh_unit="$(detect_ssh_unit)"; then
|
||||||
|
echo "SSH hardening unavailable: neither sshd.service nor ssh.service is installed" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Harden sshd (disable root, password, and keyboard-interactive authentication)? [Y/n]: '
|
||||||
|
read -r harden </dev/tty || harden=""
|
||||||
|
if [[ "$harden" =~ ^[Nn] ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_saved_exit_trap="$(trap -p EXIT)"
|
||||||
|
ssh_saved_int_trap="$(trap -p INT)"
|
||||||
|
ssh_saved_term_trap="$(trap -p TERM)"
|
||||||
|
ssh_transaction_state=preparing
|
||||||
|
trap 'handle_ssh_transaction_exit' EXIT
|
||||||
|
trap 'handle_ssh_transaction_signal 130' INT
|
||||||
|
trap 'handle_ssh_transaction_signal 143' TERM
|
||||||
|
|
||||||
|
if ! ssh_candidate="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.tmp)"; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! printf 'PermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' >"$ssh_candidate"; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -e "$ssh_dropin" ]]; then
|
||||||
|
ssh_had_prior=1
|
||||||
|
if ! ssh_backup="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.backup)"; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! cp -a -- "$ssh_dropin" "$ssh_backup"; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_transaction_state=activating
|
||||||
|
if ! mv -f -- "$ssh_candidate" "$ssh_dropin"; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
ssh_candidate=""
|
||||||
|
ssh_transaction_state=activated
|
||||||
|
|
||||||
|
if ! sshd -t || ! effective_ssh_policy_is_hardened "$username"; then
|
||||||
|
rollback_ssh_transaction 0 || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! systemctl reload "$ssh_unit"; then
|
||||||
|
rollback_ssh_transaction 1 || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
remove_ssh_artifact "$ssh_backup" || return 1
|
||||||
|
echo "Wrote $ssh_dropin; make sure your key works before logging out."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Panama assumes Fedora's repositories and package names.
|
||||||
|
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
|
||||||
|
echo "This looks like something other than Fedora; Panama only supports Fedora." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Root ─────────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# On a desktop, root is a mistake: the clone and every dotfile would land in
|
||||||
|
# root's home and configure the wrong user. On a fresh VPS it is the starting
|
||||||
|
# condition -- Hetzner hands over a root login and nothing else -- so with
|
||||||
|
# --server this walks the machine from that to a normal Panama install: a
|
||||||
|
# user with sudo, keys, an optionally hardened sshd, and `install --server`
|
||||||
|
# running as that user. Every step checks before acting, because the machine
|
||||||
|
# may be anywhere along this path already: a user half-created by hand, keys
|
||||||
|
# already copied, sshd already locked down.
|
||||||
|
if [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
if (( ! SERVER )); then
|
||||||
|
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2
|
||||||
|
echo "Setting up a fresh server from its root login is: boot --server" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Everything here asks, and a root shell from `bash <(curl ...)` can have
|
||||||
|
# the pipe as stdin, so every prompt reads the terminal explicitly.
|
||||||
|
if ! (exec </dev/tty) 2>/dev/null; then
|
||||||
|
echo "No terminal to ask on; run this from an interactive root shell." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Username for this server [gib]: '
|
||||||
|
read -r username </dev/tty || username=""
|
||||||
|
username="${username:-gib}"
|
||||||
|
|
||||||
|
if id -u "$username" >/dev/null 2>&1; then
|
||||||
|
echo "User $username already exists"
|
||||||
|
# wheel is what makes sudo work on Fedora; a user created by hand may not
|
||||||
|
# have it, and everything after this depends on it.
|
||||||
|
id -nG "$username" | grep -qw wheel || usermod -aG wheel "$username"
|
||||||
|
else
|
||||||
|
echo "Creating $username with sudo (wheel)"
|
||||||
|
useradd -m -G wheel "$username"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# useradd leaves the account locked, and sudo asks for this password -- a
|
||||||
|
# user who cannot sudo is a user the install cannot run as.
|
||||||
|
if ! passwd -S "$username" 2>/dev/null | awk '{exit $2 != "PS" && $2 != "P"}'; then
|
||||||
|
echo "Set a password for $username (sudo will ask for it):"
|
||||||
|
passwd "$username" </dev/tty
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Do not close root/password access until the account's key is an exact,
|
||||||
|
# usable login path. The fixture adapter resolves these logical system paths
|
||||||
|
# beneath a temporary root; ordinary execution receives the original paths.
|
||||||
|
logical_user_home="$(getent passwd "$username" | cut -d: -f6)"
|
||||||
|
user_home=""
|
||||||
|
if [[ "$logical_user_home" == /* ]]; then
|
||||||
|
user_home="$(system_path "$logical_user_home")" || true
|
||||||
|
fi
|
||||||
|
bootstrap_home="$user_home"
|
||||||
|
if [[ -z "$bootstrap_home" ]]; then
|
||||||
|
bootstrap_home="$(system_path "/home/$username")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
user_ssh_dir="$user_home/.ssh"
|
||||||
|
user_keys="$user_ssh_dir/authorized_keys"
|
||||||
|
if [[ -n "$user_home" && ! -e "$user_keys" && ! -L "$user_keys" \
|
||||||
|
&& ! -L "$user_ssh_dir" ]] && safe_root_authorized_keys; then
|
||||||
|
copy_root_key=0
|
||||||
|
if [[ ! -e "$user_ssh_dir" ]]; then
|
||||||
|
copy_root_key=1
|
||||||
|
elif [[ ! -d "$user_ssh_dir" \
|
||||||
|
|| "$(stat -Lc '%u:%a' "$user_ssh_dir")" != "$(id -u "$username"):700" ]]; then
|
||||||
|
echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2
|
||||||
|
else
|
||||||
|
copy_root_key=1
|
||||||
|
fi
|
||||||
|
if (( copy_root_key )); then
|
||||||
|
echo "Copying root's authorized_keys to $username"
|
||||||
|
root_keys="$(system_path /root/.ssh/authorized_keys)"
|
||||||
|
if ! runuser -u "$username" -- install -d -m 0700 -- "$user_ssh_dir" \
|
||||||
|
|| ! runuser -u "$username" -- install -m 0600 -- /dev/stdin "$user_keys" \
|
||||||
|
<"$root_keys"; then
|
||||||
|
echo "SSH hardening unavailable: could not install root's key for $username" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if safe_authorized_keys "$username" "$user_home"; then
|
||||||
|
harden_status=0
|
||||||
|
harden_server_ssh "$username" "$user_home" || harden_status=$?
|
||||||
|
if (( harden_status != 0 && harden_status != 2 )); then
|
||||||
|
echo "SSH hardening failed; stopping before install handoff." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
echo "Installing git, which the clone needs"
|
||||||
|
dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create or advance the checkout as the target user. A root-owned .git in a
|
||||||
|
# user's home would break every later update.
|
||||||
|
PANAMA_PATH="$bootstrap_home/.local/share/Panama"
|
||||||
|
BOOTSTRAP_USER="$username"
|
||||||
|
prepare_panama_checkout "$PANAMA_PATH"
|
||||||
|
|
||||||
|
echo "Handing off to install as $username"
|
||||||
|
verified_install_handoff 1
|
||||||
|
fi
|
||||||
|
|
||||||
# git is the one dependency the clone itself needs. Everything else -- gum
|
# git is the one dependency the clone itself needs. Everything else -- gum
|
||||||
# included -- is bootstrapped by `install`.
|
# included -- is bootstrapped by `install`.
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
echo "Installing git, which the clone needs"
|
echo "Installing git, which the clone needs"
|
||||||
sudo dnf install -y git
|
sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -d "$PANAMA_PATH/.git" ]]; then
|
prepare_panama_checkout "$PANAMA_PATH"
|
||||||
# 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
|
# A shell invoked from automation can have a pipe as stdin, while the first
|
||||||
# first thing install runs is the interview, which has to be able to ask.
|
# 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
|
# Reattach the terminal when there is one; without one the interview will say
|
||||||
# so itself.
|
# so itself.
|
||||||
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
||||||
# controlling terminal passes -r and then fails the redirect.
|
# controlling terminal passes -r and then fails the redirect.
|
||||||
|
handoff_tty=0
|
||||||
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
||||||
exec "$PANAMA_PATH/install" </dev/tty
|
handoff_tty=1
|
||||||
fi
|
fi
|
||||||
exec "$PANAMA_PATH/install"
|
verified_install_handoff "$handoff_tty"
|
||||||
|
|||||||
@@ -24,7 +24,3 @@ else
|
|||||||
fi
|
fi
|
||||||
export PATH
|
export PATH
|
||||||
fi
|
fi
|
||||||
# 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"
|
|
||||||
|
|||||||
+10
-1
@@ -6,7 +6,16 @@ alias :wq="exit"
|
|||||||
alias sourcerc="source ~/.bashrc"
|
alias sourcerc="source ~/.bashrc"
|
||||||
alias c="clear"
|
alias c="clear"
|
||||||
alias shutdown="systemctl poweroff"
|
alias shutdown="systemctl poweroff"
|
||||||
alias update-grub="sudo grub-mkconfig -o /etc/grub2-efi.cfg"
|
# The config target differs by firmware: EFI machines regenerate the EFI
|
||||||
|
# config, BIOS machines /boot/grub2/grub.cfg -- writing the EFI path on a BIOS
|
||||||
|
# machine updates a file nothing boots from.
|
||||||
|
update-grub() {
|
||||||
|
if [ -d /sys/firmware/efi ]; then
|
||||||
|
sudo grub2-mkconfig -o /etc/grub2-efi.cfg
|
||||||
|
else
|
||||||
|
sudo grub2-mkconfig -o /boot/grub2/grub.cfg
|
||||||
|
fi
|
||||||
|
}
|
||||||
alias nvidia-smi-docker='sudo docker run --rm --gpus all --privileged nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi'
|
alias nvidia-smi-docker='sudo docker run --rm --gpus all --privileged nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi'
|
||||||
alias ncconnect='sudo docker exec -u www-data -it nextcloud-aio-nextcloud bash'
|
alias ncconnect='sudo docker exec -u www-data -it nextcloud-aio-nextcloud bash'
|
||||||
alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mode()end, 100)"'
|
alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mode()end, 100)"'
|
||||||
|
|||||||
+29
-3
@@ -1,5 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Personal environment -- API keys, tokens -- lives OUTSIDE the checkout,
|
||||||
|
# where agents, backup tools and `panama update` walk, and is kept owner-only
|
||||||
|
# every time it is read: a secrets file that drifts to 644 is quietly
|
||||||
|
# re-tightened rather than trusted. Sourced first, because settings below
|
||||||
|
# (PANAMA_SSH_TMUX) read it. (config/bash/env, its old home inside the repo,
|
||||||
|
# is still sourced by .bashrc's glob if a machine has not been migrated yet.)
|
||||||
|
PANAMA_ENV="${XDG_CONFIG_HOME:-$HOME/.config}/panama/env"
|
||||||
|
if [ -f "$PANAMA_ENV" ]; then
|
||||||
|
[ "$(stat -c %a "$PANAMA_ENV" 2>/dev/null)" = "600" ] || chmod 600 "$PANAMA_ENV"
|
||||||
|
. "$PANAMA_ENV"
|
||||||
|
fi
|
||||||
|
unset PANAMA_ENV
|
||||||
|
|
||||||
# Editor used by CLI
|
# Editor used by CLI
|
||||||
export EDITOR="nvim"
|
export EDITOR="nvim"
|
||||||
export SUDO_EDITOR="$EDITOR"
|
export SUDO_EDITOR="$EDITOR"
|
||||||
@@ -22,20 +35,33 @@ export DOTNETPATH="$HOME/.dotnet/tools"
|
|||||||
# Set complete path
|
# 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"
|
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"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
# Nvm. Guarded because the file belongs to the nvm package: before that is
|
# 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
|
# installed it does not exist, and an unconditional source means every shell on
|
||||||
# a fresh machine opens with an error.
|
# a fresh machine opens with an error.
|
||||||
[ -f /etc/profile.d/nvm.sh ] && source /etc/profile.d/nvm.sh
|
[ -f /etc/profile.d/nvm.sh ] && source /etc/profile.d/nvm.sh
|
||||||
# Auto-switch Node version when entering a directory with .nvmrc
|
# Auto-switch Node version when entering a directory with .nvmrc
|
||||||
_nvm_auto_use() {
|
_nvm_auto_use() {
|
||||||
if [[ -f .nvmrc ]]; then
|
# Guarded on nvm actually being loaded: without this, a machine where the
|
||||||
|
# nvm profile script is absent printed "command not found" on every single
|
||||||
|
# prompt in any directory carrying a .nvmrc.
|
||||||
|
if [[ -f .nvmrc ]] && type -t nvm >/dev/null 2>&1; then
|
||||||
nvm use --silent
|
nvm use --silent
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
export PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }_nvm_auto_use"
|
export PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }_nvm_auto_use"
|
||||||
|
|
||||||
# Auto-start or attach tmux for SSH interactive shells
|
# Auto-start or attach tmux for SSH interactive shells. A deliberate Panama
|
||||||
if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && $- == *i* ]]; then
|
# behavior (tmux is in initial-packages, and a dropped SSH session keeping its
|
||||||
|
# work is the point), but guarded: it must not replace the shell of someone
|
||||||
|
# whose machine lacks tmux, and PANAMA_SSH_TMUX=off turns it off for people
|
||||||
|
# who want a plain shell -- set it in ~/.config/panama/env.
|
||||||
|
if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && $- == *i* &&
|
||||||
|
"${PANAMA_SSH_TMUX:-on}" != "off" ]] && command -v tmux >/dev/null 2>&1; then
|
||||||
exec tmux new-session -A -s main
|
exec tmux new-session -A -s main
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# see `man dnf.conf` for defaults and possible options
|
|
||||||
|
|
||||||
[main]
|
|
||||||
fastestmirror=True
|
|
||||||
max_parallel_downloads=10
|
|
||||||
defaultyes=True
|
|
||||||
keepcache=True
|
|
||||||
deltarpm=True
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# A stray press of the power button should ask, not act. Fedora's logind
|
||||||
|
# default is HandlePowerKey=poweroff -- an instant, unconfirmed shutdown.
|
||||||
|
# Panama binds the key in the compositor to open its power menu instead
|
||||||
|
# (keybinds.lua), the way GNOME turns the key into a dialog, so logind is
|
||||||
|
# told to stand down. Holding the button still hard-cuts through firmware,
|
||||||
|
# which remains the escape hatch this file cannot and should not remove.
|
||||||
|
[Login]
|
||||||
|
HandlePowerKey=ignore
|
||||||
@@ -18,5 +18,18 @@
|
|||||||
#
|
#
|
||||||
# `-` because a clean start has nothing to unmount and fusermount3 exits 1
|
# `-` because a clean start has nothing to unmount and fusermount3 exits 1
|
||||||
# saying so, which is not a failure to start on.
|
# saying so, which is not a failure to start on.
|
||||||
|
#
|
||||||
|
# The unmount alone did not close the hole: the third recurrence (2026-08-26)
|
||||||
|
# started on ground the ExecStartPre had cleaned and still came up with no
|
||||||
|
# mount, sitting active-and-broken for fourteen hours until the next fresh
|
||||||
|
# flatpak launch found it. Hence ExecStartPost: the unit is not "started"
|
||||||
|
# until the mount it exists to provide actually exists. Type=dbus declares
|
||||||
|
# readiness at bus-name acquisition, which can land before the fuse thread
|
||||||
|
# has the mount up, so this polls rather than checks once. If five seconds
|
||||||
|
# pass without a mount, the start fails -- loudly, in the journal, as failed
|
||||||
|
# -- and the next bus activation retries from the ExecStartPre cleanup
|
||||||
|
# instead of every sandbox build failing against a unit that claims to be
|
||||||
|
# fine.
|
||||||
[Service]
|
[Service]
|
||||||
ExecStartPre=-/usr/bin/fusermount3 -u /run/user/%U/doc
|
ExecStartPre=-/usr/bin/fusermount3 -u /run/user/%U/doc
|
||||||
|
ExecStartPost=/usr/bin/timeout 5 /bin/sh -c 'until /usr/bin/mountpoint -q /run/user/%U/doc; do /usr/bin/sleep 0.2; done'
|
||||||
|
|||||||
@@ -139,18 +139,6 @@ matches:
|
|||||||
- trigger: "dokcer"
|
- trigger: "dokcer"
|
||||||
replace: "docker"
|
replace: "docker"
|
||||||
|
|
||||||
- trigger: "gborwn"
|
|
||||||
replace: "gbrown"
|
|
||||||
|
|
||||||
- trigger: "gbrwon"
|
|
||||||
replace: "gbrown"
|
|
||||||
|
|
||||||
- trigger: "gbronw"
|
|
||||||
replace: "gbrown"
|
|
||||||
|
|
||||||
- trigger: "grbown"
|
|
||||||
replace: "gbrown"
|
|
||||||
|
|
||||||
- trigger: "restrat"
|
- trigger: "restrat"
|
||||||
replace: "restart"
|
replace: "restart"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
file:///home/gib/Documents Documents
|
|
||||||
file:///home/gib/Documents/Code Code
|
|
||||||
file:///home/gib/Downloads Downloads
|
|
||||||
file:///home/gib/Music Music
|
|
||||||
file:///home/gib/Pictures Pictures
|
|
||||||
file:///home/gib/Videos Videos
|
|
||||||
file:///home/gib/.local/share/Panama Panama
|
|
||||||
smb://server-gib.local/homes/ Gib's Server
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
file://$HOME/Documents Documents
|
||||||
|
file://$HOME/Downloads Downloads
|
||||||
|
file://$HOME/Music Music
|
||||||
|
file://$HOME/Pictures Pictures
|
||||||
|
file://$HOME/Videos Videos
|
||||||
|
file://$HOME/.local/share/Panama Panama
|
||||||
+60
-33
@@ -1,36 +1,63 @@
|
|||||||
@define-color accent_color rgba(192,153,255,0.8);
|
/* Panama regenerates the block below from the active theme's palette, and only
|
||||||
@define-color accent_bg_color rgba(130,170,255,0.8);
|
* the block: every rule outside the markers belongs to the vendored theme and
|
||||||
@define-color accent_fg_color #ffffff;
|
* is never touched. Edit the theme, not this file -- the next theme change
|
||||||
@define-color destructive_color #ff7b63;
|
* overwrites whatever is between the markers. The values shipped here are Tokyo
|
||||||
@define-color destructive_bg_color #c01c28;
|
* Moon, the default dark theme, so the file is valid before it has ever been
|
||||||
@define-color destructive_fg_color #ffffff;
|
* rewritten. See quickshell/scripts/panama-theme-apps. */
|
||||||
@define-color success_color #8ff0a4;
|
/* PANAMA THEME BEGIN */
|
||||||
@define-color success_bg_color #26a269;
|
/* Generated from the "moon" theme by panama-theme-apps. Edit the
|
||||||
@define-color success_fg_color #ffffff;
|
* theme, not this block -- the next theme change overwrites it. */
|
||||||
@define-color warning_color #f8e45c;
|
@define-color accent_bg_color #82aaff;
|
||||||
@define-color warning_bg_color #cd9309;
|
@define-color accent_color #65bcff;
|
||||||
@define-color warning_fg_color rgba(0, 0, 0, 0.8);
|
@define-color accent_fg_color #1e2030;
|
||||||
@define-color error_color #ff7b63;
|
@define-color destructive_bg_color #c53b53;
|
||||||
@define-color error_bg_color #c01c28;
|
@define-color destructive_color #ff757f;
|
||||||
@define-color error_fg_color #ffffff;
|
@define-color destructive_fg_color #c8d3f5;
|
||||||
@define-color window_bg_color rgba(46,47,61,0.98);
|
@define-color success_bg_color #c3e88d;
|
||||||
@define-color window_fg_color #ffffff;
|
@define-color success_color #c3e88d;
|
||||||
@define-color view_bg_color rgba(46,47,61,0.98);
|
@define-color success_fg_color #1e2030;
|
||||||
@define-color view_fg_color #ffffff;
|
@define-color warning_bg_color #ffc777;
|
||||||
@define-color headerbar_bg_color rgba(46,47,61,0.98);
|
@define-color warning_color #ffc777;
|
||||||
@define-color headerbar_fg_color #ffffff;
|
@define-color warning_fg_color #1e2030;
|
||||||
@define-color headerbar_border_color #ffffff;
|
@define-color error_bg_color #c53b53;
|
||||||
@define-color headerbar_backdrop_color @window_bg_color;
|
@define-color error_color #ff757f;
|
||||||
@define-color headerbar_shade_color rgba(46,47,61,0.05);
|
@define-color error_fg_color #c8d3f5;
|
||||||
@define-color card_bg_color rgba(180, 200, 255, 0.15);
|
@define-color window_bg_color #2e2f3d;
|
||||||
@define-color card_fg_color #ffffff;
|
@define-color window_fg_color #c8d3f5;
|
||||||
@define-color card_shade_color rgba(0, 0, 0, 0.36);
|
@define-color view_bg_color #222436;
|
||||||
@define-color dialog_bg_color rgba(46,47,61,0.98);
|
@define-color view_fg_color #c8d3f5;
|
||||||
@define-color dialog_fg_color #ffffff;
|
@define-color headerbar_bg_color #2e2f3d;
|
||||||
@define-color popover_bg_color rgba(46,47,61,0.98);
|
@define-color headerbar_fg_color #c8d3f5;
|
||||||
@define-color popover_fg_color #ffffff;
|
@define-color headerbar_border_color #3b4261;
|
||||||
@define-color shade_color rgba(0, 0, 0, 0);
|
@define-color headerbar_backdrop_color #1e2030;
|
||||||
@define-color scrollbar_outline_color rgba(0, 0, 0, 0.5);
|
@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
|
||||||
|
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
|
||||||
|
@define-color sidebar_bg_color #1e2030;
|
||||||
|
@define-color sidebar_fg_color #c8d3f5;
|
||||||
|
@define-color sidebar_backdrop_color #1e2030;
|
||||||
|
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
|
||||||
|
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||||
|
@define-color secondary_sidebar_bg_color #1e2030;
|
||||||
|
@define-color secondary_sidebar_fg_color #c8d3f5;
|
||||||
|
@define-color secondary_sidebar_backdrop_color #1e2030;
|
||||||
|
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||||
|
@define-color card_bg_color #2f334d;
|
||||||
|
@define-color card_fg_color #c8d3f5;
|
||||||
|
@define-color card_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color dialog_bg_color #21212f;
|
||||||
|
@define-color dialog_fg_color #c8d3f5;
|
||||||
|
@define-color popover_bg_color #21212f;
|
||||||
|
@define-color popover_fg_color #c8d3f5;
|
||||||
|
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color thumbnail_bg_color #2f334d;
|
||||||
|
@define-color thumbnail_fg_color #c8d3f5;
|
||||||
|
@define-color shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
|
||||||
|
/* PANAMA THEME END */
|
||||||
|
|
||||||
|
/* The GNOME named palette below is reference, not theme: applications ask for
|
||||||
|
* @red_3 by name and expect GNOME's red. It stays outside the block. */
|
||||||
@define-color blue_1 #99c1f1;
|
@define-color blue_1 #99c1f1;
|
||||||
@define-color blue_2 #62a0ea;
|
@define-color blue_2 #62a0ea;
|
||||||
@define-color blue_3 #3584e4;
|
@define-color blue_3 #3584e4;
|
||||||
|
|||||||
Executable → Regular
+58
-47
@@ -1,53 +1,64 @@
|
|||||||
/* GTK NAMED COLORS ---------------- use responsibly! */
|
/* GTK NAMED COLORS ---------------- use responsibly! */
|
||||||
@define-color accent_bg_color RGB(130 170 255 / 80%);
|
/* Panama regenerates the block below from the active theme's palette, and only
|
||||||
|
* the block: every rule outside the markers belongs to the vendored theme and
|
||||||
@define-color destructive_bg_color #c01c28;
|
* is never touched. Edit the theme, not this file -- the next theme change
|
||||||
@define-color destructive_fg_color white;
|
* overwrites whatever is between the markers. The values shipped here are Tokyo
|
||||||
@define-color success_bg_color #26a269;
|
* Moon, the default dark theme, so the file is valid before it has ever been
|
||||||
@define-color success_fg_color white;
|
* rewritten. See quickshell/scripts/panama-theme-apps. */
|
||||||
@define-color warning_bg_color #cd9309;
|
/* PANAMA THEME BEGIN */
|
||||||
@define-color warning_fg_color RGB(0 0 0 / 80%);
|
/* Generated from the "moon" theme by panama-theme-apps. Edit the
|
||||||
@define-color error_bg_color #c01c28;
|
* theme, not this block -- the next theme change overwrites it. */
|
||||||
@define-color error_fg_color white;
|
@define-color accent_bg_color #82aaff;
|
||||||
@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b);
|
@define-color accent_color #65bcff;
|
||||||
@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b);
|
@define-color accent_fg_color #1e2030;
|
||||||
@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b);
|
@define-color destructive_bg_color #c53b53;
|
||||||
@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b);
|
@define-color destructive_color #ff757f;
|
||||||
@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b);
|
@define-color destructive_fg_color #c8d3f5;
|
||||||
|
@define-color success_bg_color #c3e88d;
|
||||||
|
@define-color success_color #c3e88d;
|
||||||
|
@define-color success_fg_color #1e2030;
|
||||||
|
@define-color warning_bg_color #ffc777;
|
||||||
|
@define-color warning_color #ffc777;
|
||||||
|
@define-color warning_fg_color #1e2030;
|
||||||
|
@define-color error_bg_color #c53b53;
|
||||||
|
@define-color error_color #ff757f;
|
||||||
|
@define-color error_fg_color #c8d3f5;
|
||||||
@define-color window_bg_color #2e2f3d;
|
@define-color window_bg_color #2e2f3d;
|
||||||
@define-color window_fg_color white;
|
@define-color window_fg_color #c8d3f5;
|
||||||
@define-color view_bg_color @window_bg_color
|
@define-color view_bg_color #222436;
|
||||||
@define-color view_fg_color white;
|
@define-color view_fg_color #c8d3f5;
|
||||||
@define-color headerbar_bg_color @window_bg_color
|
@define-color headerbar_bg_color #2e2f3d;
|
||||||
@define-color headerbar_fg_color white;
|
@define-color headerbar_fg_color #c8d3f5;
|
||||||
@define-color headerbar_border_color white;
|
@define-color headerbar_border_color #3b4261;
|
||||||
@define-color headerbar_backdrop_color @window_bg_color;
|
@define-color headerbar_backdrop_color #1e2030;
|
||||||
@define-color headerbar_shade_color RGB(46 47 61 / 8%);
|
@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
|
||||||
@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9);
|
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
|
||||||
|
@define-color sidebar_bg_color #1e2030;
|
||||||
|
@define-color sidebar_fg_color #c8d3f5;
|
||||||
|
@define-color sidebar_backdrop_color #1e2030;
|
||||||
|
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
|
||||||
|
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||||
|
@define-color secondary_sidebar_bg_color #1e2030;
|
||||||
|
@define-color secondary_sidebar_fg_color #c8d3f5;
|
||||||
|
@define-color secondary_sidebar_backdrop_color #1e2030;
|
||||||
|
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||||
|
@define-color card_bg_color #2f334d;
|
||||||
|
@define-color card_fg_color #c8d3f5;
|
||||||
|
@define-color card_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color dialog_bg_color #21212f;
|
||||||
|
@define-color dialog_fg_color #c8d3f5;
|
||||||
|
@define-color popover_bg_color #21212f;
|
||||||
|
@define-color popover_fg_color #c8d3f5;
|
||||||
|
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color thumbnail_bg_color #2f334d;
|
||||||
|
@define-color thumbnail_fg_color #c8d3f5;
|
||||||
|
@define-color shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
|
||||||
|
/* PANAMA THEME END */
|
||||||
|
|
||||||
@define-color sidebar_bg_color #3c3c4b;
|
/* The GNOME named palette below is reference, not theme: applications ask for
|
||||||
@define-color sidebar_fg_color white;
|
* @red_3 by name and expect GNOME's red. It stays outside the block. */
|
||||||
@define-color sidebar_backdrop_color #3c3c4b;
|
|
||||||
@define-color sidebar_shade_color RGB(60 60 75/8%);
|
|
||||||
@define-color sidebar_border_color RGB(75 75 90/36%);
|
|
||||||
@define-color secondary_sidebar_bg_color #3c3c4b;
|
|
||||||
@define-color secondary_sidebar_fg_color white;
|
|
||||||
@define-color secondary_sidebar_backdrop_color #3c3c4b;
|
|
||||||
@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
|
|
||||||
@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
|
|
||||||
@define-color card_bg_color RGB(180 200 255/15%);
|
|
||||||
@define-color card_fg_color white;
|
|
||||||
@define-color card_shade_color RGB(46 47 61/8%);
|
|
||||||
@define-color dialog_bg_color @window_bg_color;
|
|
||||||
@define-color dialog_fg_color white;
|
|
||||||
@define-color popover_bg_color @window_bg_color;
|
|
||||||
@define-color popover_fg_color white;
|
|
||||||
@define-color popover_shade_color RGB(0 0 6/25%);
|
|
||||||
@define-color thumbnail_bg_color @window_bg_color;
|
|
||||||
@define-color thumbnail_fg_color white;
|
|
||||||
@define-color shade_color RGB(0 0 6/25%);
|
|
||||||
@define-color scrollbar_outline_color RGB(0 0 12/95%);
|
|
||||||
@define-color blue_1 #99c1f1;
|
@define-color blue_1 #99c1f1;
|
||||||
@define-color blue_2 #62a0ea;
|
@define-color blue_2 #62a0ea;
|
||||||
@define-color blue_3 #3584e4;
|
@define-color blue_3 #3584e4;
|
||||||
|
|||||||
Executable → Regular
+58
-47
@@ -1,53 +1,64 @@
|
|||||||
/* GTK NAMED COLORS ---------------- use responsibly! */
|
/* GTK NAMED COLORS ---------------- use responsibly! */
|
||||||
@define-color accent_bg_color RGB(130 170 255 / 80%);
|
/* Panama regenerates the block below from the active theme's palette, and only
|
||||||
|
* the block: every rule outside the markers belongs to the vendored theme and
|
||||||
@define-color destructive_bg_color #c01c28;
|
* is never touched. Edit the theme, not this file -- the next theme change
|
||||||
@define-color destructive_fg_color white;
|
* overwrites whatever is between the markers. The values shipped here are Tokyo
|
||||||
@define-color success_bg_color #26a269;
|
* Moon, the default dark theme, so the file is valid before it has ever been
|
||||||
@define-color success_fg_color white;
|
* rewritten. See quickshell/scripts/panama-theme-apps. */
|
||||||
@define-color warning_bg_color #cd9309;
|
/* PANAMA THEME BEGIN */
|
||||||
@define-color warning_fg_color RGB(0 0 0 / 80%);
|
/* Generated from the "moon" theme by panama-theme-apps. Edit the
|
||||||
@define-color error_bg_color #c01c28;
|
* theme, not this block -- the next theme change overwrites it. */
|
||||||
@define-color error_fg_color white;
|
@define-color accent_bg_color #82aaff;
|
||||||
@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b);
|
@define-color accent_color #65bcff;
|
||||||
@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b);
|
@define-color accent_fg_color #1e2030;
|
||||||
@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b);
|
@define-color destructive_bg_color #c53b53;
|
||||||
@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b);
|
@define-color destructive_color #ff757f;
|
||||||
@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b);
|
@define-color destructive_fg_color #c8d3f5;
|
||||||
|
@define-color success_bg_color #c3e88d;
|
||||||
|
@define-color success_color #c3e88d;
|
||||||
|
@define-color success_fg_color #1e2030;
|
||||||
|
@define-color warning_bg_color #ffc777;
|
||||||
|
@define-color warning_color #ffc777;
|
||||||
|
@define-color warning_fg_color #1e2030;
|
||||||
|
@define-color error_bg_color #c53b53;
|
||||||
|
@define-color error_color #ff757f;
|
||||||
|
@define-color error_fg_color #c8d3f5;
|
||||||
@define-color window_bg_color #2e2f3d;
|
@define-color window_bg_color #2e2f3d;
|
||||||
@define-color window_fg_color white;
|
@define-color window_fg_color #c8d3f5;
|
||||||
@define-color view_bg_color @window_bg_color
|
@define-color view_bg_color #222436;
|
||||||
@define-color view_fg_color white;
|
@define-color view_fg_color #c8d3f5;
|
||||||
@define-color headerbar_bg_color @window_bg_color
|
@define-color headerbar_bg_color #2e2f3d;
|
||||||
@define-color headerbar_fg_color white;
|
@define-color headerbar_fg_color #c8d3f5;
|
||||||
@define-color headerbar_border_color white;
|
@define-color headerbar_border_color #3b4261;
|
||||||
@define-color headerbar_backdrop_color @window_bg_color;
|
@define-color headerbar_backdrop_color #1e2030;
|
||||||
@define-color headerbar_shade_color RGB(46 47 61 / 8%);
|
@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
|
||||||
@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9);
|
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
|
||||||
|
@define-color sidebar_bg_color #1e2030;
|
||||||
|
@define-color sidebar_fg_color #c8d3f5;
|
||||||
|
@define-color sidebar_backdrop_color #1e2030;
|
||||||
|
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
|
||||||
|
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||||
|
@define-color secondary_sidebar_bg_color #1e2030;
|
||||||
|
@define-color secondary_sidebar_fg_color #c8d3f5;
|
||||||
|
@define-color secondary_sidebar_backdrop_color #1e2030;
|
||||||
|
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||||
|
@define-color card_bg_color #2f334d;
|
||||||
|
@define-color card_fg_color #c8d3f5;
|
||||||
|
@define-color card_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color dialog_bg_color #21212f;
|
||||||
|
@define-color dialog_fg_color #c8d3f5;
|
||||||
|
@define-color popover_bg_color #21212f;
|
||||||
|
@define-color popover_fg_color #c8d3f5;
|
||||||
|
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color thumbnail_bg_color #2f334d;
|
||||||
|
@define-color thumbnail_fg_color #c8d3f5;
|
||||||
|
@define-color shade_color rgba(30, 32, 48, 0.25);
|
||||||
|
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
|
||||||
|
/* PANAMA THEME END */
|
||||||
|
|
||||||
@define-color sidebar_bg_color #3c3c4b;
|
/* The GNOME named palette below is reference, not theme: applications ask for
|
||||||
@define-color sidebar_fg_color white;
|
* @red_3 by name and expect GNOME's red. It stays outside the block. */
|
||||||
@define-color sidebar_backdrop_color #3c3c4b;
|
|
||||||
@define-color sidebar_shade_color RGB(60 60 75/8%);
|
|
||||||
@define-color sidebar_border_color RGB(75 75 90/36%);
|
|
||||||
@define-color secondary_sidebar_bg_color #3c3c4b;
|
|
||||||
@define-color secondary_sidebar_fg_color white;
|
|
||||||
@define-color secondary_sidebar_backdrop_color #3c3c4b;
|
|
||||||
@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
|
|
||||||
@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
|
|
||||||
@define-color card_bg_color RGB(180 200 255/15%);
|
|
||||||
@define-color card_fg_color white;
|
|
||||||
@define-color card_shade_color RGB(46 47 61/8%);
|
|
||||||
@define-color dialog_bg_color @window_bg_color;
|
|
||||||
@define-color dialog_fg_color white;
|
|
||||||
@define-color popover_bg_color @window_bg_color;
|
|
||||||
@define-color popover_fg_color white;
|
|
||||||
@define-color popover_shade_color RGB(0 0 6/25%);
|
|
||||||
@define-color thumbnail_bg_color @window_bg_color;
|
|
||||||
@define-color thumbnail_fg_color white;
|
|
||||||
@define-color shade_color RGB(0 0 6/25%);
|
|
||||||
@define-color scrollbar_outline_color RGB(0 0 12/95%);
|
|
||||||
@define-color blue_1 #99c1f1;
|
@define-color blue_1 #99c1f1;
|
||||||
@define-color blue_2 #62a0ea;
|
@define-color blue_2 #62a0ea;
|
||||||
@define-color blue_3 #3584e4;
|
@define-color blue_3 #3584e4;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
|||||||
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | 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 favorites, 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 |
|
| 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 |
|
| Brightness | Appears only when a backlight device exists | Live where the hardware exposes it |
|
||||||
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
||||||
| Screenshot and recording | Quickshell picker, grim/slurp/satty/wf-recorder | Live |
|
| Screenshot and recording | Quickshell picker, grim/slurp/satty/wf-recorder | Live |
|
||||||
| Screen text and code recognition | Local Tesseract/ZBar Read mode, selectable Prism result sheet | Live after package install |
|
| Screen text and code recognition | Local Tesseract/ZBar Read mode, selectable Prism result sheet | Live after package install |
|
||||||
@@ -119,8 +119,16 @@ a polished general-purpose desktop can go beyond the current shell.
|
|||||||
3. A keyboard-layout change notice. Hyprland reports the active keymap but 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
|
a change event Quickshell already consumes, so this needs either polling or
|
||||||
new event plumbing, and a single-layout machine cannot test it.
|
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
|
4. Sticky keys, slow keys and bounce keys. Wayland has no protocol for these,
|
||||||
with no Wayland equivalent; GNOME, macOS and Windows all ship these.
|
so each compositor implements them for itself — mutter does, which is how
|
||||||
|
GNOME has them on Wayland, and Hyprland does not. There is no XKB option to
|
||||||
|
lean on either: the accessx option group is X11-only and does not appear in
|
||||||
|
evdev.lst at all (checked, not assumed — `grep -c accessx
|
||||||
|
/usr/share/X11/xkb/rules/evdev.lst` is 0), and Hyprland will happily store
|
||||||
|
`accessx:enable` as a keyboard option that nothing ever acts on. This is a
|
||||||
|
Hyprland gap rather than a Wayland impossibility, and the Accessibility page
|
||||||
|
says so in those terms; an earlier version of this line blamed X11 and sent
|
||||||
|
anyone who needs sticky keys to the wrong conclusion about the platform.
|
||||||
5. An on-screen keyboard, for a touch or convertible machine.
|
5. An on-screen keyboard, for a touch or convertible machine.
|
||||||
|
|
||||||
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ Don't "fix" them.
|
|||||||
| `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
|
| `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
|
||||||
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
|
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
|
||||||
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
|
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
|
||||||
| `keybinds.lua` | The full keymap |
|
| `keybinds.lua` | The full keymap, including the custom shortcuts the Settings app stores |
|
||||||
|
| `actions.lua` | Named-action resolver: the whitelist tables that turn stored `{kind, target}` data into binds, gestures — never into free-form commands |
|
||||||
| `autostart.lua` | Session startup |
|
| `autostart.lua` | Session startup |
|
||||||
| `overrides.lua` | Per-machine escape hatch, loaded last |
|
| `overrides.lua` | Per-machine escape hatch, loaded last |
|
||||||
|
| `shaders/` | Whole-screen color-filter shaders (grayscale and the three color-blindness corrections) `looks.lua` maps the `colorFilter` setting onto |
|
||||||
| `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) |
|
| `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) |
|
||||||
|
|
||||||
Validate any change without leaving your session:
|
Validate any change without leaving your session:
|
||||||
@@ -119,7 +121,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
|
|||||||
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
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
|
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
|
one has nothing readable beside its chord, and the Settings app drops it from the
|
||||||
Input & Shortcuts page rather than showing a mystery row.
|
Keyboard page under Input rather than showing a mystery row.
|
||||||
`tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
|
`tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
|
||||||
this cannot regress silently.
|
this cannot regress silently.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Named actions
|
||||||
|
--
|
||||||
|
-- The one thing that makes it safe for settings.json to describe a shortcut.
|
||||||
|
--
|
||||||
|
-- Panama Settings lets a person invent a keyboard shortcut and assign a
|
||||||
|
-- four-finger gesture. Both are stored in the same user-editable JSON file the
|
||||||
|
-- rest of the desktop reads, and both have to end up as something the
|
||||||
|
-- compositor executes -- which is exactly the shape of every configuration
|
||||||
|
-- format that turned out to be a shell injection.
|
||||||
|
--
|
||||||
|
-- It is not one here, and this file is why. A stored action is DATA:
|
||||||
|
--
|
||||||
|
-- { kind = "app" | "shell" | "window", target = "<id>", label = "<text>" }
|
||||||
|
--
|
||||||
|
-- `kind` is an enum with three members. `target` is either a key of one of the
|
||||||
|
-- whitelist tables below -- whose values are literals written here, in Lua, by
|
||||||
|
-- a human -- or, for `app`, an identifier that has to match a character class
|
||||||
|
-- containing no shell metacharacter at all, and which is then quoted as a
|
||||||
|
-- single argv element for panama-launch rather than pasted into a command.
|
||||||
|
--
|
||||||
|
-- So the worst a hand-edited (or maliciously written) settings file can do is
|
||||||
|
-- pick a different entry from a list that is fixed at ship time, or launch an
|
||||||
|
-- application by id. It cannot introduce a command. There is no path from a
|
||||||
|
-- stored string to a new exec string; the table lookups are the only source of
|
||||||
|
-- one.
|
||||||
|
--
|
||||||
|
-- Everything invalid returns nil and the caller skips the bind or gesture --
|
||||||
|
-- the prefs.lua philosophy: never raise, never guess. A malformed entry costs
|
||||||
|
-- one shortcut, never the keymap and never the compositor.
|
||||||
|
--
|
||||||
|
-- Required by keybinds.lua (custom shortcuts) and input.lua (four-finger
|
||||||
|
-- gestures). services/Keybinds.qml's describeAction() is the QML mirror of the
|
||||||
|
-- vocabulary below; the two lists have to be edited together.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local actions = {}
|
||||||
|
|
||||||
|
-- ── Shell verbs ─────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Every entry is a command string written HERE. Nothing stored anywhere else
|
||||||
|
-- contributes a character to one; `target` only chooses which of these to use.
|
||||||
|
--
|
||||||
|
-- The `qs ipc call` targets and functions are the ones quickshell/shell.qml
|
||||||
|
-- actually registers -- an IpcHandler silently declines to register a function
|
||||||
|
-- it cannot type-check, so a verb invented here would be a shortcut that does
|
||||||
|
-- nothing. Checked against shell.qml, not remembered.
|
||||||
|
--
|
||||||
|
-- The three that are not IPC (`launcher`, `color-picker`, `lock`) are the same
|
||||||
|
-- literal commands the shipped binds in keybinds.lua use, for the same reason
|
||||||
|
-- they use them: they are the tools, not the shell.
|
||||||
|
local SHELL = {
|
||||||
|
["dnd-toggle"] = { label = "Do Not Disturb", command = "qs ipc call notifications dnd" },
|
||||||
|
["notifications"] = { label = "Notifications", command = "qs ipc call notifications toggle" },
|
||||||
|
["overview"] = { label = "Overview", command = "qs ipc call overview toggle" },
|
||||||
|
["launcher"] = { label = "Launcher", command = "vicinae toggle" },
|
||||||
|
["clipboard"] = { label = "Clipboard history", command = "qs ipc call clipboard toggle" },
|
||||||
|
["screenshot"] = { label = "Screenshot / record", command = "qs ipc call capture open" },
|
||||||
|
["screenshot-screen"] = { label = "Screenshot: whole screen", command = "qs ipc call capture screenNow" },
|
||||||
|
["screenshot-window"] = { label = "Screenshot: window", command = "qs ipc call capture windowNow" },
|
||||||
|
["screen-intelligence"]= { label = "Screen Intelligence", command = "qs ipc call screen-intelligence open" },
|
||||||
|
["color-picker"] = { label = "Color picker", command = "hyprpicker -a -f hex" },
|
||||||
|
["quick-settings"] = { label = "Quick settings", command = "qs ipc call quicksettings toggle" },
|
||||||
|
["settings"] = { label = "Settings", command = "qs ipc call settings toggle" },
|
||||||
|
["cheatsheet"] = { label = "Keyboard shortcuts", command = "qs ipc call cheatsheet toggle" },
|
||||||
|
["focus-session"] = { label = "Focus session", command = "qs ipc call focus reveal" },
|
||||||
|
["caffeine"] = { label = "Keep awake", command = "qs ipc call caffeine toggle" },
|
||||||
|
["night-light"] = { label = "Night Light", command = "qs ipc call night-light toggle" },
|
||||||
|
["activity"] = { label = "Activity", command = "qs ipc call activity toggle" },
|
||||||
|
["power-menu"] = { label = "Power menu", command = "qs ipc call powermenu toggle" },
|
||||||
|
["lock"] = { label = "Lock", command = "loginctl lock-session" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ── Window verbs ────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Builders rather than dispatchers, so nothing is constructed for a verb that
|
||||||
|
-- is never chosen, and so a gesture builds its dispatcher when the fingers
|
||||||
|
-- move rather than holding one from config time.
|
||||||
|
--
|
||||||
|
-- `workspace:N` is not in the table: it is ten entries that differ by a number,
|
||||||
|
-- and the number is validated as 1..10 in `window_action` below.
|
||||||
|
local WINDOW = {
|
||||||
|
["float-toggle"] = { label = "Toggle float", build = function() return hl.dsp.window.float({ action = "toggle" }) end },
|
||||||
|
["fullscreen"] = { label = "Fullscreen", build = function() return hl.dsp.window.fullscreen({ mode = "fullscreen" }) end },
|
||||||
|
["pin"] = { label = "Pin window", build = function() return hl.dsp.window.pin({ action = "toggle" }) end },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Published so a contract can read the vocabulary without parsing this file,
|
||||||
|
-- and so the ten workspace verbs have one definition rather than two.
|
||||||
|
actions.shell_verbs = SHELL
|
||||||
|
actions.window_verbs = WINDOW
|
||||||
|
actions.workspace_min = 1
|
||||||
|
actions.workspace_max = 10
|
||||||
|
|
||||||
|
-- ── Targets ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Letters, digits, and the four punctuation marks a desktop id actually uses.
|
||||||
|
-- Deliberately excludes every shell metacharacter, quote, slash and space, so
|
||||||
|
-- an id that passes cannot change the meaning of a command line even before it
|
||||||
|
-- is quoted -- the quoting below is the second lock on the same door.
|
||||||
|
local APP_TARGET = "^[A-Za-z0-9@._%-]+$"
|
||||||
|
|
||||||
|
local function valid_app_target(target)
|
||||||
|
return type(target) == "string"
|
||||||
|
and #target >= 1 and #target <= 128
|
||||||
|
and target:match(APP_TARGET) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Single-quoted for the shell, with the one escape single quotes need. Same
|
||||||
|
-- function keybinds.lua uses for the go-to patterns, and used here for the
|
||||||
|
-- same reason: the value reaches the command as one argument.
|
||||||
|
local function shell_quote(value)
|
||||||
|
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Google RE2 metacharacters, escaped so the id matches itself literally.
|
||||||
|
-- panama-launch takes a regular expression, and an unescaped "org.gnome.Files"
|
||||||
|
-- would also match "orgxgnomexFiles".
|
||||||
|
local function escape_regex(value)
|
||||||
|
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Resolution ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
|
||||||
|
|
||||||
|
-- The launch-or-focus path the shipped application keys use: raise the window
|
||||||
|
-- if it is already open, start it if it is not. The id is the class pattern
|
||||||
|
-- (anchored, escaped) and the thing to start; on Wayland an application's
|
||||||
|
-- desktop id and its window class are the same string often enough that this
|
||||||
|
-- is the right first guess, and the wrong guess costs a second window rather
|
||||||
|
-- than an error.
|
||||||
|
--
|
||||||
|
-- gtk-launch activates a desktop entry by id, which is what the applications
|
||||||
|
-- catalog in Settings offers -- a desktop id is not a binary and cannot be
|
||||||
|
-- exec'd directly.
|
||||||
|
local function app_action(target)
|
||||||
|
if not valid_app_target(target) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local launch_command = table.concat({
|
||||||
|
launcher_bin,
|
||||||
|
"--class", shell_quote("^" .. escape_regex(target) .. "$"),
|
||||||
|
"--", "gtk-launch", shell_quote(target),
|
||||||
|
}, " ")
|
||||||
|
return function() return hl.dsp.exec_cmd(launch_command) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shell_action(target)
|
||||||
|
if type(target) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local verb = SHELL[target]
|
||||||
|
if verb == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return function() return hl.dsp.exec_cmd(verb.command) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function window_action(target)
|
||||||
|
if type(target) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local verb = WINDOW[target]
|
||||||
|
if verb ~= nil then
|
||||||
|
return verb.build
|
||||||
|
end
|
||||||
|
|
||||||
|
local index = target:match("^workspace:(%d+)$")
|
||||||
|
if index == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local number = tonumber(index)
|
||||||
|
if number == nil or number < actions.workspace_min or number > actions.workspace_max then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return function() return hl.dsp.focus({ workspace = number }) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local KINDS = {
|
||||||
|
app = app_action,
|
||||||
|
shell = shell_action,
|
||||||
|
window = window_action,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- The builder for one stored entry, or nil when the entry is anything this
|
||||||
|
-- file does not recognise. Everything above funnels through here, so there is
|
||||||
|
-- exactly one place where a stored value becomes an action.
|
||||||
|
local function builder(entry)
|
||||||
|
if type(entry) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local resolve = KINDS[entry.kind]
|
||||||
|
if resolve == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return resolve(entry.target)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- For hl.bind: the dispatcher itself, or nil.
|
||||||
|
function actions.dispatcher(entry)
|
||||||
|
local build = builder(entry)
|
||||||
|
if build == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return build()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- For hl.gesture: a function, which is what a gesture action has to be when it
|
||||||
|
-- is not one of Hyprland's own built-in names ("workspace" and friends).
|
||||||
|
function actions.gesture(entry)
|
||||||
|
local build = builder(entry)
|
||||||
|
if build == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return function() hl.dispatch(build()) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- True when an entry resolves to something. Cheap enough to call twice; used
|
||||||
|
-- where the caller wants to check before it commits to emitting anything.
|
||||||
|
function actions.valid(entry)
|
||||||
|
return builder(entry) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return actions
|
||||||
@@ -57,8 +57,12 @@ hl.on("hyprland.start", function()
|
|||||||
hl.exec_cmd("systemctl --user start espanso.service")
|
hl.exec_cmd("systemctl --user start espanso.service")
|
||||||
|
|
||||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
-- A supervised unit rather than a compositor child, for the one property
|
||||||
hl.exec_cmd("quickshell --daemonize")
|
-- a child cannot have: if the shell crashes, systemd restarts it in two
|
||||||
|
-- seconds instead of leaving a desktop with no bar until someone knows
|
||||||
|
-- what to type. The unit ships in config/local/share/systemd/user and is
|
||||||
|
-- started per-session here like every other Panama unit.
|
||||||
|
hl.exec_cmd("systemctl --user start panama-quickshell.service")
|
||||||
|
|
||||||
-- Removable-media automounting. GNOME did this invisibly via gvfs+udisks;
|
-- Removable-media automounting. GNOME did this invisibly via gvfs+udisks;
|
||||||
-- outside GNOME something has to ask udisks to mount. No tray icon: the
|
-- outside GNOME something has to ask udisks to mount. No tray icon: the
|
||||||
@@ -87,8 +91,28 @@ hl.on("hyprland.start", function()
|
|||||||
-- session on its own. Starting it here as well would give you two trays.
|
-- session on its own. Starting it here as well would give you two trays.
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- Tear down what the start handler brought up.
|
||||||
|
--
|
||||||
|
-- This stopped `hyprland-session.target`, which does not exist on Fedora --
|
||||||
|
-- systemctl reports "Unit not found" and the handler achieves nothing. The
|
||||||
|
-- units started above are not children of the compositor, so nothing else
|
||||||
|
-- stopped them either: hyprpaper, hypridle, vicinae and the polkit agent kept
|
||||||
|
-- running after logout, and each one Requires=graphical-session.target, so
|
||||||
|
-- that target stayed active with no session behind it.
|
||||||
|
--
|
||||||
|
-- The next login then failed. Both uwsm and gnome-session refuse to start into
|
||||||
|
-- a session that is already running ("A compositor or graphical-session*
|
||||||
|
-- target is already active!"), so "Hyprland (uwsm-managed)" and GNOME both
|
||||||
|
-- bounced straight back to the login screen while plain Hyprland -- which
|
||||||
|
-- makes no such check -- kept working. The desktop appeared to be the only one
|
||||||
|
-- that functioned, when it was the one leaving the mess.
|
||||||
|
--
|
||||||
|
-- Stopping graphical-session.target is enough on its own: every unit above is
|
||||||
|
-- PartOf= it, so they come down with it, and it carries StopWhenUnneeded=yes.
|
||||||
|
-- It is stopped rather than the units listed individually so that a unit added
|
||||||
|
-- to the start handler later cannot be forgotten here.
|
||||||
hl.on("hyprland.shutdown", function()
|
hl.on("hyprland.shutdown", function()
|
||||||
hl.exec_cmd("systemctl --user stop hyprland-session.target")
|
hl.exec_cmd("systemctl --user stop graphical-session.target")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -17,20 +17,16 @@
|
|||||||
# frame time for nothing.
|
# frame time for nothing.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# One block for every output: per-monitor wallpaper is applied at runtime by
|
||||||
|
# the shell (services/Wallpaper.qml) over IPC, so a per-connector block here
|
||||||
|
# would just be one machine's connector name in everyone's config.
|
||||||
wallpaper {
|
wallpaper {
|
||||||
monitor = DP-2
|
monitor =
|
||||||
path = ~/Pictures/Wallpapers/faroe_islands.jpg
|
path = ~/Pictures/Wallpapers/faroe_islands.jpg
|
||||||
# cover = fill the output, cropping overflow. Matches the "zoom" picture
|
# cover = fill the output, cropping overflow. Matches the "zoom" picture
|
||||||
# option this setup used under GNOME.
|
# option this setup used under GNOME.
|
||||||
fit_mode = cover
|
fit_mode = cover
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fallback for any output without its own block above.
|
|
||||||
wallpaper {
|
|
||||||
monitor =
|
|
||||||
path = ~/Pictures/Wallpapers/faroe_islands.jpg
|
|
||||||
fit_mode = cover
|
|
||||||
}
|
|
||||||
|
|
||||||
ipc = on
|
ipc = on
|
||||||
splash = false
|
splash = false
|
||||||
|
|||||||
+106
-2
@@ -6,6 +6,7 @@
|
|||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
local prefs = require("prefs")
|
local prefs = require("prefs")
|
||||||
|
local actions = require("actions")
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
input = {
|
input = {
|
||||||
@@ -35,12 +36,85 @@ hl.config({
|
|||||||
-- Still focus-follows-pointer, just less twitchy.
|
-- Still focus-follows-pointer, just less twitchy.
|
||||||
mouse_refocus = false,
|
mouse_refocus = false,
|
||||||
|
|
||||||
-- Flat pointer response, no acceleration. Matters for gaming.
|
-- 0 = NEXT, the compositor's own default: focus goes to the next window
|
||||||
|
-- in the layout order. `hyprctl descriptions` publishes
|
||||||
|
-- map: [{"mru":2},{"cursor":1},{"next":0}], and Settings offers all
|
||||||
|
-- three; 0 is repeated here so nothing changes on a machine with no
|
||||||
|
-- settings file.
|
||||||
|
focus_on_close = prefs.getInt("focusOnClose", 0),
|
||||||
|
|
||||||
|
-- Flat pointer response by default, no acceleration. Matters for
|
||||||
|
-- gaming; Settings offers adaptive for people who want it back.
|
||||||
sensitivity = prefs.get("pointerSensitivity", 0),
|
sensitivity = prefs.get("pointerSensitivity", 0),
|
||||||
accel_profile = "flat",
|
accel_profile = prefs.get("accelProfile", "flat"),
|
||||||
|
|
||||||
|
natural_scroll = prefs.get("naturalScroll", false),
|
||||||
|
scroll_factor = prefs.get("scrollFactor", 1.0),
|
||||||
|
left_handed = prefs.get("leftHanded", false),
|
||||||
|
|
||||||
|
-- Empty is a value, not an omission: it is what `hyprctl getoption`
|
||||||
|
-- reports as "[[EMPTY]]" before anything writes the option, and it means
|
||||||
|
-- "let libinput pick per device" -- two fingers on a touchpad, the wheel
|
||||||
|
-- on a mouse. Writing it back explicitly is the same branch a stock
|
||||||
|
-- Hyprland takes, and it keeps the setting reversible: without an empty
|
||||||
|
-- choice in the schema there would be no way back from a scroll method
|
||||||
|
-- once one was picked. Settings offers 2fg / edge / on_button_down /
|
||||||
|
-- no_scroll alongside it, the four words the option's own description
|
||||||
|
-- names (it publishes no map).
|
||||||
|
scroll_method = prefs.get("scrollMethod", ""),
|
||||||
|
|
||||||
|
-- Only consulted while scroll_method is on_button_down. 0 means the
|
||||||
|
-- device's own middle button.
|
||||||
|
scroll_button = prefs.get("scrollButton", 0),
|
||||||
|
|
||||||
-- Clicking a floating window raises and focuses it.
|
-- Clicking a floating window raises and focuses it.
|
||||||
float_switch_override_focus = 2,
|
float_switch_override_focus = 2,
|
||||||
|
|
||||||
|
-- Every touchpad preference Settings offers is read here as well as
|
||||||
|
-- applied live: the live half (hyprctl eval) reaches a running
|
||||||
|
-- compositor, and THIS half is what survives `hyprctl reload` and the
|
||||||
|
-- gap before the shell starts. A schema `hypr:` entry without a
|
||||||
|
-- prefs.get() here is a control that quietly reverts -- the whole
|
||||||
|
-- Mouse & Touchpad page once had exactly that bug. Defaults must
|
||||||
|
-- match PreferenceSchema's; the hypr-prefs-contract checks both.
|
||||||
|
touchpad = {
|
||||||
|
tap_to_click = prefs.get("touchpadTapToClick", true),
|
||||||
|
natural_scroll = prefs.get("touchpadNaturalScroll", true),
|
||||||
|
disable_while_typing = prefs.get("touchpadDisableWhileTyping", true),
|
||||||
|
scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
|
||||||
|
drag_lock = prefs.getInt("touchpadDragLock", 0),
|
||||||
|
middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false),
|
||||||
|
clickfinger_behavior = prefs.get("touchpadClickfinger", false),
|
||||||
|
|
||||||
|
-- Underscores here, hyphens in the option name getoption answers to
|
||||||
|
-- (input:touchpad:tap-and-drag) -- the same split tap_to_click has
|
||||||
|
-- at the top of this table. Hyprland's own default is true; it is
|
||||||
|
-- repeated rather than omitted so the schema, the Lua, and the
|
||||||
|
-- compositor all state the same value.
|
||||||
|
tap_and_drag = prefs.get("touchpadTapAndDrag", true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Pointer BEHAVIOUR, as opposed to pointer appearance: how the cursor reacts
|
||||||
|
-- to typing and to workspace switches. The cursor's looks -- theme, size,
|
||||||
|
-- hardware cursors, the inactivity fade -- are a separate cursor table in
|
||||||
|
-- looks.lua. hl.config calls are additive per option, so the two tables
|
||||||
|
-- coexist; they are split by what a person would go looking for, and these
|
||||||
|
-- two appear on Settings' Mouse & Touchpad page rather than in Appearance.
|
||||||
|
cursor = {
|
||||||
|
hide_on_key_press = prefs.get("cursorHideWhileTyping", false),
|
||||||
|
|
||||||
|
-- An integer with three states, written from a switch: disable = 0,
|
||||||
|
-- enable = 1, force = 2. Settings offers the first two, so getInt is
|
||||||
|
-- what bridges a stored boolean to the number Hyprland wants -- the same
|
||||||
|
-- pairing render.cm_auto_hdr uses in looks.lua.
|
||||||
|
warp_on_change_workspace = prefs.getInt("cursorWarpOnWorkspaceChange", 0),
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Tuning for the three-finger gestures registered below.
|
||||||
|
gestures = {
|
||||||
|
workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
|
||||||
|
workspace_swipe_invert = prefs.get("swipeInvert", true),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,4 +142,34 @@ hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
|
|||||||
hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
|
hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
|
||||||
hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
|
hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
|
||||||
|
|
||||||
|
-- ── Four-finger gestures ────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- The three above are the desktop's, fixed. These four are the user's: each
|
||||||
|
-- holds a named action from settings.json, or {} for unassigned, and the
|
||||||
|
-- vocabulary is exactly the one custom shortcuts use -- actions.lua resolves
|
||||||
|
-- both, through the same whitelist tables, so a gesture can no more introduce
|
||||||
|
-- a command than a keybind can.
|
||||||
|
--
|
||||||
|
-- Nothing is emitted for an unassigned direction. That matters more here than
|
||||||
|
-- it looks: a registration is read at config time and there is no way to
|
||||||
|
-- remove one afterwards, so emitting a no-op gesture for every direction would
|
||||||
|
-- consume the four-finger swipes permanently, including for whatever the
|
||||||
|
-- compositor might do with them later.
|
||||||
|
--
|
||||||
|
-- Four rather than three because three is spoken for, and because four fingers
|
||||||
|
-- is the largest number of them a touchpad this size can tell apart.
|
||||||
|
local custom_gestures = {
|
||||||
|
{ pref = "gestureFourUp", direction = "up" },
|
||||||
|
{ pref = "gestureFourDown", direction = "down" },
|
||||||
|
{ pref = "gestureFourLeft", direction = "left" },
|
||||||
|
{ pref = "gestureFourRight", direction = "right" },
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, gesture in ipairs(custom_gestures) do
|
||||||
|
local action = actions.gesture(prefs.get(gesture.pref, {}))
|
||||||
|
if action ~= nil then
|
||||||
|
hl.gesture({ fingers = 4, direction = gesture.direction, action = action })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
local prefs = require("prefs")
|
local prefs = require("prefs")
|
||||||
|
local actions = require("actions")
|
||||||
|
|
||||||
local mod = "SUPER"
|
local mod = "SUPER"
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ local osd = function(action)
|
|||||||
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local lid = function(action)
|
||||||
|
return "$HOME/.config/quickshell/scripts/panama-lid " .. action
|
||||||
|
end
|
||||||
|
|
||||||
local dictate = function(action)
|
local dictate = function(action)
|
||||||
return "$HOME/.config/quickshell/scripts/panama-dictate " .. action
|
return "$HOME/.config/quickshell/scripts/panama-dictate " .. action
|
||||||
end
|
end
|
||||||
@@ -258,6 +263,44 @@ bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
|
|||||||
-- Color picker: copies the hex under the cursor to the clipboard.
|
-- 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" })
|
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
|
||||||
|
|
||||||
|
-- ── Magnifier ───────────────────────────────────────────────────────────────
|
||||||
|
category("Shell")
|
||||||
|
--
|
||||||
|
-- The chords are NOT the obvious SUPER+=/-/0. SUPER+equal is already "Reset
|
||||||
|
-- split" (Window management, below), and taking a daily tiling key away to
|
||||||
|
-- give the magnifier the prettiest chord on the keyboard is the wrong trade.
|
||||||
|
--
|
||||||
|
-- SUPER+ALT is where they went instead, which is also where GNOME's magnifier
|
||||||
|
-- lives: gsettings' magnifier-zoom-in / magnifier-zoom-out ship as
|
||||||
|
-- <Alt><Super>= and <Alt><Super>-, so this is the shortcut the machine this
|
||||||
|
-- desktop replaced already had. SUPER+ALT+0 -- free; the workspace digits are
|
||||||
|
-- plain ALT -- resets to 1.00 ×, reading as "back to zero magnification".
|
||||||
|
--
|
||||||
|
-- These go THROUGH the shell rather than calling `hyprctl keyword
|
||||||
|
-- cursor:zoom_factor` directly. Setting the compositor option behind Panama's
|
||||||
|
-- back would leave the stored preference and the Magnifier slider claiming a
|
||||||
|
-- magnification that is not the one on screen; the IPC call commits through
|
||||||
|
-- the same verified-preference path the slider uses, so the store, the
|
||||||
|
-- compositor and the settings page can never disagree. It also posts the OSD,
|
||||||
|
-- which is the only way to see what the factor now is with the pointer
|
||||||
|
-- somewhere else entirely.
|
||||||
|
--
|
||||||
|
-- Not `repeating`: the step is multiplicative (×1.25), so a held key repeating
|
||||||
|
-- at the keyboard rate would arrive at the 5.00 × ceiling in about a tenth of
|
||||||
|
-- a second. One press, one step.
|
||||||
|
--
|
||||||
|
-- Written as literal chords rather than `mod .. " + ALT + ..."` (as
|
||||||
|
-- "SUPER + Backspace" already is, above) because these three are the most
|
||||||
|
-- collision-prone binds in the file -- they were placed around one -- and a
|
||||||
|
-- literal is the form both the duplicate-chord check and the settings page's
|
||||||
|
-- chord display can actually read.
|
||||||
|
bind("SUPER + ALT + equal", hl.dsp.exec_cmd(qs("accessibility", "zoom in")),
|
||||||
|
{ description = "Zoom in" })
|
||||||
|
bind("SUPER + ALT + minus", hl.dsp.exec_cmd(qs("accessibility", "zoom out")),
|
||||||
|
{ description = "Zoom out" })
|
||||||
|
bind("SUPER + ALT + 0", hl.dsp.exec_cmd(qs("accessibility", "zoom reset")),
|
||||||
|
{ description = "Reset zoom" })
|
||||||
|
|
||||||
-- ── Window management ───────────────────────────────────────────────────────
|
-- ── Window management ───────────────────────────────────────────────────────
|
||||||
category("Windows")
|
category("Windows")
|
||||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||||
@@ -458,6 +501,71 @@ 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("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" })
|
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
|
||||||
|
|
||||||
|
-- Airplane mode. The Framework's F10 emits exactly this keysym, and for a
|
||||||
|
-- while it emitted it into silence. panama-osd owns the toggle so the OSD
|
||||||
|
-- can say which way it went.
|
||||||
|
bind("XF86RFKill", hl.dsp.exec_cmd(osd("airplane toggle")), { locked = true, description = "Airplane mode" })
|
||||||
|
|
||||||
|
-- F9 on the same row. GNOME shows a display-switching OSD here; until
|
||||||
|
-- mirroring exists (DESKTOP-PARITY gap), the honest action is the page where
|
||||||
|
-- displays are actually arranged.
|
||||||
|
bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" })
|
||||||
|
|
||||||
|
-- ── The power button ────────────────────────────────────────────────────────
|
||||||
|
category("Media & hardware")
|
||||||
|
--
|
||||||
|
-- logind is told to ignore the power key (config/copy ships the drop-in) so a
|
||||||
|
-- stray press is a question, not an instant poweroff. That makes what the
|
||||||
|
-- question IS Panama's to choose, and `powerButtonAction` is where the choice
|
||||||
|
-- is recorded. Until the next boot after that drop-in lands, logind still acts
|
||||||
|
-- on the key; this bind costs nothing extra then.
|
||||||
|
--
|
||||||
|
-- The branch runs ON EVERY PRESS rather than here at config time.
|
||||||
|
--
|
||||||
|
-- A config-time branch would be shorter -- `prefs.get` and four `if`s -- and it
|
||||||
|
-- would also make this the one control on the Power page that does nothing
|
||||||
|
-- until the compositor is reloaded. Every other setting in Panama applies as
|
||||||
|
-- you change it, and a power button that ignores what the settings app says it
|
||||||
|
-- does is a worse thing to ship than a long command string. So the bind is a
|
||||||
|
-- `case` over what the settings file says at the moment the key goes down.
|
||||||
|
--
|
||||||
|
-- Everything that can go wrong lands on the shipped default: no jq, no file, a
|
||||||
|
-- truncated file, or a value nobody recognises all fall through to `*)` and
|
||||||
|
-- open the menu. The failure direction is "the power button opens a menu",
|
||||||
|
-- never "the power button does something you did not ask for".
|
||||||
|
--
|
||||||
|
-- Powering off goes THROUGH the menu with Power Off pre-armed rather than
|
||||||
|
-- calling `systemctl poweroff` here. The menu's two-press confirm is what
|
||||||
|
-- stands between a pocketed key and an unsaved afternoon, and a direct
|
||||||
|
-- poweroff would quietly throw it away -- so a person who picks "Powers off"
|
||||||
|
-- gets a fast poweroff, not an unguarded one.
|
||||||
|
local power_button = {
|
||||||
|
menu = qs("powermenu", "toggle"),
|
||||||
|
suspend = "systemctl suspend",
|
||||||
|
poweroff = qs("powermenu", "open") .. " poweroff",
|
||||||
|
nothing = ":",
|
||||||
|
}
|
||||||
|
|
||||||
|
local power_button_command = table.concat({
|
||||||
|
[[case "$(jq -r '.powerButtonAction // empty' "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" 2>/dev/null)" in]],
|
||||||
|
"suspend) " .. power_button.suspend .. " ;;",
|
||||||
|
"poweroff) " .. power_button.poweroff .. " ;;",
|
||||||
|
"nothing) " .. power_button.nothing .. " ;;",
|
||||||
|
"*) " .. power_button.menu .. " ;;",
|
||||||
|
"esac",
|
||||||
|
}, " ")
|
||||||
|
|
||||||
|
bind("XF86PowerOff", hl.dsp.exec_cmd(power_button_command),
|
||||||
|
{ locked = true, description = "Power button" })
|
||||||
|
|
||||||
|
-- The lid, as a switch rather than a key. Closing a docked lid turns the
|
||||||
|
-- internal panel off so nothing renders inside a closed shell and no
|
||||||
|
-- workspace strands on an invisible output; opening it turns the panel back
|
||||||
|
-- on. panama-lid owns the decision -- undocked machines suspend via logind
|
||||||
|
-- before this matters, and the guard's inhibitor handles staying awake.
|
||||||
|
bind("switch:on:Lid Switch", hl.dsp.exec_cmd(lid("close")), { locked = true, description = "Lid closed" })
|
||||||
|
bind("switch:off:Lid Switch", hl.dsp.exec_cmd(lid("open")), { locked = true, description = "Lid opened" })
|
||||||
|
|
||||||
category("Applications")
|
category("Applications")
|
||||||
-- Hardware keys GNOME mapped that have obvious equivalents.
|
-- Hardware keys GNOME mapped that have obvious equivalents.
|
||||||
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||||
@@ -467,6 +575,52 @@ bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
|||||||
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||||
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||||
|
|
||||||
|
-- ── Custom shortcuts ────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Shortcuts the user invented, from `customBinds` in settings.json. Each entry
|
||||||
|
-- is { chord, kind, target, label } -- data, never a command. actions.lua turns
|
||||||
|
-- the kind/target pair into a dispatcher through whitelist tables; an entry it
|
||||||
|
-- does not recognise resolves to nil and is silently not emitted.
|
||||||
|
--
|
||||||
|
-- Emitted LAST, and through hl.bind rather than the `bind` wrapper above. Two
|
||||||
|
-- separate reasons, both about keeping the two rebinding mechanisms apart:
|
||||||
|
--
|
||||||
|
-- * `keybindOverrides` is keyed by a SHIPPED chord. A custom bind has no
|
||||||
|
-- shipped chord -- it is rebound by rewriting its own entry -- so putting
|
||||||
|
-- one through `bind` would let an override for some shipped key silently
|
||||||
|
-- move a custom one that happened to share a chord.
|
||||||
|
-- * last means a custom chord that collides with a shipped one loses, which
|
||||||
|
-- is checked explicitly below rather than left to Hyprland's ordering.
|
||||||
|
--
|
||||||
|
-- Settings prevents a collision upstream; this is the second lock, because the
|
||||||
|
-- file is hand-editable and losing a shipped key to a typo is not acceptable.
|
||||||
|
category("Custom")
|
||||||
|
|
||||||
|
local function custom_bind(chord, dispatcher, label)
|
||||||
|
categories[chord] = current_category
|
||||||
|
return hl.bind(chord, dispatcher, { description = label })
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, entry in ipairs(prefs.get("customBinds", {})) do
|
||||||
|
if type(entry) == "table" then
|
||||||
|
local chord = entry.chord
|
||||||
|
local label = entry.label
|
||||||
|
|
||||||
|
-- A description is not decoration: keybinds-contract fails a build
|
||||||
|
-- with a description-less bind, and the cheatsheet and Shortcuts page
|
||||||
|
-- both list what they find. A nameless shortcut is unfindable.
|
||||||
|
if valid_chord(chord)
|
||||||
|
and type(label) == "string" and label ~= ""
|
||||||
|
and categories[chord] == nil
|
||||||
|
then
|
||||||
|
local dispatcher = actions.dispatcher(entry)
|
||||||
|
if dispatcher ~= nil then
|
||||||
|
custom_bind(chord, dispatcher, label)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
write_categories()
|
write_categories()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -37,6 +37,53 @@ local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
|
|||||||
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
|
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
|
||||||
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
|
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
|
||||||
|
|
||||||
|
-- ── Color filters ───────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Grayscale and three color-blindness corrections, as end-of-pipe screen
|
||||||
|
-- shaders. Hyprland composites the desktop and then runs one fragment shader
|
||||||
|
-- over the result, so a filter here covers every window, the shell, the cursor
|
||||||
|
-- and video alike -- which is the only way a filter is honest.
|
||||||
|
--
|
||||||
|
-- The PREFERENCE is the enum, not the path. That split is deliberate: storing
|
||||||
|
-- the path would put a filesystem location a person can edit into the value
|
||||||
|
-- that becomes `decoration:screen_shader`, and it would make the stored value
|
||||||
|
-- disagree with what hyprctl reports back (which is the path), failing the
|
||||||
|
-- schema shape and write-sweep contracts. So PreferenceSchema's colorFilter
|
||||||
|
-- entry carries no `hypr:` block, and the enum→path mapping is written twice
|
||||||
|
-- on purpose: here, for reloads and for the moment before the shell starts,
|
||||||
|
-- and in services/SystemSettings.qml's applyColorFilter for the live apply.
|
||||||
|
-- The two lists have to be edited together.
|
||||||
|
--
|
||||||
|
-- "none" and anything unrecognised both produce the empty string, which is
|
||||||
|
-- what Hyprland reads as "no shader" -- and is the value it needs to be given
|
||||||
|
-- to turn one OFF, since there is no way to unset the option.
|
||||||
|
local shaderDir = (function()
|
||||||
|
local configHome = os.getenv("XDG_CONFIG_HOME")
|
||||||
|
if configHome == nil or configHome == "" then
|
||||||
|
local home = os.getenv("HOME")
|
||||||
|
if home == nil or home == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
configHome = home .. "/.config"
|
||||||
|
end
|
||||||
|
return configHome .. "/hypr/shaders"
|
||||||
|
end)()
|
||||||
|
|
||||||
|
local colorFilters = {
|
||||||
|
grayscale = "grayscale.frag",
|
||||||
|
protanopia = "protanopia.frag",
|
||||||
|
deuteranopia = "deuteranopia.frag",
|
||||||
|
tritanopia = "tritanopia.frag",
|
||||||
|
}
|
||||||
|
|
||||||
|
local colorFilterShader = ""
|
||||||
|
do
|
||||||
|
local file = colorFilters[prefs.get("colorFilter", "none")]
|
||||||
|
if file ~= nil and shaderDir ~= nil then
|
||||||
|
colorFilterShader = shaderDir .. "/" .. file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
general = {
|
general = {
|
||||||
gaps_in = prefs.get("gapsIn", 5),
|
gaps_in = prefs.get("gapsIn", 5),
|
||||||
@@ -68,10 +115,10 @@ hl.config({
|
|||||||
-- Harmless on its own; tearing only happens where a rule opts in.
|
-- Harmless on its own; tearing only happens where a rule opts in.
|
||||||
allow_tearing = true,
|
allow_tearing = true,
|
||||||
|
|
||||||
layout = "dwindle",
|
layout = prefs.get("windowLayout", "dwindle"),
|
||||||
|
|
||||||
snap = {
|
snap = {
|
||||||
enabled = true,
|
enabled = prefs.get("windowSnapping", true),
|
||||||
window_gap = prefs.getInt("snapWindowGap", 10),
|
window_gap = prefs.getInt("snapWindowGap", 10),
|
||||||
monitor_gap = prefs.getInt("snapMonitorGap", 10),
|
monitor_gap = prefs.getInt("snapMonitorGap", 10),
|
||||||
respect_gaps = prefs.get("snapRespectGaps", false),
|
respect_gaps = prefs.get("snapRespectGaps", false),
|
||||||
@@ -88,6 +135,10 @@ hl.config({
|
|||||||
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
|
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
|
||||||
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
||||||
|
|
||||||
|
-- Accessibility: dim every window but the focused one.
|
||||||
|
dim_inactive = prefs.get("dimInactive", false),
|
||||||
|
dim_strength = prefs.get("dimStrength", 0.5),
|
||||||
|
|
||||||
blur = {
|
blur = {
|
||||||
enabled = prefs.get("blurEnabled", true),
|
enabled = prefs.get("blurEnabled", true),
|
||||||
size = prefs.get("blurSize", 8),
|
size = prefs.get("blurSize", 8),
|
||||||
@@ -141,6 +192,11 @@ hl.config({
|
|||||||
|
|
||||||
-- Off: costs real frame time and reads as smeary on a 60Hz panel.
|
-- Off: costs real frame time and reads as smeary on a 60Hz panel.
|
||||||
motion_blur = { enabled = false },
|
motion_blur = { enabled = false },
|
||||||
|
|
||||||
|
-- The accessibility color filter, resolved above. Empty when off, and
|
||||||
|
-- empty costs nothing: Hyprland skips the pass entirely rather than
|
||||||
|
-- running an identity shader.
|
||||||
|
screen_shader = colorFilterShader,
|
||||||
},
|
},
|
||||||
|
|
||||||
animations = { enabled = prefs.get("animationsEnabled", true) },
|
animations = { enabled = prefs.get("animationsEnabled", true) },
|
||||||
@@ -150,6 +206,7 @@ hl.config({
|
|||||||
-- to how the Forge extension behaved on GNOME.
|
-- to how the Forge extension behaved on GNOME.
|
||||||
preserve_split = prefs.get("preserveSplit", true),
|
preserve_split = prefs.get("preserveSplit", true),
|
||||||
smart_resizing = true,
|
smart_resizing = true,
|
||||||
|
force_split = prefs.getInt("forceSplit", 0),
|
||||||
},
|
},
|
||||||
|
|
||||||
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
|
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
|
||||||
@@ -193,7 +250,11 @@ hl.config({
|
|||||||
allow_session_lock_restore = true,
|
allow_session_lock_restore = true,
|
||||||
|
|
||||||
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
|
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
|
||||||
focus_on_activate = false,
|
focus_on_activate = prefs.get("focusOnActivate", false),
|
||||||
|
|
||||||
|
-- Whether pointing at another monitor is enough to move focus there,
|
||||||
|
-- or it takes a click. GNOME moves on pointer; both are offered.
|
||||||
|
mouse_move_focuses_monitor = prefs.get("mouseMoveFocusesMonitor", true),
|
||||||
|
|
||||||
-- Window swallowing: a terminal hides itself while a graphical
|
-- Window swallowing: a terminal hides itself while a graphical
|
||||||
-- application launched from it is open, and comes back when that
|
-- application launched from it is open, and comes back when that
|
||||||
@@ -237,6 +298,10 @@ hl.config({
|
|||||||
|
|
||||||
-- Fade the cursor out after 4s of no movement, like GNOME does.
|
-- Fade the cursor out after 4s of no movement, like GNOME does.
|
||||||
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
|
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
|
||||||
|
|
||||||
|
-- The accessibility magnifier. 1.0 = off.
|
||||||
|
zoom_factor = prefs.get("magnifierFactor", 1.0),
|
||||||
|
zoom_rigid = prefs.get("magnifierRigid", false),
|
||||||
},
|
},
|
||||||
|
|
||||||
ecosystem = {
|
ecosystem = {
|
||||||
|
|||||||
+127
-26
@@ -13,8 +13,9 @@
|
|||||||
-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to
|
-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to
|
||||||
-- fullscreen games only, via render.cm_auto_hdr in looks.lua.
|
-- fullscreen games only, via render.cm_auto_hdr in looks.lua.
|
||||||
--
|
--
|
||||||
-- To try full-time HDR anyway, set cm = "hdr" below (or override it in
|
-- To try full-time HDR anyway, set shipped_cm = "hdr" below (or pick HDR for
|
||||||
-- overrides.lua) and read the notes in that file first.
|
-- this display in Settings, which saves it as the display's colorProfile and
|
||||||
|
-- wins over the shipped value) and read the notes in overrides.lua first.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
local prefs = require("prefs")
|
local prefs = require("prefs")
|
||||||
@@ -23,12 +24,21 @@ local prefs = require("prefs")
|
|||||||
-- { ["DP-2"] = {
|
-- { ["DP-2"] = {
|
||||||
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
||||||
-- x = 0, y = 0, primary = true,
|
-- x = 0, y = 0, primary = true,
|
||||||
|
-- vrrMode = -1, colorProfile = "auto", bitdepth = 10,
|
||||||
|
-- sdrBrightness = 1, sdrSaturation = 1, mirrorOf = "",
|
||||||
-- } }
|
-- } }
|
||||||
--
|
--
|
||||||
-- Only mode, scale, and transform are read. Color management and bit depth
|
-- The fields past `primary` are optional: records written before they existed
|
||||||
-- stay here, because those are the settings with a documented reason attached
|
-- carry none of them, and the shipped values below stand instead. That is the
|
||||||
-- (see the header) rather than preferences, and a settings page has no way to
|
-- relationship in both directions -- what is written here is what a saved
|
||||||
-- explain the screencopy tradeoff at the moment you would be changing it.
|
-- record inherits, and what Settings saves is what overrides it, so the two
|
||||||
|
-- stop fighting over the same monitor rule.
|
||||||
|
--
|
||||||
|
-- Geometry and colour fail differently on purpose. A half-written position is
|
||||||
|
-- refused outright (below), because guessing one can strand an output where
|
||||||
|
-- nothing can reach it. An unreadable colour, VRR or mirror value is dropped
|
||||||
|
-- on its own and the shipped default stands: the worst it costs is a wrong
|
||||||
|
-- shade, and taking the whole record down with it would cost the arrangement.
|
||||||
local displays = prefs.get("displays", {})
|
local displays = prefs.get("displays", {})
|
||||||
if type(displays) ~= "table" then
|
if type(displays) ~= "table" then
|
||||||
displays = {}
|
displays = {}
|
||||||
@@ -125,19 +135,102 @@ local function display_position(entry, fallback)
|
|||||||
return fallback
|
return fallback
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local color_profiles = { auto = true, srgb = true, wide = true, hdr = true }
|
||||||
|
|
||||||
|
local function color_profile(entry, fallback)
|
||||||
|
local value = entry ~= nil and entry.colorProfile or nil
|
||||||
|
if type(value) == "string" and color_profiles[value] then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bitdepth_value(entry, fallback)
|
||||||
|
local value = entry ~= nil and entry.bitdepth or nil
|
||||||
|
if value == 8 or value == 10 then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -1 means the display follows the global misc.vrr policy, which is said by
|
||||||
|
-- leaving the key out. 3 is the global policy's own value and not a per-display
|
||||||
|
-- choice, so it is not accepted here either.
|
||||||
|
local function vrr_value(entry)
|
||||||
|
local value = entry ~= nil and entry.vrrMode or nil
|
||||||
|
if type(value) ~= "number" or value ~= math.floor(value)
|
||||||
|
or value < 0 or value > 2 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Neutral is 1.0, and the neutral value is left out rather than written: a rule
|
||||||
|
-- that names it pins the display to it, which is not the same as leaving the
|
||||||
|
-- trim alone.
|
||||||
|
local function sdr_value(value, minimum, maximum)
|
||||||
|
if type(value) ~= "number" or value ~= value
|
||||||
|
or value < minimum or value > maximum
|
||||||
|
or math.abs(value - 1) < 0.001 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A mirror needs a target that is not itself and not another mirror -- Hyprland
|
||||||
|
-- has no chain to follow -- and the primary may not mirror at all, since the
|
||||||
|
-- arrangement is anchored on it.
|
||||||
|
local function mirror_value(entry, output)
|
||||||
|
local value = entry ~= nil and entry.mirrorOf or nil
|
||||||
|
if type(value) ~= "string" or value == "" or value == output
|
||||||
|
or value:match("^[%w_.-]+$") == nil
|
||||||
|
or entry.primary == true then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local target = displays[value]
|
||||||
|
if type(target) == "table" and type(target.mirrorOf) == "string"
|
||||||
|
and target.mirrorOf ~= "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Colour, VRR and mirroring layered onto a rule that already carries
|
||||||
|
-- mode/position/scale/transform. A monitor rule replaces the previous rule for
|
||||||
|
-- that output whole, so the shipped defaults are passed in here rather than
|
||||||
|
-- written in a rule of their own. `connector` is the output name the record was
|
||||||
|
-- saved under, which is not always the rule's own output: the Kuycon rule
|
||||||
|
-- matches by description.
|
||||||
|
local function with_display_fields(rule, entry, connector, default_bitdepth, default_cm)
|
||||||
|
rule.bitdepth = bitdepth_value(entry, default_bitdepth)
|
||||||
|
rule.cm = color_profile(entry, default_cm)
|
||||||
|
rule.vrr = vrr_value(entry)
|
||||||
|
rule.sdrbrightness = entry ~= nil and sdr_value(entry.sdrBrightness, 0.8, 2.0) or nil
|
||||||
|
rule.sdrsaturation = entry ~= nil and sdr_value(entry.sdrSaturation, 0.8, 1.2) or nil
|
||||||
|
|
||||||
|
-- A mirror shows its target's picture in its target's place, so the saved
|
||||||
|
-- position is not the compositor's to honour or ours to ask for.
|
||||||
|
local mirror = mirror_value(entry, connector)
|
||||||
|
if mirror ~= nil then
|
||||||
|
rule.mirror = mirror
|
||||||
|
rule.position = "auto"
|
||||||
|
end
|
||||||
|
return rule
|
||||||
|
end
|
||||||
|
|
||||||
-- Every connected output uses the same validated per-output store. Automatic
|
-- Every connected output uses the same validated per-output store. Automatic
|
||||||
-- placement and the compositor's normal color policy unless the entry says
|
-- placement and the compositor's normal color policy unless the entry says
|
||||||
-- otherwise.
|
-- otherwise.
|
||||||
for output, _ in pairs(displays) do
|
for output, _ in pairs(displays) do
|
||||||
local entry = display_entry(output)
|
local entry = display_entry(output)
|
||||||
if entry ~= nil then
|
if entry ~= nil then
|
||||||
hl.monitor({
|
hl.monitor(with_display_fields({
|
||||||
output = output,
|
output = output,
|
||||||
mode = entry.mode,
|
mode = entry.mode,
|
||||||
position = display_position(entry, "auto"),
|
position = display_position(entry, "auto"),
|
||||||
scale = entry.scale,
|
scale = entry.scale,
|
||||||
transform = entry.transform,
|
transform = entry.transform,
|
||||||
})
|
}, entry, output, nil, nil))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -150,22 +243,23 @@ end
|
|||||||
local shipped_mode = "4500x3000@60"
|
local shipped_mode = "4500x3000@60"
|
||||||
local shipped_scale = 1.5
|
local shipped_scale = 1.5
|
||||||
local shipped_transform = 0
|
local shipped_transform = 0
|
||||||
|
|
||||||
|
-- 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 to 8 first.
|
||||||
|
local shipped_bitdepth = 10
|
||||||
|
|
||||||
|
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
||||||
|
local shipped_cm = "auto"
|
||||||
|
|
||||||
local kuycon = display_entry("DP-2")
|
local kuycon = display_entry("DP-2")
|
||||||
hl.monitor({
|
hl.monitor(with_display_fields({
|
||||||
output = "desc:GVT Kuycon P20",
|
output = "desc:GVT Kuycon P20",
|
||||||
mode = kuycon and kuycon.mode or shipped_mode,
|
mode = kuycon and kuycon.mode or shipped_mode,
|
||||||
position = display_position(kuycon, "0x0"),
|
position = display_position(kuycon, "0x0"),
|
||||||
scale = kuycon and kuycon.scale or shipped_scale,
|
scale = kuycon and kuycon.scale or shipped_scale,
|
||||||
transform = kuycon and kuycon.transform or shipped_transform,
|
transform = kuycon and kuycon.transform or shipped_transform,
|
||||||
|
}, kuycon, "DP-2", shipped_bitdepth, shipped_cm))
|
||||||
-- 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.
|
-- Any monitor not named above: sane defaults rather than nothing.
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
@@ -193,18 +287,25 @@ hl.monitor({
|
|||||||
-- empty monitor leaves the previous binding in place. So the config is the only
|
-- empty monitor leaves the previous binding in place. So the config is the only
|
||||||
-- honest source, and applying a change is a reload.
|
-- honest source, and applying a change is a reload.
|
||||||
if prefs.get("workspacesOnPrimaryOnly", false) == true then
|
if prefs.get("workspacesOnPrimaryOnly", false) == true then
|
||||||
local primary = nil
|
-- Only a record display_entry accepts counts. A half-written entry is one
|
||||||
for output, entry in pairs(displays) do
|
-- the monitor rules above already refuse, so pinning ten workspaces to it on
|
||||||
if type(entry) == "table" and entry.primary == true
|
-- the strength of a `primary` flag nothing else trusts would put them on a
|
||||||
and type(output) == "string" and output:match("^[%w_.-]+$") ~= nil then
|
-- screen that never got a rule of its own.
|
||||||
primary = output
|
local primaries = {}
|
||||||
break
|
for output, _ in pairs(displays) do
|
||||||
|
local entry = display_entry(output)
|
||||||
|
if entry ~= nil and entry.primary == true then
|
||||||
|
primaries[#primaries + 1] = output
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Without a primary there is nothing to pin to, and guessing one would move
|
-- Without a primary there is nothing to pin to, and guessing one would move
|
||||||
-- every workspace onto whichever screen happened to sort first.
|
-- every workspace onto whichever screen happened to sort first. Two records
|
||||||
if primary ~= nil then
|
-- both claiming primary is the same problem wearing a different hat: pairs()
|
||||||
|
-- has no order, so picking one of them would pin the workspaces to a
|
||||||
|
-- different screen from one reload to the next. Neither case guesses.
|
||||||
|
if #primaries == 1 then
|
||||||
|
local primary = primaries[1]
|
||||||
for i = 1, 10 do
|
for i = 1, 10 do
|
||||||
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
|
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
-- to invert it.
|
-- to invert it.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
-- ── Upstream sanity rules ───────────────────────────────────────────────────
|
-- ── Upstream sanity rules ───────────────────────────────────────────────────
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
name = "suppress-maximize-events",
|
name = "suppress-maximize-events",
|
||||||
@@ -136,6 +138,97 @@ hl.window_rule({
|
|||||||
no_dim = true,
|
no_dim = true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- ── Per-application rules the user wrote ────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- `windowRules` in settings.json, edited from Settings' Windows page. Each
|
||||||
|
-- entry is data and nothing else:
|
||||||
|
--
|
||||||
|
-- { class, label, float, center, size = {w, h}, workspace, noAnim, game,
|
||||||
|
-- noDim, pin }
|
||||||
|
--
|
||||||
|
-- `class` is matched LITERALLY. Hyprland matches with RE2, so a class typed
|
||||||
|
-- into a text field is a regular expression unless something escapes it -- and
|
||||||
|
-- "org.gnome.Files" as a pattern also matches "orgxgnomexFiles", while a
|
||||||
|
-- half-typed "(" is a pattern error rather than a rule that matches nothing.
|
||||||
|
-- Escaped and anchored here, so what the user typed is what gets matched.
|
||||||
|
--
|
||||||
|
-- Emitted AFTER the shipped rules and deliberately WITHOUT a name. Hyprland
|
||||||
|
-- evaluates every named rule before every anonymous one, so a named user rule
|
||||||
|
-- would silently outrank the anonymous shipped rules above it -- the opposite
|
||||||
|
-- of the intended precedence. Anonymous, last, is what "the user's rule wins"
|
||||||
|
-- actually means here.
|
||||||
|
--
|
||||||
|
-- An entry that fails any check is skipped whole rather than emitted with the
|
||||||
|
-- bad field dropped: a rule that half-applies is harder to understand than one
|
||||||
|
-- that is not there, and the settings page can see the same thing is wrong.
|
||||||
|
|
||||||
|
local function escape_regex(value)
|
||||||
|
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The shell's own surfaces are layers, not windows -- but Quickshell's helper
|
||||||
|
-- windows are not, and a rule that floats or moves one of them would be a user
|
||||||
|
-- breaking their own desktop from the Windows page. Refused at both ends; this
|
||||||
|
-- is the end that matters, because the file is hand-editable.
|
||||||
|
local function reserved_class(class)
|
||||||
|
local lowered = class:lower()
|
||||||
|
return lowered:match("^quickshell") ~= nil or lowered:match("^qs%-") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function positive_integer(value, low, high)
|
||||||
|
if type(value) ~= "number" or value ~= math.floor(value) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if value < low or value > high then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, entry in ipairs(prefs.get("windowRules", {})) do
|
||||||
|
if type(entry) == "table" and type(entry.class) == "string" then
|
||||||
|
local class = entry.class
|
||||||
|
local rule = nil
|
||||||
|
|
||||||
|
if #class >= 1 and #class <= 128 and not reserved_class(class) then
|
||||||
|
rule = { match = { class = "^" .. escape_regex(class) .. "$" } }
|
||||||
|
|
||||||
|
if entry.float == true then rule.float = true end
|
||||||
|
if entry.center == true then rule.center = true end
|
||||||
|
if entry.noAnim == true then rule.no_anim = true end
|
||||||
|
if entry.noDim == true then rule.no_dim = true end
|
||||||
|
if entry.pin == true then rule.pin = true end
|
||||||
|
-- The keystone the gaming rules above use: misc.vrr,
|
||||||
|
-- render.direct_scanout and cursor.no_break_fs_vrr all key off it.
|
||||||
|
if entry.game == true then rule.content = "game" end
|
||||||
|
|
||||||
|
if entry.size ~= nil then
|
||||||
|
local size = entry.size
|
||||||
|
local width = type(size) == "table" and positive_integer(size[1], 50, 10000) or nil
|
||||||
|
local height = type(size) == "table" and positive_integer(size[2], 50, 10000) or nil
|
||||||
|
if width == nil or height == nil then
|
||||||
|
rule = nil
|
||||||
|
else
|
||||||
|
rule.size = { width, height }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if rule ~= nil and entry.workspace ~= nil then
|
||||||
|
local workspace = positive_integer(entry.workspace, 1, 10)
|
||||||
|
if workspace == nil then
|
||||||
|
rule = nil
|
||||||
|
else
|
||||||
|
rule.workspace = workspace
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if rule ~= nil then
|
||||||
|
hl.window_rule(rule)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- ── Workspace rules ─────────────────────────────────────────────────────────
|
-- ── Workspace rules ─────────────────────────────────────────────────────────
|
||||||
-- Deliberately NO "smart gaps".
|
-- Deliberately NO "smart gaps".
|
||||||
--
|
--
|
||||||
@@ -159,6 +252,11 @@ hl.layer_rule({
|
|||||||
name = "qs-dock",
|
name = "qs-dock",
|
||||||
match = { namespace = "^qs-dock$" },
|
match = { namespace = "^qs-dock$" },
|
||||||
blur = true,
|
blur = true,
|
||||||
|
-- The dock's context menu and its window previews are xdg-popups of this
|
||||||
|
-- surface, not layers of their own, so they are only blurred if the rule
|
||||||
|
-- says to blur the popups too. Without it they are flat panes over the
|
||||||
|
-- wallpaper while everything else on the desktop is glass.
|
||||||
|
blur_popups = true,
|
||||||
ignore_alpha = 0.3,
|
ignore_alpha = 0.3,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Deuteranopia -- green-blind.
|
||||||
|
//
|
||||||
|
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||||
|
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||||
|
// services/SystemSettings.qml does the same live.
|
||||||
|
//
|
||||||
|
// Same family of matrices as protanopia.frag, weighted for the missing green
|
||||||
|
// cone instead of the red one. See that file for why this is a correction
|
||||||
|
// rather than a simulation, and for the column-major note.
|
||||||
|
|
||||||
|
#version 300 es
|
||||||
|
|
||||||
|
precision mediump float;
|
||||||
|
in vec2 v_texcoord;
|
||||||
|
layout(location = 0) out vec4 fragColor;
|
||||||
|
uniform sampler2D tex;
|
||||||
|
|
||||||
|
const mat3 deuteranopia = mat3(
|
||||||
|
0.625, 0.700, 0.000,
|
||||||
|
0.375, 0.300, 0.300,
|
||||||
|
0.000, 0.000, 0.700
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 pixColor = texture(tex, v_texcoord);
|
||||||
|
|
||||||
|
fragColor = vec4(clamp(deuteranopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Grayscale.
|
||||||
|
//
|
||||||
|
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||||
|
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||||
|
// services/SystemSettings.qml does the same live.
|
||||||
|
//
|
||||||
|
// Hyprland runs one fragment shader over the finished frame, so this covers
|
||||||
|
// every window, the shell, video and the cursor alike.
|
||||||
|
//
|
||||||
|
// Rec. 709 luminance weights -- the same ones an SVG <feColorMatrix
|
||||||
|
// type="saturate" values="0"> uses. A flat average would make reds and blues
|
||||||
|
// far too bright and greens far too dark, because the eye does not weigh the
|
||||||
|
// channels equally.
|
||||||
|
|
||||||
|
#version 300 es
|
||||||
|
|
||||||
|
precision mediump float;
|
||||||
|
in vec2 v_texcoord;
|
||||||
|
layout(location = 0) out vec4 fragColor;
|
||||||
|
uniform sampler2D tex;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 pixColor = texture(tex, v_texcoord);
|
||||||
|
|
||||||
|
float luminance = dot(pixColor.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||||
|
|
||||||
|
fragColor = vec4(vec3(luminance), pixColor.a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Protanopia -- red-blind.
|
||||||
|
//
|
||||||
|
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||||
|
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||||
|
// services/SystemSettings.qml does the same live.
|
||||||
|
//
|
||||||
|
// The matrix is the feColorMatrix set the mock uses, which is the widely
|
||||||
|
// carried HCIRN-derived one: it redistributes the red channel into the two the
|
||||||
|
// eye can still separate, so a red/green pair that was one colour becomes two
|
||||||
|
// distinguishable ones. It is a CORRECTION, not a simulation -- the point is to
|
||||||
|
// make the screen readable, not to show what protanopia looks like.
|
||||||
|
//
|
||||||
|
// Row-major here, column-major to GLSL: mat3 takes its arguments column by
|
||||||
|
// column, so the transpose below is the matrix as written in the SVG.
|
||||||
|
|
||||||
|
#version 300 es
|
||||||
|
|
||||||
|
precision mediump float;
|
||||||
|
in vec2 v_texcoord;
|
||||||
|
layout(location = 0) out vec4 fragColor;
|
||||||
|
uniform sampler2D tex;
|
||||||
|
|
||||||
|
const mat3 protanopia = mat3(
|
||||||
|
0.567, 0.558, 0.000,
|
||||||
|
0.433, 0.442, 0.242,
|
||||||
|
0.000, 0.000, 0.758
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 pixColor = texture(tex, v_texcoord);
|
||||||
|
|
||||||
|
fragColor = vec4(clamp(protanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Tritanopia -- blue-blind.
|
||||||
|
//
|
||||||
|
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||||
|
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||||
|
// services/SystemSettings.qml does the same live.
|
||||||
|
//
|
||||||
|
// Same family of matrices as protanopia.frag, weighted for the missing blue
|
||||||
|
// cone. See that file for why this is a correction rather than a simulation,
|
||||||
|
// and for the column-major note.
|
||||||
|
|
||||||
|
#version 300 es
|
||||||
|
|
||||||
|
precision mediump float;
|
||||||
|
in vec2 v_texcoord;
|
||||||
|
layout(location = 0) out vec4 fragColor;
|
||||||
|
uniform sampler2D tex;
|
||||||
|
|
||||||
|
const mat3 tritanopia = mat3(
|
||||||
|
0.950, 0.000, 0.000,
|
||||||
|
0.050, 0.433, 0.475,
|
||||||
|
0.000, 0.567, 0.525
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 pixColor = texture(tex, v_texcoord);
|
||||||
|
|
||||||
|
fragColor = vec4(clamp(tritanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
# Tokyo Night Moon — the dark theme Panama ships.
|
# Tokyo Night Moon — the dark theme Panama ships.
|
||||||
#
|
#
|
||||||
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
# Reference, not the source of the running colours. kitty.conf includes
|
||||||
# includes current-theme.conf, which Panama generates from the color scheme
|
# current-theme.conf, which panama-theme-apps RENDERS from the active theme's
|
||||||
# setting; that generated file is gitignored because it is machine state.
|
# palette -- so any of the ten themes reaches the terminal, not only the two
|
||||||
|
# Tokyo Nights. This file used to be copied into that one; it is kept because
|
||||||
|
# it is a complete kitty theme somebody may want to include directly, and
|
||||||
|
# because it is what Moon is supposed to look like.
|
||||||
#
|
#
|
||||||
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||||
# remote control. Without it a scheme change would only reach terminals opened
|
# remote control. Without it a scheme change would only reach terminals opened
|
||||||
|
|||||||
@@ -1,40 +1,79 @@
|
|||||||
|
// Headless theme editor, for accent-controls-contract.
|
||||||
|
//
|
||||||
|
// The two halves of the editor that write colour: the four wells
|
||||||
|
// (ThemeEditorWells + ColorWell), which are the fast path and own hex
|
||||||
|
// validation and the eyedropper, and the six HSV rows (AccentEditor), which
|
||||||
|
// are the fine-tune behind a disclosure.
|
||||||
|
//
|
||||||
|
// AccentEditor commits on a debounce rather than per move, so `adjust` returns
|
||||||
|
// what is *pending*, not what is stored -- the caller waits and reads `status`
|
||||||
|
// again. That is the behaviour under test as much as the colour itself: a
|
||||||
|
// slider that wrote on every move spent a whole drag in apply-and-verify round
|
||||||
|
// trips and left the desktop repainting behind the pointer.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
import qs.modules.settings
|
import qs.modules.settings
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
Item {
|
Item {
|
||||||
width: 680
|
width: 680
|
||||||
height: editor.implicitHeight
|
height: wells.implicitHeight + editor.implicitHeight
|
||||||
|
|
||||||
|
ThemeEditorWells {
|
||||||
|
id: wells
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
AccentEditor {
|
AccentEditor {
|
||||||
id: editor
|
id: editor
|
||||||
|
y: wells.implicitHeight
|
||||||
width: parent.width
|
width: parent.width
|
||||||
}
|
}
|
||||||
|
|
||||||
ThemeProfilePicker {
|
|
||||||
width: parent.width
|
|
||||||
visible: false
|
|
||||||
}
|
|
||||||
|
|
||||||
AccentPicker {
|
|
||||||
width: parent.width
|
|
||||||
visible: false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "accent-controls-test"
|
target: "accent-controls-test"
|
||||||
|
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify(ThemeProfiles.activeProfile);
|
return JSON.stringify({
|
||||||
|
id: ThemeProfiles.activeProfile.id,
|
||||||
|
name: ThemeProfiles.activeProfile.name,
|
||||||
|
scheme: ThemeProfiles.activeProfile.scheme,
|
||||||
|
accent: String(ThemeProfiles.activeProfile.accent),
|
||||||
|
secondary: String(ThemeProfiles.activeProfile.secondary),
|
||||||
|
shipped: ThemeProfiles.activeProfile.shipped === true,
|
||||||
|
accentName: String(DesktopPreferences.get("accentName")),
|
||||||
|
bg: String(ThemeProfiles.activePalette.bg),
|
||||||
|
fg: String(ThemeProfiles.activePalette.fg),
|
||||||
|
fgDim: String(ThemeProfiles.activePalette.fgDim),
|
||||||
|
pending: editor.pending !== null,
|
||||||
|
wellError: wells.lastError
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moves one HSV slider. The commit is debounced, so the answer is the
|
||||||
|
// pair the sliders are showing, not the stored one.
|
||||||
function adjust(target: string, channel: string, ratio: real): string {
|
function adjust(target: string, channel: string, ratio: real): string {
|
||||||
editor.changeChannel(target, channel, ratio);
|
editor.changeChannel(target, channel, ratio);
|
||||||
|
return JSON.stringify({
|
||||||
|
shown: { accent: editor.shownAccent, secondary: editor.shownSecondary },
|
||||||
|
pending: editor.pending !== null,
|
||||||
|
stored: {
|
||||||
|
accent: String(ThemeProfiles.activeProfile.accent),
|
||||||
|
secondary: String(ThemeProfiles.activeProfile.secondary)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one path every well takes -- typed hex, colour wheel and
|
||||||
|
// eyedropper all end here, so validation cannot differ between them.
|
||||||
|
function well(which: string, hex: string): string {
|
||||||
|
wells.apply(which, hex);
|
||||||
return status();
|
return status();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ ShellRoot {
|
|||||||
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
|
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A throwaway two-stream group for the clamp cases. The shared fixture
|
||||||
|
// above is mutated by the volume and mute tests, and a clamp assertion that
|
||||||
|
// depended on which of those ran first would be worthless.
|
||||||
|
function clampFixture(): var {
|
||||||
|
return {
|
||||||
|
key: "clamp",
|
||||||
|
label: "Clamp",
|
||||||
|
icon: "audio-x-generic-symbolic",
|
||||||
|
nodes: [
|
||||||
|
{ audio: { volume: 0.1, muted: true } },
|
||||||
|
{ audio: { volume: 0.1, muted: true } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "application-volume-test"
|
target: "application-volume-test"
|
||||||
|
|
||||||
@@ -115,6 +130,31 @@ ShellRoot {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Over-amplification is a preference, so the ceiling is an argument
|
||||||
|
// rather than a constant -- this file stays Settings-free on purpose.
|
||||||
|
function clampVolume(): string {
|
||||||
|
const overAmp = clampFixture();
|
||||||
|
const overAmpChanged = AudioStreams.setVolume(overAmp, 1.4, 1.5);
|
||||||
|
const ceiling = clampFixture();
|
||||||
|
AudioStreams.setVolume(ceiling, 2.5, 1.5);
|
||||||
|
const defaultMax = clampFixture();
|
||||||
|
AudioStreams.setVolume(defaultMax, 1.4);
|
||||||
|
const floor = clampFixture();
|
||||||
|
AudioStreams.setVolume(floor, -0.5, 1.5);
|
||||||
|
const nonNumeric = clampFixture();
|
||||||
|
const nonNumericChanged = AudioStreams.setVolume(nonNumeric, "loud", 1.5);
|
||||||
|
return JSON.stringify({
|
||||||
|
overAmpChanged,
|
||||||
|
overAmp: overAmp.nodes.map(node => node.audio.volume),
|
||||||
|
overAmpMuted: overAmp.nodes.map(node => node.audio.muted),
|
||||||
|
ceiling: ceiling.nodes.map(node => node.audio.volume),
|
||||||
|
defaultMax: defaultMax.nodes.map(node => node.audio.volume),
|
||||||
|
floor: floor.nodes.map(node => node.audio.volume),
|
||||||
|
nonNumericChanged,
|
||||||
|
nonNumeric: nonNumeric.nodes.map(node => node.audio.volume)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function serviceSummary(): string {
|
function serviceSummary(): string {
|
||||||
const applications = AudioDevices.applications;
|
const applications = AudioDevices.applications;
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ Singleton {
|
|||||||
const coerced = PreferenceSchema.coerce(key, value);
|
const coerced = PreferenceSchema.coerce(key, value);
|
||||||
if (coerced === undefined)
|
if (coerced === undefined)
|
||||||
return false;
|
return false;
|
||||||
if (root.values[key] === coerced)
|
// A json value comes out of coerce() with a fresh identity every time,
|
||||||
|
// so `===` never held for one and an identical write still bumped the
|
||||||
|
// revision. The revision is what drives the video restore, the scheme
|
||||||
|
// reconciliation and the theme coalesce timer, so re-storing the same
|
||||||
|
// display map ran all three again for a change nobody made.
|
||||||
|
if (root.sameStoredValue(key, coerced))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
// Reassign rather than mutate: QML does not notify on in-place changes
|
// Reassign rather than mutate: QML does not notify on in-place changes
|
||||||
@@ -70,6 +75,19 @@ Singleton {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the store already holds `coerced` for `key`. Scalars compare by
|
||||||
|
// value; json compares by serialization, which is the only comparison an
|
||||||
|
// object or array has here. Two equal objects written in a different key
|
||||||
|
// order serialize differently and are treated as a change -- that is the
|
||||||
|
// old behaviour, so the comparison can only ever remove churn, never
|
||||||
|
// swallow a real write.
|
||||||
|
function sameStoredValue(key: string, coerced: var): bool {
|
||||||
|
const stored = root.values[key];
|
||||||
|
if (PreferenceSchema.spec(key)?.type === "json")
|
||||||
|
return stored !== undefined && JSON.stringify(stored) === JSON.stringify(coerced);
|
||||||
|
return stored === coerced;
|
||||||
|
}
|
||||||
|
|
||||||
// Restores every schema default in one write. Complete by construction --
|
// Restores every schema default in one write. Complete by construction --
|
||||||
// there is no hand-maintained list to fall out of sync with the schema.
|
// there is no hand-maintained list to fall out of sync with the schema.
|
||||||
function resetDesktopDefaults(): void {
|
function resetDesktopDefaults(): void {
|
||||||
|
|||||||
@@ -52,11 +52,74 @@ Singleton {
|
|||||||
|
|
||||||
readonly property var entries: [
|
readonly property var entries: [
|
||||||
// ── Clock ───────────────────────────────────────────────────────────
|
// ── Clock ───────────────────────────────────────────────────────────
|
||||||
|
// Group datetime, not clock: this drives the date menu, notification
|
||||||
|
// timestamps, and the lock screen — Date & Time owns it.
|
||||||
{
|
{
|
||||||
key: "use24Hour", type: "bool", def: false, group: "clock",
|
key: "use24Hour", type: "bool", def: false, group: "datetime",
|
||||||
label: "24-hour time",
|
label: "24-hour time",
|
||||||
detail: "Use 18:30 instead of 6:30 PM"
|
detail: "Use 18:30 instead of 6:30 PM"
|
||||||
},
|
},
|
||||||
|
// ── Bar ─────────────────────────────────────────────────────────────
|
||||||
|
// The bar floats directly on the wallpaper; these keep it legible on
|
||||||
|
// grounds the theme never met, and choose which widgets earn a place.
|
||||||
|
{
|
||||||
|
key: "barTextTone", type: "enum", def: "theme", group: "bar",
|
||||||
|
label: "Bar text",
|
||||||
|
detail: "Follow the theme, or force a light or dark tone for the wallpaper you actually use",
|
||||||
|
options: [
|
||||||
|
{ value: "theme", label: "Follow theme" },
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "barTextShadow", type: "bool", def: false, group: "bar",
|
||||||
|
label: "Bar text shadow",
|
||||||
|
detail: "A soft dark halo under every glyph and label in the bar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "barBackdrop", type: "bool", def: false, group: "bar",
|
||||||
|
label: "Bar backdrop",
|
||||||
|
detail: "A subtle scrim fading down from the top edge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "showWeatherWidget", type: "bool", def: true, group: "bar",
|
||||||
|
label: "Weather in the bar",
|
||||||
|
detail: "Beside the clock, once a forecast has been fetched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "showMediaWidget", type: "bool", def: true, group: "bar",
|
||||||
|
label: "Media in the bar",
|
||||||
|
detail: "Now playing, click to pause"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "showClipboardButton", type: "bool", def: true, group: "bar",
|
||||||
|
label: "Clipboard button",
|
||||||
|
detail: "The history stays on Super+V either way"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "showCalendarCountdown", type: "bool", def: true, group: "bar",
|
||||||
|
label: "Calendar countdown",
|
||||||
|
detail: "Appears in the bar fifteen minutes before an event"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Control Center ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "ccShowFocus", type: "bool", def: true, group: "controlCenter",
|
||||||
|
label: "Focus in Control Center",
|
||||||
|
detail: "The session row at the top of the panel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ccShowHome", type: "bool", def: true, group: "controlCenter",
|
||||||
|
label: "Home in Control Center",
|
||||||
|
detail: "Your accessory shelf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ccShowPhone", type: "bool", def: true, group: "controlCenter",
|
||||||
|
label: "Phone in Control Center",
|
||||||
|
detail: "Vitals and reach-it actions"
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "showSeconds", type: "bool", def: true, group: "clock",
|
key: "showSeconds", type: "bool", def: true, group: "clock",
|
||||||
label: "Show seconds",
|
label: "Show seconds",
|
||||||
@@ -105,8 +168,60 @@ Singleton {
|
|||||||
// general-purpose desktop should show without being asked.
|
// general-purpose desktop should show without being asked.
|
||||||
{
|
{
|
||||||
key: "showAgentUsage", type: "bool", def: false, group: "vitals",
|
key: "showAgentUsage", type: "bool", def: false, group: "vitals",
|
||||||
label: "Claude usage",
|
label: "Agent usage",
|
||||||
detail: "Show how much of the Claude subscription has been used, beside the other vitals"
|
detail: "Show how much of the busiest agent subscription has been used, beside the other vitals"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Agents ──────────────────────────────────────────────────────────
|
||||||
|
// The escalation ladder and the usage collectors. `preferredAgent` is
|
||||||
|
// deliberately "none" out of the box: until an agent is chosen, crash
|
||||||
|
// notifications carry no action -- the desktop stays quiet rather than
|
||||||
|
// volunteering a tool the user never asked for.
|
||||||
|
{
|
||||||
|
key: "preferredAgent", type: "enum", def: "none", group: "agents",
|
||||||
|
label: "Preferred agent",
|
||||||
|
detail: "Who answers when the desktop offers to investigate something",
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "claude", label: "Claude Code" },
|
||||||
|
{ value: "codex", label: "Codex" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "crashDiagnoseOffer", type: "bool", def: true, group: "agents",
|
||||||
|
label: "Offer to diagnose crashes",
|
||||||
|
detail: "When a program dumps core, the notification carries a click that opens the preferred agent mid-investigation with the crash details in hand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reloadFailureOffer", type: "bool", def: true, group: "agents",
|
||||||
|
label: "Offer help when the shell fails to reload",
|
||||||
|
detail: "A broken change to the shell's own configuration offers the failing log to the agent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "healthAgentHandoff", type: "bool", def: true, group: "agents",
|
||||||
|
label: "System Health hands off unrepairable checks",
|
||||||
|
detail: "A red check with no repair, or whose repair failed, grows an Ask-the-agent button carrying the check's snapshot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agentAutoApprove", type: "bool", def: true, group: "agents",
|
||||||
|
label: "Launched agents approve their own tools",
|
||||||
|
detail: "Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agentUsageClaude", type: "bool", def: true, group: "agents",
|
||||||
|
label: "Collect Claude Code usage",
|
||||||
|
detail: "Limits from Anthropic's usage endpoint, tokens from the local transcripts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agentUsageCodex", type: "bool", def: true, group: "agents",
|
||||||
|
label: "Collect Codex usage",
|
||||||
|
detail: "Limits over the Codex app-server, sessions from its local files"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agentUsageRefreshMinutes", type: "int", def: 15, min: 5, max: 60, step: 5,
|
||||||
|
unit: " min", group: "agents",
|
||||||
|
label: "Refresh interval",
|
||||||
|
detail: "How often the usage collectors ask for fresh numbers, in minutes"
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Battery ─────────────────────────────────────────────────────────
|
// ── Battery ─────────────────────────────────────────────────────────
|
||||||
@@ -125,6 +240,20 @@ Singleton {
|
|||||||
label: "Urgent at",
|
label: "Urgent at",
|
||||||
detail: "Interrupt at this level, even during Do Not Disturb"
|
detail: "Interrupt at this level, even during Do Not Disturb"
|
||||||
},
|
},
|
||||||
|
// What the desktop DOES at the critical threshold, beyond interrupting.
|
||||||
|
// Suspend by default, which is GNOME's behavior: sleep preserves the
|
||||||
|
// session at a level the firmware can hold for days, and the
|
||||||
|
// alternative -- a hard cut at 0% -- preserves nothing.
|
||||||
|
{
|
||||||
|
key: "batteryCriticalAction", type: "enum", def: "suspend", group: "battery",
|
||||||
|
label: "At the urgent level",
|
||||||
|
detail: "What happens when the battery reaches the urgent threshold while discharging",
|
||||||
|
options: [
|
||||||
|
{ value: "suspend", label: "Suspend" },
|
||||||
|
{ value: "nothing", label: "Only warn" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// Only offered where the firmware exposes a ceiling; the Power page
|
// Only offered where the firmware exposes a ceiling; the Power page
|
||||||
// hides the control entirely otherwise. 100 means charge to full.
|
// hides the control entirely otherwise. 100 means charge to full.
|
||||||
{
|
{
|
||||||
@@ -217,6 +346,9 @@ Singleton {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
// Set by the duration chips on the Focus tab, which is also where
|
||||||
|
// focusModes renders and where the focus group routes -- one editor,
|
||||||
|
// one page, so search and the docs point at the only place it exists.
|
||||||
{
|
{
|
||||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||||
unit: "min",
|
unit: "min",
|
||||||
@@ -677,6 +809,88 @@ Singleton {
|
|||||||
detail: "Paste the primary selection in GTK and native Wayland applications",
|
detail: "Paste the primary selection in GTK and native Wayland applications",
|
||||||
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
|
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "focusOnClose", type: "enum", def: 0, group: "pointer",
|
||||||
|
label: "Focus after closing",
|
||||||
|
detail: "Which window takes keyboard focus when the focused one goes away",
|
||||||
|
// Designed as a two-way choice; the compositor publishes three.
|
||||||
|
// map: [{"mru":2},{"cursor":1},{"next":0}]
|
||||||
|
// and 0 -- the value this desktop runs on today -- is "next in the
|
||||||
|
// stack", which is neither of the two the design named. Hiding it
|
||||||
|
// would make the shipped default unreachable from its own dropdown,
|
||||||
|
// and enum-hypr-map-contract refuses an enum that drops a published
|
||||||
|
// value for exactly that reason.
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Next in the stack",
|
||||||
|
detail: "Whichever window Hyprland has next in the layout order" },
|
||||||
|
{ value: 1, label: "Under the pointer",
|
||||||
|
detail: "Whatever window the pointer happens to be over" },
|
||||||
|
{ value: 2, label: "Most recently used",
|
||||||
|
detail: "The window you were on before this one" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "focus_on_close"], option: "input:focus_on_close", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scrollMethod", type: "enum", def: "", group: "pointer",
|
||||||
|
label: "Scroll method",
|
||||||
|
detail: "How a pointing device turns movement into scrolling",
|
||||||
|
// No `map` is published for this one -- it is a plain string option,
|
||||||
|
// and the words it accepts live in its description instead:
|
||||||
|
// [2fg/edge/on_button_down/no_scroll].
|
||||||
|
//
|
||||||
|
// Unset is a real state rather than an absence, and it is the state
|
||||||
|
// Panama ships: getoption answers "[[EMPTY]]" until something writes
|
||||||
|
// the option, and an empty value means "whatever libinput picks for
|
||||||
|
// this device", which is the branch every stock Hyprland takes. So
|
||||||
|
// empty is offered as a choice of its own -- without it the setting
|
||||||
|
// would be a one-way door, and its default would be unreachable.
|
||||||
|
// Writing "" reads back as "" with set:true, the same round trip
|
||||||
|
// input:kb_variant has made for as long as it has been empty.
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Whatever suits the device",
|
||||||
|
detail: "Two fingers on a touchpad, the wheel on a mouse" },
|
||||||
|
{ value: "2fg", label: "Two fingers" },
|
||||||
|
{ value: "edge", label: "Along the edge of the touchpad" },
|
||||||
|
{ value: "on_button_down", label: "While a button is held" },
|
||||||
|
{ value: "no_scroll", label: "Never scroll" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "scroll_method"], option: "input:scroll_method", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scrollButton", type: "int", def: 0, min: 0, max: 300, step: 1,
|
||||||
|
group: "pointer",
|
||||||
|
label: "Scroll button",
|
||||||
|
detail: "Which button is held to scroll, as an evdev code; 0 lets the device choose",
|
||||||
|
// The range is the compositor's own rather than a guess: descriptions
|
||||||
|
// gives min 0, max 300. Only meaningful while Scroll method is
|
||||||
|
// "While a button is held", which is a UI condition, not a schema one
|
||||||
|
// -- the value stays valid and stored either way.
|
||||||
|
hypr: { path: ["input", "scroll_button"], option: "input:scroll_button", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cursorHideWhileTyping", type: "bool", def: false, group: "pointer",
|
||||||
|
label: "Hide pointer while typing",
|
||||||
|
detail: "The pointer vanishes on the next keystroke and returns when you move it",
|
||||||
|
// A `cursor:` option rather than an `input:` one, so its read-back in
|
||||||
|
// the Lua sits in a cursor table of its own; see hypr/input.lua.
|
||||||
|
hypr: { path: ["cursor", "hide_on_key_press"], option: "cursor:hide_on_key_press", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cursorWarpOnWorkspaceChange", type: "bool", def: false, group: "pointer",
|
||||||
|
label: "Jump pointer to the focused display",
|
||||||
|
detail: "Moves the pointer to the last focused window after switching workspace",
|
||||||
|
// A switch here, an integer in the compositor -- the same shape
|
||||||
|
// autoHdr has, and `readAs: "int"` is what keeps the two sides in
|
||||||
|
// agreement. The published map is
|
||||||
|
// map: [{"force":2},{"enable":1},{"disable":0}]
|
||||||
|
// and "force" -- warp even when the pointer is already on that
|
||||||
|
// display -- is deliberately not offered: a third state would turn a
|
||||||
|
// switch into a dropdown for a distinction almost nobody wants.
|
||||||
|
// enum-hypr-map-contract governs enums only, so this is a decision
|
||||||
|
// rather than a violation, but it IS a decision: value 2 is not
|
||||||
|
// reachable from Settings.
|
||||||
|
hypr: { path: ["cursor", "warp_on_change_workspace"], option: "cursor:warp_on_change_workspace", readAs: "int" }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Touchpad ────────────────────────────────────────────────────────
|
// ── Touchpad ────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -734,6 +948,28 @@ Singleton {
|
|||||||
detail: "Pressing left and right together acts as a middle click",
|
detail: "Pressing left and right together acts as a middle click",
|
||||||
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadClickfinger", type: "bool", def: false, group: "touchpad",
|
||||||
|
label: "Two-finger right-click",
|
||||||
|
detail: "One, two, or three fingers pressing down give left, right, and middle click, instead of clicking by which part of the pad you press",
|
||||||
|
hypr: { path: ["input", "touchpad", "clickfinger_behavior"], option: "input:touchpad:clickfinger_behavior", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadTapAndDrag", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Tap and drag",
|
||||||
|
detail: "A tap followed straight away by a tap-and-hold starts a drag, with nothing pressed down",
|
||||||
|
// Hyphens in the option name, underscores in the Lua path -- the same
|
||||||
|
// split tap-to-click documents above, and the only other option in
|
||||||
|
// the touchpad section spelled that way.
|
||||||
|
//
|
||||||
|
// `hyprctl descriptions` contradicts itself here: it reports current
|
||||||
|
// false while `hyprctl getoption` answers bool true with set:false,
|
||||||
|
// meaning nothing has ever written it and it is sitting on
|
||||||
|
// Hyprland's own default of true. getoption is the authority, since
|
||||||
|
// it is what the write path verifies against, so true is what ships
|
||||||
|
// and nothing changes on a machine that has a touchpad.
|
||||||
|
hypr: { path: ["input", "touchpad", "tap_and_drag"], option: "input:touchpad:tap-and-drag", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
// Tuning for the three-finger gestures registered in hypr/input.lua.
|
// Tuning for the three-finger gestures registered in hypr/input.lua.
|
||||||
// The gestures themselves are not settings: Hyprland reads a gesture
|
// The gestures themselves are not settings: Hyprland reads a gesture
|
||||||
@@ -797,31 +1033,31 @@ Singleton {
|
|||||||
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
|
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
|
key: "workspaceBackAndForth", type: "bool", def: false, group: "workspaces",
|
||||||
label: "Switch back and forth",
|
label: "Switch back and forth",
|
||||||
detail: "Selecting the workspace you are already on returns you to the previous one",
|
detail: "Selecting the workspace you are already on returns you to the previous one",
|
||||||
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
|
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
|
key: "allowWorkspaceCycles", type: "bool", def: false, group: "workspaces",
|
||||||
label: "Wrap around at the ends",
|
label: "Wrap around at the ends",
|
||||||
detail: "Moving past the last workspace continues from the first",
|
detail: "Moving past the last workspace continues from the first",
|
||||||
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
|
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
|
key: "focusOnActivate", type: "bool", def: false, group: "workspaces",
|
||||||
label: "Let applications take focus",
|
label: "Let applications take focus",
|
||||||
detail: "An application asking for attention is switched to, rather than only highlighted",
|
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" }
|
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "windowSwallow", type: "bool", def: false, group: "multitasking",
|
key: "windowSwallow", type: "bool", def: false, group: "workspaces",
|
||||||
label: "Hide the terminal that launched a window",
|
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",
|
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" }
|
hypr: { path: ["misc", "enable_swallow"], option: "misc:enable_swallow", readAs: "bool" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "workspaces",
|
||||||
label: "Pointer changes active display",
|
label: "Pointer changes active display",
|
||||||
detail: "Moving the pointer to another display makes it the active one",
|
detail: "Moving the pointer to another display makes it the active one",
|
||||||
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
||||||
@@ -838,9 +1074,14 @@ Singleton {
|
|||||||
// this app refuses to ship.
|
// this app refuses to ship.
|
||||||
{
|
{
|
||||||
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
|
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
|
||||||
group: "accessibility",
|
unit: "×", group: "accessibility",
|
||||||
label: "Magnifier",
|
label: "Magnifier",
|
||||||
detail: "Magnifies the screen around the pointer. 1.0 is off",
|
// The readout is "1.00 ×", so the detail says "1.00 ×" too. It used
|
||||||
|
// to say "1.0 is off" beside a slider reading 1.00, and the page
|
||||||
|
// carried a `zeroLabel: "Off"` that could never fire: the minimum
|
||||||
|
// IS 1.0, so the value is never 0 and the zero label was dead copy.
|
||||||
|
// Off is a magnification of one, and that is what both lines say.
|
||||||
|
detail: "Magnifies the screen around the pointer. 1.00 × is off",
|
||||||
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
|
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -870,6 +1111,33 @@ Singleton {
|
|||||||
detail: "How much darker unfocused windows are",
|
detail: "How much darker unfocused windows are",
|
||||||
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "colorFilter", type: "enum", def: "none", group: "accessibility",
|
||||||
|
label: "Color filter",
|
||||||
|
detail: "A whole-screen filter rendered by the compositor — grayscale, or a correction for one kind of color blindness. Costs nothing when off.",
|
||||||
|
// No hypr mapping, deliberately: hyprctl stores decoration:screen_shader
|
||||||
|
// as a shader *path*, not this enum, so a hypr: block would fail the
|
||||||
|
// shape and sweep contracts on read-back. hypr/looks.lua maps the enum
|
||||||
|
// to a shipped shader for reloads; SystemSettings.applyColorFilter does
|
||||||
|
// the same mapping live.
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "grayscale", label: "Grayscale" },
|
||||||
|
{ value: "protanopia", label: "Protanopia" },
|
||||||
|
{ value: "deuteranopia", label: "Deuteranopia" },
|
||||||
|
{ value: "tritanopia", label: "Tritanopia" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "visualAlerts", type: "bool", def: false, group: "accessibility",
|
||||||
|
label: "Flash the screen for notifications",
|
||||||
|
// No hypr mapping and no gsettings mapping: the flash is drawn by
|
||||||
|
// modules/notifications/VisualBell.qml, one per screen, and fires
|
||||||
|
// on the same notifications the bell would ring for -- except that
|
||||||
|
// it is deliberately NOT gated on the event-sounds switch, since a
|
||||||
|
// visual alert exists for people who cannot hear the bell.
|
||||||
|
detail: "A single flash at the edges of every screen when a notification arrives that would ring the bell"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Gaming ──────────────────────────────────────────────────────────
|
// ── Gaming ──────────────────────────────────────────────────────────
|
||||||
// What Panama does while a game runs. gamemode tells us when that
|
// What Panama does while a game runs. gamemode tells us when that
|
||||||
@@ -957,6 +1225,29 @@ Singleton {
|
|||||||
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
|
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
|
||||||
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
|
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
|
||||||
},
|
},
|
||||||
|
// Video wallpapers play through mpvpaper (services/VideoWallpaper.qml);
|
||||||
|
// the active video rides wallpaperPath like any still, routed by
|
||||||
|
// extension. These two only shape discovery and the battery policy.
|
||||||
|
{
|
||||||
|
key: "videoWallpaperDir", type: "string", def: "Videos/Wallpapers", group: "wallpaper",
|
||||||
|
pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
|
||||||
|
label: "Video wallpaper folder",
|
||||||
|
detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /"
|
||||||
|
},
|
||||||
|
// Its own key, not wallpaperPath: the still pipeline persists its
|
||||||
|
// policy transactionally and once clobbered a stored video path.
|
||||||
|
// Two owners, two keys.
|
||||||
|
{
|
||||||
|
key: "videoWallpaperPath", type: "string", def: "", group: "wallpaper", internal: true,
|
||||||
|
pattern: "^(|/[^,\n]+)$",
|
||||||
|
label: "Video wallpaper",
|
||||||
|
detail: "The video playing as the desktop background"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "videoWallpaperPauseOnBattery", type: "bool", def: true, group: "wallpaper",
|
||||||
|
label: "Pause video wallpaper on battery",
|
||||||
|
detail: "Freezes on the current frame and resumes on wall power"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Lock-screen appearance ─────────────────────────────────────────
|
// ── Lock-screen appearance ─────────────────────────────────────────
|
||||||
// scripts/panama-lock validates these again before generating a state
|
// scripts/panama-lock validates these again before generating a state
|
||||||
@@ -1046,6 +1337,27 @@ Singleton {
|
|||||||
detail: "On battery, sleeping is what makes the charge last"
|
detail: "On battery, sleeping is what makes the charge last"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Power button ────────────────────────────────────────────────────
|
||||||
|
// logind is told to ignore the power key -- config/copy ships the
|
||||||
|
// drop-in -- so what a press does is the compositor's decision rather
|
||||||
|
// than the system's, and changing it needs no root.
|
||||||
|
//
|
||||||
|
// No `hypr` block: this is not a compositor option, it is read by
|
||||||
|
// config/dot/hypr/keybinds.lua the way the workspace rules are. The
|
||||||
|
// bind evaluates it AT PRESS TIME rather than at config time, so a
|
||||||
|
// change here applies to the very next press and no reload is needed.
|
||||||
|
{
|
||||||
|
key: "powerButtonAction", type: "enum", def: "menu", group: "power",
|
||||||
|
label: "Pressing the power button",
|
||||||
|
detail: "The system ignores the key; Panama decides — so a bumped button never yanks the plug",
|
||||||
|
options: [
|
||||||
|
{ value: "menu", label: "Shows the power menu" },
|
||||||
|
{ value: "suspend", label: "Suspends" },
|
||||||
|
{ value: "poweroff", label: "Powers off (two-press)" },
|
||||||
|
{ value: "nothing", label: "Does nothing" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// ── Night light schedule ────────────────────────────────────────────
|
// ── Night light schedule ────────────────────────────────────────────
|
||||||
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
|
// 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
|
// is normal here and is what the shipped values do: on at 17:00, off at
|
||||||
@@ -1115,6 +1427,22 @@ Singleton {
|
|||||||
detail: "Named custom colour schemes and accent pairs",
|
detail: "Named custom colour schemes and accent pairs",
|
||||||
internal: true
|
internal: true
|
||||||
},
|
},
|
||||||
|
// The remembered theme per scheme: flipping light/dark lands on the
|
||||||
|
// theme you last chose for that side, never a forced default.
|
||||||
|
{
|
||||||
|
key: "themeDark", type: "string", def: "moon", group: "appearance",
|
||||||
|
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||||
|
label: "Dark theme",
|
||||||
|
detail: "The theme applied while the desktop is dark",
|
||||||
|
internal: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "themeLight", type: "string", def: "day", group: "appearance",
|
||||||
|
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||||
|
label: "Light theme",
|
||||||
|
detail: "The theme applied while the desktop is light",
|
||||||
|
internal: true
|
||||||
|
},
|
||||||
|
|
||||||
// ── Application themes ─────────────────────────────────────────────
|
// ── Application themes ─────────────────────────────────────────────
|
||||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||||
@@ -1234,20 +1562,18 @@ Singleton {
|
|||||||
{ value: "right", label: "Right" }
|
{ value: "right", label: "Right" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
// Panama's own windows (Settings) draw their own titlebar and follow
|
||||||
|
// the same rules as GNOME apps. Off is pure Hyprland: Super+Q closes,
|
||||||
|
// Super+drag moves, Escape still works.
|
||||||
{
|
{
|
||||||
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
|
key: "panamaTitlebar", type: "bool", def: true, group: "titlebar",
|
||||||
label: "Maximize button",
|
label: "Titlebar on Panama windows",
|
||||||
detail: "Show a maximize button in application titlebars that support it"
|
detail: "Hide it and the window is pure Hyprland — Super+Q closes, Super+drag moves"
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
|
|
||||||
label: "Double-click titlebar",
|
|
||||||
detail: "Choose what a double-click on an application titlebar does",
|
|
||||||
options: [
|
|
||||||
{ value: "toggle-maximize", label: "Toggle maximize" },
|
|
||||||
{ value: "none", label: "Do nothing" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
// titlebarMaximizeButton and titlebarDoubleClick used to live here.
|
||||||
|
// Removed on purpose: Hyprland has no minimize, maximize is noise in a
|
||||||
|
// tiler, and DesktopStyle now pushes a close-only button-layout and
|
||||||
|
// leaves GNOME's double-click default alone.
|
||||||
|
|
||||||
// ── Accessibility ───────────────────────────────────────────────────
|
// ── Accessibility ───────────────────────────────────────────────────
|
||||||
// Backed by gsettings so GTK applications agree with the shell, and
|
// Backed by gsettings so GTK applications agree with the shell, and
|
||||||
@@ -1297,28 +1623,44 @@ Singleton {
|
|||||||
detail: "Which GPU the graphics readout in the bar measures"
|
detail: "Which GPU the graphics readout in the bar measures"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Search ──────────────────────────────────────────────────────────
|
||||||
|
// The launcher's web search appends the query to this. It shipped
|
||||||
|
// pointing at the author's personal bang redirector once; a stranger's
|
||||||
|
// searches belong to no one's server but the engine they chose.
|
||||||
|
{
|
||||||
|
key: "webSearchUrl", type: "string",
|
||||||
|
def: "https://duckduckgo.com/?q=", group: "search",
|
||||||
|
pattern: "^https://[^\\s]{1,200}$",
|
||||||
|
label: "Web search engine",
|
||||||
|
detail: "Where the launcher's web search sends a query; the search text is appended"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Weather location ────────────────────────────────────────────────
|
// ── Weather location ────────────────────────────────────────────────
|
||||||
// Coordinates rather than a place name, because that is what Open-Meteo
|
// 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
|
// 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
|
// in the UI; it is never sent anywhere, so it can say whatever makes the
|
||||||
// reading recognizable.
|
// reading recognizable.
|
||||||
{
|
{
|
||||||
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
key: "weatherLatitude", type: "real", def: 0, min: -90, max: 90, step: 0.0001,
|
||||||
group: "weather", internal: true,
|
group: "weather", internal: true,
|
||||||
label: "Latitude",
|
label: "Latitude",
|
||||||
detail: "Set by choosing a location"
|
detail: "Set by choosing a location"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
|
key: "weatherLongitude", type: "real", def: 0, min: -180, max: 180, step: 0.0001,
|
||||||
group: "weather", internal: true,
|
group: "weather", internal: true,
|
||||||
label: "Longitude",
|
label: "Longitude",
|
||||||
detail: "Set by choosing a location"
|
detail: "Set by choosing a location"
|
||||||
},
|
},
|
||||||
|
// Empty until a location is chosen. The shipped value was once the
|
||||||
|
// author's home town, which confidently reported his weather on every
|
||||||
|
// machine anywhere; an unset location fetches nothing and says so
|
||||||
|
// instead.
|
||||||
{
|
{
|
||||||
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
|
key: "weatherLocation", type: "string", def: "", group: "weather",
|
||||||
internal: true,
|
internal: true,
|
||||||
// Display only -- never sent to the weather service.
|
// Display only -- never sent to the weather service.
|
||||||
pattern: "^[^\\n]{1,64}$",
|
pattern: "^[^\\n]{0,64}$",
|
||||||
label: "Weather location",
|
label: "Weather location",
|
||||||
detail: "The place the weather reading is for"
|
detail: "The place the weather reading is for"
|
||||||
},
|
},
|
||||||
@@ -1356,6 +1698,38 @@ Singleton {
|
|||||||
label: "Visible banners",
|
label: "Visible banners",
|
||||||
detail: "Maximum notification banners shown at once"
|
detail: "Maximum notification banners shown at once"
|
||||||
},
|
},
|
||||||
|
// The one hole anyone is allowed to punch in Do Not Disturb. Off by
|
||||||
|
// default, because Do Not Disturb that lets something through anyway is
|
||||||
|
// not the thing most people asked for -- and "critical" is the sender's
|
||||||
|
// word, not yours, so an application that calls everything critical
|
||||||
|
// would otherwise defeat the switch on its own say-so. Turned on, it
|
||||||
|
// shows a banner for critical notifications while a mode or a manual Do
|
||||||
|
// Not Disturb is silencing everything else; the per-application urgency
|
||||||
|
// override is how you decide which senders get to claim it.
|
||||||
|
{
|
||||||
|
key: "criticalBreaksThrough", type: "bool", def: false,
|
||||||
|
group: "notifications",
|
||||||
|
label: "Critical alerts break through",
|
||||||
|
detail: "Show critical notifications as banners even while Do Not Disturb is on"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Sound ───────────────────────────────────────────────────────────
|
||||||
|
// The two audio preferences that are Panama's own. Everything else on
|
||||||
|
// the Sound page is live PipeWire or a GNOME desktop key, and belongs
|
||||||
|
// to the system rather than to this file.
|
||||||
|
//
|
||||||
|
// Both are read by scripts/panama-osd as well as by the shell, so the
|
||||||
|
// volume keys behave the same whether the panel is open or not.
|
||||||
|
{
|
||||||
|
key: "overAmplification", type: "bool", def: false, group: "sound",
|
||||||
|
label: "Over-amplification",
|
||||||
|
detail: "Lets the volume slider go to 150% — louder, at the cost of distortion on some hardware"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "volumeChangeBlip", type: "bool", def: true, group: "sound",
|
||||||
|
label: "Volume-change blip",
|
||||||
|
detail: "A short click each time the volume keys move the output level"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Capture ─────────────────────────────────────────────────────────
|
// ── Capture ─────────────────────────────────────────────────────────
|
||||||
// Directories and encoder arguments are enums rather than free text:
|
// Directories and encoder arguments are enums rather than free text:
|
||||||
@@ -1379,14 +1753,34 @@ Singleton {
|
|||||||
label: "Recording folder",
|
label: "Recording folder",
|
||||||
detail: "Where screen recordings are saved. Relative to your home folder unless it starts with /"
|
detail: "Where screen recordings are saved. Relative to your home folder unless it starts with /"
|
||||||
},
|
},
|
||||||
|
// What a recording hears. Off by default, which is GNOME's default
|
||||||
|
// too -- a screencast that silently captured the microphone would be
|
||||||
|
// a privacy incident, not a feature. The @DEFAULT_*@ tokens are
|
||||||
|
// PulseAudio's own always-current aliases, so the recording follows
|
||||||
|
// the device Sound settings has selected rather than naming one.
|
||||||
{
|
{
|
||||||
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
|
key: "recorderAudio", type: "enum", def: "none", group: "capture",
|
||||||
|
label: "Recording audio",
|
||||||
|
detail: "What screen recordings capture alongside the video",
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "No audio" },
|
||||||
|
{ value: "system", label: "System audio" },
|
||||||
|
{ value: "microphone", label: "Microphone" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// "auto" stands in for the render node until record time:
|
||||||
|
// /dev/dri/renderD128 was baked into every option once, which is one
|
||||||
|
// machine's enumeration and frequently the wrong node on hybrid
|
||||||
|
// graphics. Capture.qml resolves it when recording starts.
|
||||||
|
{
|
||||||
|
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d auto",
|
||||||
group: "capture",
|
group: "capture",
|
||||||
label: "Recording encoder",
|
label: "Recording encoder",
|
||||||
detail: "Hardware encoding keeps recording off the processor while gaming",
|
detail: "Hardware encoding keeps recording off the processor while gaming",
|
||||||
options: [
|
options: [
|
||||||
{ value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
|
{ value: "-c h264_vaapi -d auto", label: "VAAPI H.264" },
|
||||||
{ value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
|
{ value: "-c hevc_vaapi -d auto", label: "VAAPI HEVC" },
|
||||||
{ value: "-c libx264", label: "CPU x264" }
|
{ value: "-c libx264", label: "CPU x264" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1423,7 +1817,7 @@ Singleton {
|
|||||||
// each chord and falls back to the shipped one, so a hand-edited file
|
// each chord and falls back to the shipped one, so a hand-edited file
|
||||||
// cannot cost you a keymap.
|
// cannot cost you a keymap.
|
||||||
//
|
//
|
||||||
// Edited through the Input & Shortcuts page rather than as a row, hence
|
// Edited through the Keyboard page (under Input) rather than as a row, hence
|
||||||
// internal.
|
// internal.
|
||||||
{
|
{
|
||||||
key: "keybindOverrides", type: "json", def: ({}), group: "input",
|
key: "keybindOverrides", type: "json", def: ({}), group: "input",
|
||||||
@@ -1432,21 +1826,88 @@ Singleton {
|
|||||||
detail: "Shortcuts you have moved from their shipped chord"
|
detail: "Shortcuts you have moved from their shipped chord"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Custom shortcuts ────────────────────────────────────────────────
|
||||||
|
// [ { chord, kind, target, label } ]. Data, never code: `kind` is one of
|
||||||
|
// app | shell | window, `target` is a validated id resolved through the
|
||||||
|
// whitelist tables in hypr/actions.lua, and an entry that fails any check
|
||||||
|
// is silently not emitted. This is what keeps a user-editable file from
|
||||||
|
// being executable even though it now describes shortcuts the user
|
||||||
|
// invented. Edited through the Keyboard page, hence internal.
|
||||||
|
{
|
||||||
|
key: "customBinds", type: "json", def: [], group: "input",
|
||||||
|
internal: true,
|
||||||
|
label: "Custom shortcuts",
|
||||||
|
detail: "Shortcuts you invented: each one launches an application, triggers a shell action, or moves a window"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Per-application window rules ────────────────────────────────────
|
||||||
|
// [ { class, label, float, center, size, workspace, noAnim, game,
|
||||||
|
// noDim, pin } ]. `class` is matched literally (hypr/rules.lua escapes
|
||||||
|
// it before Hyprland's RE2 sees it); `size` is [w, h] or null;
|
||||||
|
// `workspace` is 1..10 or null; everything else is a boolean. Rules
|
||||||
|
// matching the shell's own surfaces are refused at both ends. Edited
|
||||||
|
// through the Windows page, hence internal.
|
||||||
|
{
|
||||||
|
key: "windowRules", type: "json", def: [], group: "multitasking",
|
||||||
|
internal: true,
|
||||||
|
label: "Application window rules",
|
||||||
|
detail: "How specific applications behave when they open: floating, size, workspace, animations"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Four-finger gestures ────────────────────────────────────────────
|
||||||
|
// Each holds {} (unassigned) or a named action { kind, target, label },
|
||||||
|
// the same shape customBinds stores and the same whitelists resolve.
|
||||||
|
// Registered at compositor config time, so assigning one reloads.
|
||||||
|
{
|
||||||
|
key: "gestureFourUp", type: "json", def: ({}), group: "touchpad",
|
||||||
|
internal: true,
|
||||||
|
label: "Four-finger swipe up",
|
||||||
|
detail: "What a four-finger upward swipe does"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gestureFourDown", type: "json", def: ({}), group: "touchpad",
|
||||||
|
internal: true,
|
||||||
|
label: "Four-finger swipe down",
|
||||||
|
detail: "What a four-finger downward swipe does"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gestureFourLeft", type: "json", def: ({}), group: "touchpad",
|
||||||
|
internal: true,
|
||||||
|
label: "Four-finger swipe left",
|
||||||
|
detail: "What a four-finger leftward swipe does"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gestureFourRight", type: "json", def: ({}), group: "touchpad",
|
||||||
|
internal: true,
|
||||||
|
label: "Four-finger swipe right",
|
||||||
|
detail: "What a four-finger rightward swipe does"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Display configuration ───────────────────────────────────────────
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
// { "<output>": { mode, scale, transform, x, y, primary, vrrMode,
|
||||||
// hypr/monitors.lua on top of the shipped values. Color management and
|
// colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } },
|
||||||
// bit depth are deliberately not here: those carry a documented
|
// applied by hypr/monitors.lua on top of the shipped values. Everything
|
||||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
// past `primary` is optional, so records written before those fields
|
||||||
// you would be changing it.
|
// existed still load and the shipped defaults stand for them.
|
||||||
{
|
{
|
||||||
key: "displays", type: "json", def: ({}), group: "display",
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
internal: true,
|
internal: true,
|
||||||
label: "Display configuration",
|
label: "Display configuration",
|
||||||
detail: "Resolution, scale, rotation, position, and primary display"
|
detail: "Resolution, scale, rotation, position, primary display, color, VRR override, and mirroring"
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Per-application notification rules ──────────────────────────────
|
// ── Per-application notification rules ──────────────────────────────
|
||||||
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
|
// { "<appId>": { enabled, sound, display, urgency, lastSeenMs, name,
|
||||||
|
// icon } -- every field past `enabled` is optional, so rules written
|
||||||
|
// when this held only `enabled` still load. Lock-screen fields from
|
||||||
|
// older rules are dropped at normalization; hyprlock cannot render
|
||||||
|
// notifications.
|
||||||
|
//
|
||||||
|
// `name` and `icon` are a cache for the settings list, not the source:
|
||||||
|
// resolution stays live-first through DesktopEntries, and these only
|
||||||
|
// stand in for an application that is not installed (or not scanned
|
||||||
|
// yet). `lastSeenMs` is stamped on every notification and is what puts
|
||||||
|
// an application in the "Recent" section.
|
||||||
//
|
//
|
||||||
// Absent means "no rule", which is not the same as a rule that allows
|
// Absent means "no rule", which is not the same as a rule that allows
|
||||||
// everything: a new application must be able to notify without needing
|
// everything: a new application must be able to notify without needing
|
||||||
@@ -1457,7 +1918,7 @@ Singleton {
|
|||||||
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
||||||
internal: true,
|
internal: true,
|
||||||
label: "Application notification rules",
|
label: "Application notification rules",
|
||||||
detail: "Per-application notification and lock-screen visibility preferences"
|
detail: "Per-application notification sound, banner, and urgency preferences"
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────────────────────────────
|
// ── Internal ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -19,6 +19,26 @@ Singleton {
|
|||||||
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
|
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
|
||||||
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
|
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
|
||||||
|
|
||||||
|
// ── Bar ─────────────────────────────────────────────────────────────────
|
||||||
|
// Legibility first: the bar sits on the wallpaper, so it may need a tone,
|
||||||
|
// a shadow or a scrim the theme cannot know about. `barTextTone` itself is
|
||||||
|
// read by Theme, which turns it into the barFg family the widgets bind to.
|
||||||
|
readonly property bool barTextShadow: DesktopPreferences.get("barTextShadow")
|
||||||
|
readonly property bool barBackdrop: DesktopPreferences.get("barBackdrop")
|
||||||
|
|
||||||
|
// Which widgets earn a place. Each is ANDed with the widget's own state
|
||||||
|
// condition, so turning one on never conjures a pill with nothing in it.
|
||||||
|
readonly property bool showWeatherWidget: DesktopPreferences.get("showWeatherWidget")
|
||||||
|
readonly property bool showMediaWidget: DesktopPreferences.get("showMediaWidget")
|
||||||
|
readonly property bool showClipboardButton: DesktopPreferences.get("showClipboardButton")
|
||||||
|
readonly property bool showCalendarCountdown: DesktopPreferences.get("showCalendarCountdown")
|
||||||
|
|
||||||
|
// ── Control Center ──────────────────────────────────────────────────────
|
||||||
|
// One bool per section of the quick settings panel that is worth hiding.
|
||||||
|
readonly property bool ccShowFocus: DesktopPreferences.get("ccShowFocus")
|
||||||
|
readonly property bool ccShowHome: DesktopPreferences.get("ccShowHome")
|
||||||
|
readonly property bool ccShowPhone: DesktopPreferences.get("ccShowPhone")
|
||||||
|
|
||||||
// ── Weather ─────────────────────────────────────────────────────────────
|
// ── Weather ─────────────────────────────────────────────────────────────
|
||||||
// Coordinates taken from the GNOME night-light setting, which had already
|
// Coordinates taken from the GNOME night-light setting, which had already
|
||||||
// resolved the location. Uses Open-Meteo, which needs no API key.
|
// resolved the location. Uses Open-Meteo, which needs no API key.
|
||||||
@@ -42,6 +62,19 @@ Singleton {
|
|||||||
readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
|
readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
|
||||||
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
|
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
|
||||||
|
|
||||||
|
// ── Agents ──────────────────────────────────────────────────────────────
|
||||||
|
// Who the desktop hands a failure to, what it is allowed to hand over, and
|
||||||
|
// which usage collectors run. `showAgentUsage` stays with the vitals above:
|
||||||
|
// it is the bar's switch, and the Agents page mirrors it.
|
||||||
|
readonly property string preferredAgent: DesktopPreferences.get("preferredAgent")
|
||||||
|
readonly property bool crashDiagnoseOffer: DesktopPreferences.get("crashDiagnoseOffer")
|
||||||
|
readonly property bool reloadFailureOffer: DesktopPreferences.get("reloadFailureOffer")
|
||||||
|
readonly property bool healthAgentHandoff: DesktopPreferences.get("healthAgentHandoff")
|
||||||
|
readonly property bool agentAutoApprove: DesktopPreferences.get("agentAutoApprove")
|
||||||
|
readonly property bool agentUsageClaude: DesktopPreferences.get("agentUsageClaude")
|
||||||
|
readonly property bool agentUsageCodex: DesktopPreferences.get("agentUsageCodex")
|
||||||
|
readonly property int agentUsageRefreshMinutes: DesktopPreferences.get("agentUsageRefreshMinutes")
|
||||||
|
|
||||||
// ── Battery ─────────────────────────────────────────────────────────────
|
// ── Battery ─────────────────────────────────────────────────────────────
|
||||||
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
|
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
|
||||||
readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent")
|
readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent")
|
||||||
@@ -62,6 +95,28 @@ Singleton {
|
|||||||
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
||||||
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||||
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
||||||
|
// The single exception to Do Not Disturb, read by the popup gate in
|
||||||
|
// services/Notifs.qml. Off means Do Not Disturb is absolute.
|
||||||
|
readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough")
|
||||||
|
|
||||||
|
// ── Accessibility ───────────────────────────────────────────────────────
|
||||||
|
// Reduce motion. Theme.qml turns this into the dur* tokens, so every
|
||||||
|
// Behavior and NumberAnimation in the shell obeys it without knowing it
|
||||||
|
// exists. Read through here rather than from the store directly because
|
||||||
|
// Theme reads it on every animated property in the shell.
|
||||||
|
readonly property bool animationsEnabled: DesktopPreferences.get("animationsEnabled")
|
||||||
|
|
||||||
|
// Read on every notification that would ring the bell, by the per-screen
|
||||||
|
// VisualBell overlay. Lives here rather than being read from the store
|
||||||
|
// directly, like every other value the shell consults at speed.
|
||||||
|
readonly property bool visualAlerts: DesktopPreferences.get("visualAlerts")
|
||||||
|
|
||||||
|
// ── Sound ───────────────────────────────────────────────────────────────
|
||||||
|
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
|
||||||
|
// on means 1.5. Every slider and the volume keys read the same switch, so
|
||||||
|
// the ceiling cannot differ depending on where you changed the volume from.
|
||||||
|
readonly property bool overAmplification: DesktopPreferences.get("overAmplification")
|
||||||
|
readonly property bool volumeChangeBlip: DesktopPreferences.get("volumeChangeBlip")
|
||||||
|
|
||||||
// ── Focus ──────────────────────────────────────────────────────────────
|
// ── Focus ──────────────────────────────────────────────────────────────
|
||||||
// One deliberate default rather than a preset picker: quick settings and
|
// One deliberate default rather than a preset picker: quick settings and
|
||||||
@@ -96,4 +151,5 @@ Singleton {
|
|||||||
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
|
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
|
||||||
// cost CPU while gaming.
|
// cost CPU while gaming.
|
||||||
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
|
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
|
||||||
|
readonly property string recorderAudio: DesktopPreferences.get("recorderAudio")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,28 +20,68 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
// ── Color scheme ───────────────────────────────────────────────────────
|
// ── Color scheme ───────────────────────────────────────────────────────
|
||||||
// Tokyo Night ships an official light variant (Day), so light mode is that
|
// The scheme still exists as a boolean because alphas and a handful of
|
||||||
// rather than a palette invented to merely not be dark. The two share the
|
// mix directions depend on which side of the ground the theme sits — but
|
||||||
// same hues at different lightness, which is what lets the Prism identity
|
// the palette itself now comes from the active theme record.
|
||||||
// survive the switch: blue still leads into orchid, it is simply a darker
|
|
||||||
// blue on a lighter ground.
|
|
||||||
//
|
//
|
||||||
// Every token below is a binding on this, so flipping it repaints the whole
|
// Every token below is a binding, so selecting a theme repaints the whole
|
||||||
// shell without anything needing to know it happened.
|
// shell without anything needing to know it happened.
|
||||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||||
|
|
||||||
// ── Palette ─────────────────────────────────────────────────────────────
|
// ── Palette ─────────────────────────────────────────────────────────────
|
||||||
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
|
// The active theme's palette — ThemeProfiles resolves it and it is always
|
||||||
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
|
// complete: a shipped theme carries its own, a custom theme without one
|
||||||
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
|
// inherits its scheme's default (Tokyo Moon or Tokyo Day) from the
|
||||||
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
|
// catalog. Nothing here needs a per-token fallback.
|
||||||
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
|
readonly property var palette: ThemeProfiles.activePalette
|
||||||
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
|
|
||||||
|
|
||||||
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
|
readonly property color bg: root.palette.bg
|
||||||
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
|
readonly property color bgDark: root.palette.bgDark
|
||||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
readonly property color bgHighlight: root.palette.bgHighlight
|
||||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
readonly property color bgPanel: root.palette.bgPanel // dock glass surface
|
||||||
|
readonly property color bgPopover: root.palette.bgPopover // Openbar submenu background
|
||||||
|
|
||||||
|
readonly property color fg: root.palette.fg
|
||||||
|
readonly property color fgDim: root.palette.fgDim
|
||||||
|
readonly property color fgMuted: root.palette.fgMuted
|
||||||
|
readonly property color gutter: root.palette.gutter
|
||||||
|
|
||||||
|
// ── Bar text ────────────────────────────────────────────────────────────
|
||||||
|
// Every other surface in the shell draws on a ground the theme chose. The
|
||||||
|
// bar draws on the wallpaper, which the theme has never seen — so a
|
||||||
|
// photograph with a bright sky can leave the palette's own fg unreadable
|
||||||
|
// exactly where the clock is.
|
||||||
|
//
|
||||||
|
// Hence a second neutral family, used by the bar and nowhere else. Left
|
||||||
|
// alone it *is* the fg family, so nothing changes for anyone who never
|
||||||
|
// asks. Forced light or dark, it becomes a run that survives whatever is
|
||||||
|
// underneath it, and the two dims are mixed toward the opposite end rather
|
||||||
|
// than picked by hand, so the three stay a family either way.
|
||||||
|
readonly property string barTextTone: DesktopPreferences.get("barTextTone")
|
||||||
|
|
||||||
|
readonly property color barFg: {
|
||||||
|
if (root.barTextTone === "light")
|
||||||
|
return "#f4f6ff";
|
||||||
|
if (root.barTextTone === "dark")
|
||||||
|
return "#1b2130";
|
||||||
|
return root.fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property color barFgDim: {
|
||||||
|
if (root.barTextTone === "light")
|
||||||
|
return root.mix(root.barFg, "#20242f", 0.35);
|
||||||
|
if (root.barTextTone === "dark")
|
||||||
|
return root.mix(root.barFg, "#ffffff", 0.35);
|
||||||
|
return root.fgDim;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property color barFgMuted: {
|
||||||
|
if (root.barTextTone === "light")
|
||||||
|
return root.mix(root.barFg, "#20242f", 0.5);
|
||||||
|
if (root.barTextTone === "dark")
|
||||||
|
return root.mix(root.barFg, "#ffffff", 0.5);
|
||||||
|
return root.fgMuted;
|
||||||
|
}
|
||||||
|
|
||||||
// ── The accent ──────────────────────────────────────────────────────────
|
// ── The accent ──────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -75,16 +115,16 @@ Singleton {
|
|||||||
// shell-wide colour roles.
|
// shell-wide colour roles.
|
||||||
readonly property color accent: root.activeProfile.accent
|
readonly property color accent: root.activeProfile.accent
|
||||||
readonly property color accentSecondary: root.activeProfile.secondary
|
readonly property color accentSecondary: root.activeProfile.secondary
|
||||||
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
readonly property color accentAlt: root.palette.accentAlt
|
||||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
readonly property color cyan: root.palette.cyan
|
||||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
readonly property color teal: root.palette.teal
|
||||||
readonly property color green: root.dark ? "#c3e88d" : "#587539"
|
readonly property color green: root.palette.green
|
||||||
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
|
readonly property color yellow: root.palette.yellow
|
||||||
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
|
readonly property color orange: root.palette.orange
|
||||||
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
|
readonly property color red: root.palette.red
|
||||||
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
|
readonly property color redDeep: root.palette.redDeep
|
||||||
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
|
readonly property color magenta: root.palette.magenta
|
||||||
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
|
readonly property color pink: root.palette.pink
|
||||||
|
|
||||||
// Semantic aliases — prefer these in widgets so intent survives a repaint.
|
// Semantic aliases — prefer these in widgets so intent survives a repaint.
|
||||||
readonly property color ok: green
|
readonly property color ok: green
|
||||||
@@ -164,14 +204,21 @@ Singleton {
|
|||||||
// ── Motion ──────────────────────────────────────────────────────────────
|
// ── Motion ──────────────────────────────────────────────────────────────
|
||||||
// Event-driven only. Nothing in this shell animates while idle — no pulse,
|
// Event-driven only. Nothing in this shell animates while idle — no pulse,
|
||||||
// no shimmer, no spinners. These durations are used for open/close/hover.
|
// no shimmer, no spinners. These durations are used for open/close/hover.
|
||||||
readonly property int durFast: 120
|
//
|
||||||
readonly property int durNormal: 200
|
// All of them collapse to zero when Reduce motion is on: the Accessibility
|
||||||
readonly property int durSlow: 320
|
// toggle used to still the compositor's windows while the shell's own bar,
|
||||||
|
// dock and panels kept moving, which made it a half-truth. A duration of 0
|
||||||
|
// is a completed animation, so every Behavior and NumberAnimation in the
|
||||||
|
// shell obeys the switch without knowing it exists.
|
||||||
|
readonly property bool motionEnabled: Settings.animationsEnabled
|
||||||
|
readonly property int durFast: motionEnabled ? 120 : 0
|
||||||
|
readonly property int durNormal: motionEnabled ? 200 : 0
|
||||||
|
readonly property int durSlow: motionEnabled ? 320 : 0
|
||||||
|
|
||||||
// The dock revealing is the one animation that answers a live pointer
|
// The dock revealing is the one animation that answers a live pointer
|
||||||
// movement, so it gets its own (much shorter) duration. Anything slower
|
// movement, so it gets its own (much shorter) duration. Anything slower
|
||||||
// reads as the desktop lagging behind the cursor rather than as motion.
|
// reads as the desktop lagging behind the cursor rather than as motion.
|
||||||
readonly property int durDockReveal: 90
|
readonly property int durDockReveal: motionEnabled ? 90 : 0
|
||||||
|
|
||||||
// Matches the "snappy" spring curve defined in hypr/looks.lua.
|
// Matches the "snappy" spring curve defined in hypr/looks.lua.
|
||||||
readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0]
|
readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0]
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
{
|
||||||
|
"comment": "The shipped theme catalog — the one source of truth for full palettes. services/ThemeCatalog.qml reads it with FileView, scripts read it with jq, contracts read it with fs. The 19 palette keys are exactly Theme.qml's color tokens; ansi's 16 feed generated terminal themes. moon and day must stay byte-identical to the shell's pre-theme literals: theme-catalog-contract pins that.",
|
||||||
|
"defaultDark": "moon",
|
||||||
|
"defaultLight": "day",
|
||||||
|
"themes": [
|
||||||
|
{
|
||||||
|
"id": "moon", "name": "Tokyo Moon", "scheme": "dark",
|
||||||
|
"accent": "#82aaff", "secondary": "#b172b0",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
|
||||||
|
"bgPanel": "#2e2f3d", "bgPopover": "#21212f",
|
||||||
|
"fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
|
||||||
|
"gutter": "#3b4261", "accentAlt": "#65bcff",
|
||||||
|
"cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
|
||||||
|
"yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
|
||||||
|
"redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
|
||||||
|
"yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
|
||||||
|
"cyan": "#86e1fc", "white": "#828bb8",
|
||||||
|
"brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
|
||||||
|
"brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
|
||||||
|
"brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "moon-rose", "name": "Moon Rose", "scheme": "dark",
|
||||||
|
"accent": "#ff757f", "secondary": "#c099ff",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
|
||||||
|
"bgPanel": "#2e2f3d", "bgPopover": "#21212f",
|
||||||
|
"fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
|
||||||
|
"gutter": "#3b4261", "accentAlt": "#fca7ea",
|
||||||
|
"cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
|
||||||
|
"yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
|
||||||
|
"redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
|
||||||
|
"yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
|
||||||
|
"cyan": "#86e1fc", "white": "#828bb8",
|
||||||
|
"brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
|
||||||
|
"brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
|
||||||
|
"brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mocha", "name": "Catppuccin Mocha", "scheme": "dark",
|
||||||
|
"accent": "#cba6f7", "secondary": "#f5c2e7",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#1e1e2e", "bgDark": "#181825", "bgHighlight": "#313244",
|
||||||
|
"bgPanel": "#27273a", "bgPopover": "#1b1b2a",
|
||||||
|
"fg": "#cdd6f4", "fgDim": "#9399b2", "fgMuted": "#7f849c",
|
||||||
|
"gutter": "#45475a", "accentAlt": "#89dceb",
|
||||||
|
"cyan": "#89dceb", "teal": "#94e2d5", "green": "#a6e3a1",
|
||||||
|
"yellow": "#f9e2af", "orange": "#fab387", "red": "#f38ba8",
|
||||||
|
"redDeep": "#b04a5a", "magenta": "#cba6f7", "pink": "#f5c2e7"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#45475a", "red": "#f38ba8", "green": "#a6e3a1",
|
||||||
|
"yellow": "#f9e2af", "blue": "#89b4fa", "magenta": "#f5c2e7",
|
||||||
|
"cyan": "#94e2d5", "white": "#bac2de",
|
||||||
|
"brightBlack": "#585b70", "brightRed": "#f38ba8", "brightGreen": "#a6e3a1",
|
||||||
|
"brightYellow": "#f9e2af", "brightBlue": "#89b4fa", "brightMagenta": "#f5c2e7",
|
||||||
|
"brightCyan": "#94e2d5", "brightWhite": "#a6adc8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nord", "name": "Nord", "scheme": "dark",
|
||||||
|
"accent": "#88c0d0", "secondary": "#81a1c1",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#2e3440", "bgDark": "#272c36", "bgHighlight": "#3b4252",
|
||||||
|
"bgPanel": "#353c4a", "bgPopover": "#2a303c",
|
||||||
|
"fg": "#d8dee9", "fgDim": "#9aa4b8", "fgMuted": "#7b8598",
|
||||||
|
"gutter": "#4c566a", "accentAlt": "#8fbcbb",
|
||||||
|
"cyan": "#88c0d0", "teal": "#8fbcbb", "green": "#a3be8c",
|
||||||
|
"yellow": "#ebcb8b", "orange": "#d08770", "red": "#bf616a",
|
||||||
|
"redDeep": "#a54e57", "magenta": "#b48ead", "pink": "#c9a3c0"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#3b4252", "red": "#bf616a", "green": "#a3be8c",
|
||||||
|
"yellow": "#ebcb8b", "blue": "#81a1c1", "magenta": "#b48ead",
|
||||||
|
"cyan": "#88c0d0", "white": "#e5e9f0",
|
||||||
|
"brightBlack": "#4c566a", "brightRed": "#bf616a", "brightGreen": "#a3be8c",
|
||||||
|
"brightYellow": "#ebcb8b", "brightBlue": "#81a1c1", "brightMagenta": "#b48ead",
|
||||||
|
"brightCyan": "#8fbcbb", "brightWhite": "#eceff4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gruvbox", "name": "Gruvbox", "scheme": "dark",
|
||||||
|
"accent": "#fabd2f", "secondary": "#fe8019",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#282828", "bgDark": "#1d2021", "bgHighlight": "#3c3836",
|
||||||
|
"bgPanel": "#32302f", "bgPopover": "#211f1e",
|
||||||
|
"fg": "#ebdbb2", "fgDim": "#a89984", "fgMuted": "#928374",
|
||||||
|
"gutter": "#504945", "accentAlt": "#83a598",
|
||||||
|
"cyan": "#83a598", "teal": "#8ec07c", "green": "#b8bb26",
|
||||||
|
"yellow": "#fabd2f", "orange": "#fe8019", "red": "#fb4934",
|
||||||
|
"redDeep": "#cc241d", "magenta": "#d3869b", "pink": "#dfa7be"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#282828", "red": "#cc241d", "green": "#98971a",
|
||||||
|
"yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
|
||||||
|
"cyan": "#689d6a", "white": "#a89984",
|
||||||
|
"brightBlack": "#928374", "brightRed": "#fb4934", "brightGreen": "#b8bb26",
|
||||||
|
"brightYellow": "#fabd2f", "brightBlue": "#83a598", "brightMagenta": "#d3869b",
|
||||||
|
"brightCyan": "#8ec07c", "brightWhite": "#ebdbb2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "everforest", "name": "Everforest", "scheme": "dark",
|
||||||
|
"accent": "#a7c080", "secondary": "#e69875",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#2d353b", "bgDark": "#272e33", "bgHighlight": "#3d484d",
|
||||||
|
"bgPanel": "#343f44", "bgPopover": "#293136",
|
||||||
|
"fg": "#d3c6aa", "fgDim": "#9da9a0", "fgMuted": "#7a8478",
|
||||||
|
"gutter": "#475258", "accentAlt": "#7fbbb3",
|
||||||
|
"cyan": "#7fbbb3", "teal": "#83c092", "green": "#a7c080",
|
||||||
|
"yellow": "#dbbc7f", "orange": "#e69875", "red": "#e67e80",
|
||||||
|
"redDeep": "#c05a5e", "magenta": "#d699b6", "pink": "#dfa7c1"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#3d484d", "red": "#e67e80", "green": "#a7c080",
|
||||||
|
"yellow": "#dbbc7f", "blue": "#7fbbb3", "magenta": "#d699b6",
|
||||||
|
"cyan": "#83c092", "white": "#d3c6aa",
|
||||||
|
"brightBlack": "#475258", "brightRed": "#e67e80", "brightGreen": "#a7c080",
|
||||||
|
"brightYellow": "#dbbc7f", "brightBlue": "#7fbbb3", "brightMagenta": "#d699b6",
|
||||||
|
"brightCyan": "#83c092", "brightWhite": "#d3c6aa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "day", "name": "Tokyo Day", "scheme": "light",
|
||||||
|
"accent": "#2e7de9", "secondary": "#9854f1",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#e1e2e7", "bgDark": "#d3d5de", "bgHighlight": "#c4c8da",
|
||||||
|
"bgPanel": "#d9dae3", "bgPopover": "#eaeaee",
|
||||||
|
"fg": "#3760bf", "fgDim": "#6172b0", "fgMuted": "#848cb5",
|
||||||
|
"gutter": "#a8aecb", "accentAlt": "#007197",
|
||||||
|
"cyan": "#007197", "teal": "#118c74", "green": "#587539",
|
||||||
|
"yellow": "#8c6c3e", "orange": "#b15c00", "red": "#f52a65",
|
||||||
|
"redDeep": "#c64343", "magenta": "#9854f1", "pink": "#d20065"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#b4b5b9", "red": "#f52a65", "green": "#587539",
|
||||||
|
"yellow": "#8c6c3e", "blue": "#2e7de9", "magenta": "#9854f1",
|
||||||
|
"cyan": "#007197", "white": "#6172b0",
|
||||||
|
"brightBlack": "#a1a6c5", "brightRed": "#ff4774", "brightGreen": "#5c8524",
|
||||||
|
"brightYellow": "#a27629", "brightBlue": "#358aff", "brightMagenta": "#a463ff",
|
||||||
|
"brightCyan": "#007ea8", "brightWhite": "#3760bf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "latte", "name": "Catppuccin Latte", "scheme": "light",
|
||||||
|
"accent": "#8839ef", "secondary": "#ea76cb",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#eff1f5", "bgDark": "#e6e9ef", "bgHighlight": "#ccd0da",
|
||||||
|
"bgPanel": "#e2e6ee", "bgPopover": "#f5f7fa",
|
||||||
|
"fg": "#4c4f69", "fgDim": "#6c6f85", "fgMuted": "#8c8fa1",
|
||||||
|
"gutter": "#acb0be", "accentAlt": "#209fb5",
|
||||||
|
"cyan": "#04a5e5", "teal": "#179299", "green": "#40a02b",
|
||||||
|
"yellow": "#df8e1d", "orange": "#fe640b", "red": "#d20f39",
|
||||||
|
"redDeep": "#e64553", "magenta": "#8839ef", "pink": "#ea76cb"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#5c5f77", "red": "#d20f39", "green": "#40a02b",
|
||||||
|
"yellow": "#df8e1d", "blue": "#1e66f5", "magenta": "#ea76cb",
|
||||||
|
"cyan": "#179299", "white": "#acb0be",
|
||||||
|
"brightBlack": "#6c6f85", "brightRed": "#d20f39", "brightGreen": "#40a02b",
|
||||||
|
"brightYellow": "#df8e1d", "brightBlue": "#1e66f5", "brightMagenta": "#ea76cb",
|
||||||
|
"brightCyan": "#179299", "brightWhite": "#bcc0cc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gruvbox-light", "name": "Gruvbox Light", "scheme": "light",
|
||||||
|
"accent": "#b57614", "secondary": "#af3a03",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#fbf1c7", "bgDark": "#f2e5bc", "bgHighlight": "#ebdbb2",
|
||||||
|
"bgPanel": "#f0e6c0", "bgPopover": "#fdf6d8",
|
||||||
|
"fg": "#3c3836", "fgDim": "#665c54", "fgMuted": "#7c6f64",
|
||||||
|
"gutter": "#bdae93", "accentAlt": "#076678",
|
||||||
|
"cyan": "#076678", "teal": "#427b58", "green": "#79740e",
|
||||||
|
"yellow": "#b57614", "orange": "#af3a03", "red": "#9d0006",
|
||||||
|
"redDeep": "#cc241d", "magenta": "#8f3f71", "pink": "#b16286"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#fbf1c7", "red": "#cc241d", "green": "#98971a",
|
||||||
|
"yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
|
||||||
|
"cyan": "#689d6a", "white": "#7c6f64",
|
||||||
|
"brightBlack": "#928374", "brightRed": "#9d0006", "brightGreen": "#79740e",
|
||||||
|
"brightYellow": "#b57614", "brightBlue": "#076678", "brightMagenta": "#8f3f71",
|
||||||
|
"brightCyan": "#427b58", "brightWhite": "#3c3836"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "everforest-light", "name": "Everforest Light", "scheme": "light",
|
||||||
|
"accent": "#8da101", "secondary": "#f57d26",
|
||||||
|
"palette": {
|
||||||
|
"bg": "#fdf6e3", "bgDark": "#f4f0d9", "bgHighlight": "#e6e2cc",
|
||||||
|
"bgPanel": "#efebd4", "bgPopover": "#faf3dc",
|
||||||
|
"fg": "#5c6a72", "fgDim": "#829181", "fgMuted": "#939f91",
|
||||||
|
"gutter": "#a6b0a0", "accentAlt": "#3a94c5",
|
||||||
|
"cyan": "#3a94c5", "teal": "#35a77c", "green": "#8da101",
|
||||||
|
"yellow": "#dfa000", "orange": "#f57d26", "red": "#f85552",
|
||||||
|
"redDeep": "#e66868", "magenta": "#df69ba", "pink": "#df69ba"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"black": "#5c6a72", "red": "#f85552", "green": "#8da101",
|
||||||
|
"yellow": "#dfa000", "blue": "#3a94c5", "magenta": "#df69ba",
|
||||||
|
"cyan": "#35a77c", "white": "#e0dcc7",
|
||||||
|
"brightBlack": "#829181", "brightRed": "#f85552", "brightGreen": "#8da101",
|
||||||
|
"brightYellow": "#dfa000", "brightBlue": "#3a94c5", "brightMagenta": "#df69ba",
|
||||||
|
"brightCyan": "#35a77c", "brightWhite": "#fdf6e3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -75,6 +75,36 @@ ShellRoot {
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One display is the common case on a laptop, and the canvas is the
|
||||||
|
// page's hero now: it renders that display rather than disappearing and
|
||||||
|
// leaving a picker for a list of one. Only dragging goes away, because
|
||||||
|
// there is nothing to arrange it against.
|
||||||
|
function soloFixture(): string {
|
||||||
|
const previous = fixtureService.monitors;
|
||||||
|
fixtureService.monitors = [previous[0]];
|
||||||
|
arrangement.resetDraft();
|
||||||
|
const snapshot = arrangement.canvasSnapshot();
|
||||||
|
fixtureService.monitors = previous;
|
||||||
|
arrangement.resetDraft();
|
||||||
|
return JSON.stringify(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mirrored display has no position of its own -- the compositor puts
|
||||||
|
// it on top of its target -- so the canvas stacks it there and says so
|
||||||
|
// rather than drawing it wherever its stale coordinates point.
|
||||||
|
function mirrorFixture(): string {
|
||||||
|
const previous = fixtureService.monitors;
|
||||||
|
fixtureService.monitors = [
|
||||||
|
previous[0],
|
||||||
|
Object.assign({}, previous[1], { mirrorOf: "DP-2" })
|
||||||
|
];
|
||||||
|
arrangement.resetDraft();
|
||||||
|
const snapshot = arrangement.canvasSnapshot();
|
||||||
|
fixtureService.monitors = previous;
|
||||||
|
arrangement.resetDraft();
|
||||||
|
return JSON.stringify(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
function identify(): void { Displays.identify(); }
|
function identify(): void { Displays.identify(); }
|
||||||
function identifying(): bool { return Displays.identifying; }
|
function identifying(): bool { return Displays.identifying; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,53 @@ ShellRoot {
|
|||||||
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
|
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// The same two displays, with the second mirroring the first. A mirrored
|
||||||
|
// display has no position of its own: the compositor puts it on top of its
|
||||||
|
// target, so its stored coordinates are stale the moment mirroring is on.
|
||||||
|
readonly property var mirrored: [
|
||||||
|
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true, mirrorOf: "" },
|
||||||
|
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 0, x: 3140, y: 80, primary: false, mirrorOf: "DP-2" }
|
||||||
|
]
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "display-layout-test"
|
target: "display-layout-test"
|
||||||
|
|
||||||
|
function mirror(): string {
|
||||||
|
const normalized = DisplayLayout.normalize(mirrored);
|
||||||
|
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
||||||
|
return JSON.stringify({
|
||||||
|
valid: DisplayLayout.validate(mirrored),
|
||||||
|
normalized: normalized.map(record => ({
|
||||||
|
name: record.name, x: record.x, y: record.y,
|
||||||
|
primary: record.primary, mirrorOf: record.mirrorOf ?? ""
|
||||||
|
})),
|
||||||
|
bounds: canvas.bounds,
|
||||||
|
rects: canvas.rects
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidMirrors(): string {
|
||||||
|
const base = mirrored.map(record => Object.assign({}, record));
|
||||||
|
const cases = [];
|
||||||
|
const add = layout => cases.push(DisplayLayout.validate(layout));
|
||||||
|
// Mirroring itself.
|
||||||
|
add([base[0], Object.assign({}, base[1], { mirrorOf: "HDMI-A-1" })]);
|
||||||
|
// Mirroring an output that is not in the layout.
|
||||||
|
add([base[0], Object.assign({}, base[1], { mirrorOf: "NOPE-1" })]);
|
||||||
|
// The primary may not mirror: the desktop is anchored on it.
|
||||||
|
add([Object.assign({}, base[0], { mirrorOf: "HDMI-A-1" }), base[1]]);
|
||||||
|
// No chains. Hyprland resolves a mirror to one target, and a chain
|
||||||
|
// is a question nobody can answer from the canvas.
|
||||||
|
add([
|
||||||
|
base[0],
|
||||||
|
Object.assign({}, base[1], { mirrorOf: "DP-3" }),
|
||||||
|
{ name: "DP-3", width: 1920, height: 1080, scale: 1, transform: 0, x: 6000, y: 0, primary: false, mirrorOf: "DP-2" }
|
||||||
|
]);
|
||||||
|
// Not a name at all.
|
||||||
|
add([base[0], Object.assign({}, base[1], { mirrorOf: 5 })]);
|
||||||
|
return JSON.stringify(cases);
|
||||||
|
}
|
||||||
|
|
||||||
function status(): string {
|
function status(): string {
|
||||||
const normalized = DisplayLayout.normalize(fixture);
|
const normalized = DisplayLayout.normalize(fixture);
|
||||||
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
||||||
|
|||||||
@@ -112,6 +112,30 @@ ShellRoot {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The extended record — vrr override, colour profile, bit depth, SDR
|
||||||
|
// trim, mirroring — merged one field at a time, which is how every
|
||||||
|
// control on the page changes it. Base64 for the reason restorePlan
|
||||||
|
// documents above: `qs ipc call` splits JSON that looks like an array
|
||||||
|
// of objects into one argument per object.
|
||||||
|
function applyRecordFixture(output: string, patchB64: string): bool {
|
||||||
|
return Displays.applyRecord(output, JSON.parse(Qt.atob(patchB64)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesLayout as a pure function. The readback carve-outs — a
|
||||||
|
// mirrored output's position, a framebuffer format Panama does not
|
||||||
|
// recognise — are decisions about what NOT to assert, and proving them
|
||||||
|
// through a compositor would mean owning a compositor that mirrors.
|
||||||
|
function layoutMatch(monitorsB64: string, layoutB64: string): bool {
|
||||||
|
return Displays.matchesLayout(JSON.parse(Qt.atob(monitorsB64)),
|
||||||
|
JSON.parse(Qt.atob(layoutB64)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A settings.json written before any of the new fields existed is the
|
||||||
|
// common case on every machine that has this installed today.
|
||||||
|
function persistedEntryValid(entryB64: string): bool {
|
||||||
|
return Displays.isPersistedLayoutEntry(JSON.parse(Qt.atob(entryB64)));
|
||||||
|
}
|
||||||
|
|
||||||
function expireApplyVerification(): void {
|
function expireApplyVerification(): void {
|
||||||
Displays.verificationTimedOut();
|
Displays.verificationTimedOut();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,9 @@ ShellRoot {
|
|||||||
function repair(id: string): bool { return Health.repair(id, false); }
|
function repair(id: string): bool { return Health.repair(id, false); }
|
||||||
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
|
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
|
||||||
function copy(): bool { return Health.copyReport(); }
|
function copy(): bool { return Health.copyReport(); }
|
||||||
|
// Both take an explicit destination or subject, so nothing here writes
|
||||||
|
// to the real home or rescans anything the fixture did not ask for.
|
||||||
|
function save(path: string): bool { return Health.saveReport(path); }
|
||||||
|
function recheck(id: string): bool { return Health.refreshCheck(id); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,5 +49,32 @@ 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
|
and brightness keys still work, and still work on the lock screen. Copy and
|
||||||
paste are unchanged. Your files are where you left them.
|
paste are unchanged. Your files are where you left them.
|
||||||
|
|
||||||
GNOME Settings is still installed for the few panels Panama deliberately does
|
## Where the settings went
|
||||||
not own, and Panama's own Settings hands off to it where that applies.
|
|
||||||
|
`Super + I` opens Panama's own Settings. The sidebar is where GNOME's panels
|
||||||
|
ended up, and it is worth one read so you never have to hunt:
|
||||||
|
|
||||||
|
| If you are looking for | It is under |
|
||||||
|
|---|---|
|
||||||
|
| Themes, wallpaper, fonts, text size, titlebars | Appearance |
|
||||||
|
| The bar, the dock, Control Center, tiling, workspaces | Shell |
|
||||||
|
| Resolution, scale, arrangement, night light | Displays |
|
||||||
|
| Output and input devices, per-application volume | Sound |
|
||||||
|
| Do Not Disturb, per-application rules, focus modes | Notifications & Focus |
|
||||||
|
| Shortcuts, mouse and touchpad, dictation | Input |
|
||||||
|
| Wi-Fi and VPN, the firewall, sharing, printers | Network & Sharing |
|
||||||
|
| Default applications, autostart, gaming, Screen Intelligence | Applications |
|
||||||
|
| The accounts on this machine, and online ones | Users & Accounts |
|
||||||
|
| Permissions, saved passwords, what the machine remembers, SSH keys | Privacy & Security |
|
||||||
|
| Battery, the lid, the power button, idle and lock timings | Power & Lock |
|
||||||
|
| Magnifier, color filters, reduced motion, screen reader | Accessibility |
|
||||||
|
| Updates, storage, snapshots, date and time, backups | System |
|
||||||
|
|
||||||
|
Home is the page `Super + I` lands on. The search box above the sidebar
|
||||||
|
searches the settings themselves rather than the page names, so typing what you
|
||||||
|
want to change is usually faster than remembering which of those it lives in.
|
||||||
|
|
||||||
|
GNOME Settings is still installed, and Panama's Settings opens it in exactly
|
||||||
|
two places: adding an online account, which has to go through the provider's
|
||||||
|
own dialog, and Digital wellbeing, which is the one panel of GNOME's that does
|
||||||
|
something Panama does not.
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ one I have".
|
|||||||
|
|
||||||
## Changing them
|
## Changing them
|
||||||
|
|
||||||
Settings has a Shortcuts page listing every bind, each of which can be
|
Settings has a [Keyboard page](panama://settings/shortcuts), under Input, listing every bind, each of which
|
||||||
reassigned. A rebind moves the shortcut and cannot change what it does, so
|
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.
|
there is no way to make a key do something unexpected by editing it.
|
||||||
|
|
||||||
|
Shortcuts you invent live in the same list, in a **Custom** group above the
|
||||||
|
rest. One of those names an application, a shell action or a window move rather
|
||||||
|
than storing a command — for the same reason: what you are choosing is which
|
||||||
|
key, not what it runs. A chord something already holds is refused rather than
|
||||||
|
stolen, so a new shortcut can never quietly cost you an old one.
|
||||||
|
|||||||
@@ -28,14 +28,43 @@ follows you between workspaces. A floating window can be moved by holding
|
|||||||
|
|
||||||
## Workspaces
|
## Workspaces
|
||||||
|
|
||||||
Ten of them, on `Alt + 1` through `Alt + 0`. `Alt + H` and `Alt + L` step
|
Ten you can jump straight to, on `Alt + 1` through `Alt + 0`, though they are
|
||||||
between neighbours, and `Alt + Shift` plus a number sends the current window
|
created and destroyed as you use them rather than sitting there empty. `Alt + H`
|
||||||
somewhere without following it.
|
and `Alt + L` step between neighbours — right past the last one makes a new one,
|
||||||
|
left from the first stays put — 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
|
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
|
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
|
main display and give the second screen its own, that is a switch on the
|
||||||
Desktop settings page.
|
Displays settings page.
|
||||||
|
|
||||||
|
How windows share the space — the layout, the master area, gaps and window
|
||||||
|
edges — is Shell › Tiling. The workspace switches themselves, and the projects
|
||||||
|
below, are Shell › Workspaces.
|
||||||
|
|
||||||
|
## The touchpad
|
||||||
|
|
||||||
|
Three fingers sideways moves between workspaces and follows your fingers as it
|
||||||
|
goes; three fingers up opens the overview and three down closes it. Those are
|
||||||
|
GNOME's gestures, reproduced, and they do not move.
|
||||||
|
|
||||||
|
Four fingers are yours. Each of the four directions can be given a job — a shell
|
||||||
|
action, a window or workspace move — from the same list your own shortcuts pick
|
||||||
|
from, on Input › Mouse & Touchpad. Assigning one reloads the compositor, so
|
||||||
|
expect a beat of black before it works. Unassigned directions are left alone
|
||||||
|
rather than bound to nothing.
|
||||||
|
|
||||||
|
## Rules for one application
|
||||||
|
|
||||||
|
Some windows want the same treatment every time: the password manager should
|
||||||
|
float, chat should open on workspace four, a picture viewer should not be
|
||||||
|
resized to fill half the screen. **Application window rules** on Shell › Tiling is where that
|
||||||
|
is written down. You pick an application and tick what should happen to it —
|
||||||
|
float, center it, pin it to every workspace, give it a fixed size or a
|
||||||
|
workspace to open on — rather than writing a match pattern. Panama's own
|
||||||
|
surfaces cannot be targeted, and a rule with nothing ticked is refused rather
|
||||||
|
than saved as a row you would wonder about later.
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
## Start here
|
## Start here
|
||||||
|
|
||||||
Run **Check System Health** from the launcher, or open Settings and go to
|
Run **Check System Health** from the launcher, or open
|
||||||
System Health. It reports what is actually running rather than what was
|
[System Health](panama://settings/services). It reports what is actually running rather than what was
|
||||||
installed, and it can repair several things itself.
|
installed, and it can repair several things itself.
|
||||||
|
|
||||||
From a terminal, the same check is `panama doctor`.
|
From a terminal, the same check is `panama doctor`.
|
||||||
|
|
||||||
## The shell is gone
|
## The shell is gone
|
||||||
|
|
||||||
If the bar and dock disappear, the Quickshell process died. **Restart Shell**
|
If the bar and dock disappear, the Quickshell process died. **Restart Desktop
|
||||||
from the launcher brings it back. If the launcher is also gone, press
|
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
|
`Super + Shift + R` for the fallback launcher, which depends on nothing but
|
||||||
itself and exists for exactly this.
|
itself and exists for exactly this.
|
||||||
|
|
||||||
@@ -20,18 +20,21 @@ From a terminal: `qs -d` starts the shell again.
|
|||||||
## A shortcut stopped working
|
## A shortcut stopped working
|
||||||
|
|
||||||
Check `Super + /` first: if the shortcut shows a different chord than you
|
Check `Super + /` first: if the shortcut shows a different chord than you
|
||||||
expect, something rebound it. The Shortcuts settings page can reset any bind,
|
expect, something rebound it. The Keyboard page, under Input in Settings, can
|
||||||
or all of them at once.
|
reset any bind, or all of them at once.
|
||||||
|
|
||||||
If the whole keymap is missing from that list, the compositor config failed
|
If the whole keymap is missing from that list, the compositor config failed
|
||||||
to load. `Hyprland --verify-config` says why without touching your session.
|
to load. `Hyprland --verify-config` says why without touching your session.
|
||||||
|
|
||||||
## The screen resolution is wrong
|
## The screen resolution is wrong
|
||||||
|
|
||||||
Settings has a Displays page. Every change there reverts itself after fifteen
|
Settings has a [Displays page](panama://settings/displays). It opens on a picture of what is connected —
|
||||||
seconds unless you confirm it, so a mode your monitor cannot show cannot
|
one display or several — and everything under that picture belongs to
|
||||||
strand you. If you are already stranded, `hyprctl monitors` from a terminal
|
whichever one you have selected. Every change there reverts itself after
|
||||||
shows what is applied.
|
fifteen seconds unless you confirm it, so a mode your monitor cannot show
|
||||||
|
cannot strand you, and that covers color, bit depth and mirroring as well as
|
||||||
|
resolution, scale and rotation. 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
|
## Something asked for a password and I do not know why
|
||||||
|
|
||||||
@@ -42,13 +45,37 @@ labelled as an unverified claim, beside the real action.
|
|||||||
If you did not expect it at all, cancel. Nothing that needs privileges is
|
If you did not expect it at all, cancel. Nothing that needs privileges is
|
||||||
harmed by being refused.
|
harmed by being refused.
|
||||||
|
|
||||||
|
## A button did nothing except change its wording
|
||||||
|
|
||||||
|
That is the button working. Anything in Settings that removes something takes
|
||||||
|
two presses: the first arms it, and the row then shows what is about to happen
|
||||||
|
beside a **Keep** that backs out. The second press is the one that does it.
|
||||||
|
|
||||||
|
Only one button in Settings is armed at a time, so arming a second one disarms
|
||||||
|
the first, wherever it was.
|
||||||
|
|
||||||
|
## The network I need is not in the list
|
||||||
|
|
||||||
|
The Wi-Fi list is what is in range. **Saved networks**, further down
|
||||||
|
[Connections](panama://settings/connectivity), is what this machine remembers,
|
||||||
|
including the ones nowhere near you — which is where to tidy up a profile you
|
||||||
|
want rid of, rather than waiting until you are standing next to it again. A
|
||||||
|
network that broadcasts no name is joined with **Join a hidden network…**, at
|
||||||
|
the bottom of the list.
|
||||||
|
|
||||||
|
Opening a connection shows what it actually got — address, gateway, DNS — and
|
||||||
|
is where you give it a fixed address instead when what it got does not work,
|
||||||
|
or mark it metered so updates and large downloads wait until you are somewhere
|
||||||
|
else.
|
||||||
|
|
||||||
## After an update, something is not right
|
## After an update, something is not right
|
||||||
|
|
||||||
`panama migrate` applies repairs this machine has not had yet. It is safe to
|
`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.
|
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
|
If an update went badly, [Snapshots](panama://settings/snapshots) under System can roll
|
||||||
where btrfs snapshots are configured.
|
the system back. That tab is only there where btrfs snapshots are configured,
|
||||||
|
so if you cannot see it, this machine has none to roll back to.
|
||||||
|
|
||||||
## Reporting it
|
## Reporting it
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,137 @@
|
|||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
`Super + I`. Thirty-odd pages covering displays, sound, network, appearance,
|
`Super + I`. Fourteen categories down the side, covering appearance, displays,
|
||||||
power, printers, users, sharing, storage, updates and the rest. The search
|
sound, input, network, power, accounts and the rest. A category with more than
|
||||||
box at the top searches the settings themselves rather than just page names,
|
one subject in it opens a row of tabs above the page, which is where the
|
||||||
so if you know what you want to change you can type it.
|
narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab
|
||||||
|
of Input, and About, Software Update, Storage, Snapshots, Sync & Backup and
|
||||||
|
this manual are tabs of System.
|
||||||
|
|
||||||
|
Home is the first of them, and the page `Super + I` lands on. Its Overview tab
|
||||||
|
is the one you already know; My Home lists your Home Assistant lights by room
|
||||||
|
alongside the accessories Control Center shows, and Phone holds the iPhone
|
||||||
|
handoffs — battery, ring it, send it the clipboard or a file, and Messages.
|
||||||
|
|
||||||
|
The search box at the top searches the settings themselves rather than page
|
||||||
|
names, and each result says where it will land, so if you know what you want
|
||||||
|
to change you can type it and never touch the sidebar.
|
||||||
|
|
||||||
## Appearance
|
## Appearance
|
||||||
|
|
||||||
Panama ships Tokyo Night Moon in a light and a dark variant, with eight
|
The Appearance page opens on **Themes**. At the top are light and dark, side by
|
||||||
accent colours. Changing either repaints the shell, the terminal, GTK
|
side, each showing the theme it will land on; below them are two galleries —
|
||||||
applications, the lock screen and the system monitor at once.
|
six dark themes and four light ones, including Catppuccin, Nord, Gruvbox and
|
||||||
|
Everforest alongside Panama's own Tokyo Night Moon and Day.
|
||||||
|
|
||||||
The wallpaper is per-monitor if you want it to be.
|
Light and dark remember separately. Pick Everforest for the dark side and
|
||||||
|
Catppuccin Latte for the light one, and flipping between them lands on each
|
||||||
|
rather than resetting to the shipped pair.
|
||||||
|
|
||||||
|
Choosing a theme repaints the shell, the terminal, GTK applications, the lock
|
||||||
|
screen and the system monitor at once — not just the shell's own windows.
|
||||||
|
|
||||||
|
### Build your own
|
||||||
|
|
||||||
|
The **Theme editor** tab is four colors: primary, secondary, background and
|
||||||
|
foreground. Each has a swatch, a hex field you can type into, a color wheel
|
||||||
|
and an eyedropper for sampling anything on screen. Background and foreground
|
||||||
|
are not single colors — the panels, popovers, dividers and dimmed text are all
|
||||||
|
mixed from them, so moving the background moves the whole family with it.
|
||||||
|
|
||||||
|
Under that, a saturation slider for the whole palette at once, a fine-tune for
|
||||||
|
the exact hue of each end of the accent, and the effects — corners, blur,
|
||||||
|
shadows, glow and motion. Save it with a name and it appears in the galleries
|
||||||
|
above beside the shipped ones, effects included. Editing a shipped theme does
|
||||||
|
not damage it: it makes you a copy.
|
||||||
|
|
||||||
|
### Wallpapers
|
||||||
|
|
||||||
|
The wallpaper is per-monitor if you want it to be, and it can be a video.
|
||||||
|
|
||||||
|
Videos loop muted and decode on the GPU. They pause themselves while a game is
|
||||||
|
running, and on battery unless you say otherwise; while one is playing a pill
|
||||||
|
appears in the bar, and clicking it pauses immediately — for a call, a
|
||||||
|
recording, or just quiet. The lock screen shows a still frame from the video,
|
||||||
|
because it cannot play motion.
|
||||||
|
|
||||||
|
### Titlebars
|
||||||
|
|
||||||
|
There is no minimize button, on Panama's windows or on GNOME applications.
|
||||||
|
Hyprland has no minimize, so the button would do nothing; maximize means very
|
||||||
|
little in a tiler. You get close, on whichever side you prefer. Panama's own
|
||||||
|
titlebar can be turned off entirely, which leaves the Settings window bare:
|
||||||
|
`Super + Q` closes it, `Super + drag` moves it, `Escape` still works.
|
||||||
|
|
||||||
|
## Shell
|
||||||
|
|
||||||
|
Everything Panama draws on the screen has a tab under **Shell**: the bar, the
|
||||||
|
dock, Control Center, tiling and workspaces. Appearance decides the colors;
|
||||||
|
this decides what is there at all.
|
||||||
|
|
||||||
|
### The bar
|
||||||
|
|
||||||
|
The bar has no background of its own — it floats on your wallpaper. That is
|
||||||
|
fine over most images and hopeless over a few, so the **Bar** tab opens on the
|
||||||
|
three controls that fix it. **Bar text** follows your theme by default, or you
|
||||||
|
can force it light or dark for the wallpaper you actually use. **Bar text
|
||||||
|
shadow** puts a soft halo under every glyph at once. **Bar backdrop** fades a
|
||||||
|
thin scrim down from the top edge. All three land on the real bar above the
|
||||||
|
window as you change them, so you can watch rather than guess.
|
||||||
|
|
||||||
|
Under that, one switch per widget: weather, media, the clipboard button, the
|
||||||
|
calendar countdown, processor, memory, graphics, battery and agent usage. A
|
||||||
|
switch only ever removes something — turning weather on does not make a
|
||||||
|
forecast appear before one has been fetched. The indicators that come and go on
|
||||||
|
their own — system health, microphone and camera, focus, a paused video
|
||||||
|
wallpaper — have no switches, because they are already absent whenever they
|
||||||
|
have nothing to say.
|
||||||
|
|
||||||
|
Seconds and the weekday are here too. **24-hour time** is not: it also changes
|
||||||
|
the date menu, your notification timestamps and the lock screen, so it lives on
|
||||||
|
System › Date & Time with the rest of the clock.
|
||||||
|
|
||||||
|
### The dock
|
||||||
|
|
||||||
|
**Pinned applications** shows the dock as the dock shows it. Drag an icon to
|
||||||
|
move it, hover one for the × that unpins it, and search underneath to add
|
||||||
|
something. The order here is the order on screen.
|
||||||
|
|
||||||
|
The dock itself takes the same gestures now. Drag an icon along the dock to
|
||||||
|
reorder it without opening Settings at all. Right-click one for its menu: every
|
||||||
|
open window of that application listed by title, whatever shortcuts the
|
||||||
|
application itself offers, then a new window, pin or unpin, quit, and **Dock
|
||||||
|
settings** if you want the rest. Scroll on an icon to step through that
|
||||||
|
application's windows. Hover one that has windows open and a preview of each
|
||||||
|
appears after a moment.
|
||||||
|
|
||||||
|
For pinning something that is not running, the launcher has **Add App to
|
||||||
|
Dock** — type it into `Super + Space`, pick the application, done.
|
||||||
|
|
||||||
|
### Control Center
|
||||||
|
|
||||||
|
The panel that opens from the status cluster at the right of the bar. The
|
||||||
|
**Control Center** tab turns its sections on and off: Focus, Home and Phone.
|
||||||
|
Hiding one only hides it from the panel; the settings page behind it still
|
||||||
|
works.
|
||||||
|
|
||||||
|
### Tiling and workspaces
|
||||||
|
|
||||||
|
The last two tabs hold what used to be spread across the old Desktop page.
|
||||||
|
**Tiling** is the window layout, the master area, gaps and window edges, and
|
||||||
|
the Hyprland notices Panama keeps quiet. **Workspaces** is the workspace
|
||||||
|
behaviour, how long a focus session runs, and your saved projects.
|
||||||
|
|
||||||
|
## Carrying settings between machines
|
||||||
|
|
||||||
|
System › **[Sync & Backup](panama://settings/sync)**. Export writes your settings to a file; importing
|
||||||
|
one shows you exactly what would change before anything does, and you decide
|
||||||
|
then. Below it, **Settings backups** keeps dated copies you can restore from —
|
||||||
|
these are your settings, not btrfs Snapshots, which is a different tab and
|
||||||
|
covers the whole filesystem. **Restore defaults** is at the bottom: it resets
|
||||||
|
appearance, the dock and the applications pinned to it, clock, focus and
|
||||||
|
display policy, removes any themes you saved, and clears your Home accessory
|
||||||
|
arrangement. Your files, paired devices and settings backups are left alone.
|
||||||
|
|
||||||
## Applications
|
## Applications
|
||||||
|
|
||||||
@@ -39,4 +158,4 @@ without editing a file the repository will update.
|
|||||||
|
|
||||||
The shell is QML under `~/.config/quickshell`. Both directories are symlinks
|
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
|
into the Panama repository, so an edit is a change to your checkout and
|
||||||
`panama update` will offer to commit it.
|
`panama sync` will offer to commit it.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Pill {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: PrivacyState.activeKinds.length > 1
|
visible: PrivacyState.activeKinds.length > 1
|
||||||
text: String(PrivacyState.activeKinds.length)
|
text: String(PrivacyState.activeKinds.length)
|
||||||
color: Theme.fg
|
color: Theme.barFg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
|||||||
@@ -0,0 +1,592 @@
|
|||||||
|
// The whole story behind the bar's one number.
|
||||||
|
//
|
||||||
|
// The bar shows the fullest window across every agent, because that is the one
|
||||||
|
// about to interrupt you. This is what that number is made of: each agent's
|
||||||
|
// limits with their reset times, what today cost, and where the tokens went.
|
||||||
|
//
|
||||||
|
// A Popover anchored under the widget, the way TrayMenu hangs off a tray icon —
|
||||||
|
// the house pattern for anything that belongs to a bar item rather than to the
|
||||||
|
// shell. Clicking outside closes it; so does Escape, which Popover's focus grab
|
||||||
|
// handles for every popover in the shell.
|
||||||
|
//
|
||||||
|
// Nothing in here animates on a timer. One 30-second tick advances the clock
|
||||||
|
// that "updated 4 minutes ago" and the reset countdowns read, and it only runs
|
||||||
|
// while the panel is open.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Popover {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
implicitWidth: Theme.popoverWidth
|
||||||
|
implicitHeight: body.implicitHeight + contentPadding * 2
|
||||||
|
|
||||||
|
// Which agent's tab is showing. Empty means "whichever is first", so the
|
||||||
|
// panel is never blank because a collector was switched off between opens.
|
||||||
|
property string selectedId: ""
|
||||||
|
|
||||||
|
readonly property var agents: AgentUsage.readyRecords
|
||||||
|
|
||||||
|
readonly property var record: {
|
||||||
|
const list = root.agents;
|
||||||
|
if (list.length === 0)
|
||||||
|
return null;
|
||||||
|
for (const candidate of list)
|
||||||
|
if (candidate.id === root.selectedId)
|
||||||
|
return candidate;
|
||||||
|
return list[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// One clock for the whole panel, ticking only while it is open. Every
|
||||||
|
// elapsed-time and countdown string in here reads this instead of calling
|
||||||
|
// Date.now() in a binding, which would never invalidate.
|
||||||
|
property double nowMs: Date.now()
|
||||||
|
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (root.visible) {
|
||||||
|
root.nowMs = Date.now();
|
||||||
|
// The local scans can be reused; the limits are what the panel is
|
||||||
|
// being opened to read.
|
||||||
|
AgentUsage.refreshLimits();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 30 * 1000
|
||||||
|
running: root.visible
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.nowMs = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function tokenText(value: double): string {
|
||||||
|
const n = Number(value) || 0;
|
||||||
|
if (n >= 1e9)
|
||||||
|
return (n / 1e9).toFixed(1) + "B";
|
||||||
|
if (n >= 1e6)
|
||||||
|
return (n / 1e6).toFixed(1) + "M";
|
||||||
|
if (n >= 1e3)
|
||||||
|
return Math.round(n / 1e3) + "k";
|
||||||
|
return String(Math.round(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentText(fraction: real): string {
|
||||||
|
return Math.round(Math.min(1, Math.max(0, Number(fraction) || 0)) * 100) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
function meterColor(fraction: real): color {
|
||||||
|
const percent = (Number(fraction) || 0) * 100;
|
||||||
|
if (percent >= 90)
|
||||||
|
return Theme.danger;
|
||||||
|
if (percent >= 75)
|
||||||
|
return Theme.warn;
|
||||||
|
return Theme.accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTime(iso: string): double {
|
||||||
|
const parsed = Date.parse(String(iso ?? ""));
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Updated 4 minutes ago". A record with no timestamp says so rather than
|
||||||
|
// implying it is current.
|
||||||
|
function agoText(iso: string): string {
|
||||||
|
const at = root.parseTime(iso);
|
||||||
|
if (at <= 0)
|
||||||
|
return "Never collected";
|
||||||
|
const minutes = Math.floor(Math.max(0, root.nowMs - at) / 60000);
|
||||||
|
if (minutes < 1)
|
||||||
|
return "Updated just now";
|
||||||
|
if (minutes === 1)
|
||||||
|
return "Updated 1 minute ago";
|
||||||
|
if (minutes < 60)
|
||||||
|
return `Updated ${minutes} minutes ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return hours === 1 ? "Updated 1 hour ago" : `Updated ${hours} hours ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "resets 2:40 pm" for something today, "resets Thu" for something further
|
||||||
|
// out. A window whose reset has passed says so rather than counting into
|
||||||
|
// the negative — the collector keeps a cached limit only until its window
|
||||||
|
// rolls over, so this is a record caught mid-rollover.
|
||||||
|
function resetText(iso: string): string {
|
||||||
|
const at = root.parseTime(iso);
|
||||||
|
if (at <= 0)
|
||||||
|
return "";
|
||||||
|
if (at <= root.nowMs)
|
||||||
|
return "resetting";
|
||||||
|
const when = new Date(at);
|
||||||
|
const sameDay = new Date(root.nowMs).toDateString() === when.toDateString();
|
||||||
|
if (sameDay)
|
||||||
|
return "resets " + when.toLocaleTimeString(Qt.locale(), "h:mm ap");
|
||||||
|
return "resets " + when.toLocaleDateString(Qt.locale(), "ddd");
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayText(date: string): string {
|
||||||
|
const parts = String(date ?? "").split("-");
|
||||||
|
if (parts.length !== 3)
|
||||||
|
return "";
|
||||||
|
const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||||
|
return when.toLocaleDateString(Qt.locale(), "ddd").slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(date: string): bool {
|
||||||
|
const parts = String(date ?? "").split("-");
|
||||||
|
if (parts.length !== 3)
|
||||||
|
return false;
|
||||||
|
const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||||
|
return when.toDateString() === new Date(root.nowMs).toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived views of the selected record ────────────────────────────────
|
||||||
|
|
||||||
|
readonly property var limits: {
|
||||||
|
const entries = root.record && Array.isArray(root.record.limits) ? root.record.limits : [];
|
||||||
|
return entries.filter(entry => entry && Number.isFinite(Number(entry.percent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var days: {
|
||||||
|
const entries = root.record && Array.isArray(root.record.recentDays) ? root.record.recentDays : [];
|
||||||
|
// recentDays.messageCount is a token total, despite the legacy name the
|
||||||
|
// collectors inherited.
|
||||||
|
return entries.map(day => ({
|
||||||
|
date: String(day?.date ?? ""),
|
||||||
|
tokens: Number(day?.messageCount) || 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property double dayPeak: {
|
||||||
|
let peak = 0;
|
||||||
|
for (const day of root.days)
|
||||||
|
peak = Math.max(peak, day.tokens);
|
||||||
|
return peak;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top models by total tokens. Five rows is the whole point of the section:
|
||||||
|
// more than that and it stops being a glance.
|
||||||
|
readonly property var models: {
|
||||||
|
const usage = root.record?.modelUsage;
|
||||||
|
if (!usage || typeof usage !== "object")
|
||||||
|
return [];
|
||||||
|
const rows = [];
|
||||||
|
for (const name of Object.keys(usage)) {
|
||||||
|
const bucket = usage[name] || {};
|
||||||
|
const total = (Number(bucket.inputTokens) || 0)
|
||||||
|
+ (Number(bucket.outputTokens) || 0)
|
||||||
|
+ (Number(bucket.cacheReadInputTokens) || 0)
|
||||||
|
+ (Number(bucket.cacheCreationInputTokens) || 0);
|
||||||
|
if (total > 0)
|
||||||
|
rows.push({ name: name, tokens: total });
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => b.tokens - a.tokens);
|
||||||
|
return rows.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property double modelPeak: root.models.length > 0 ? root.models[0].tokens : 0
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: body
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
// ── Agent tabs ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Only worth drawing when there is a choice to make.
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
visible: root.agents.length > 1
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.agents
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: tab
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool current: root.record && root.record.id === tab.modelData.id
|
||||||
|
|
||||||
|
width: (body.width - 6 * (root.agents.length - 1)) / root.agents.length
|
||||||
|
height: 28
|
||||||
|
radius: 9
|
||||||
|
border.width: tab.current ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.3)
|
||||||
|
color: tab.current
|
||||||
|
? Theme.alpha(Theme.accent, 0.14)
|
||||||
|
: (tabMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.05))
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: tab.modelData.name || tab.modelData.id
|
||||||
|
color: tab.current ? Theme.fg : Theme.fgDim
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: tabMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.selectedId = String(tab.modelData.id ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Who, and how current ────────────────────────────────────────────
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: Math.max(heroGlyph.implicitHeight, heroName.implicitHeight, tierChip.implicitHeight)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: heroGlyph
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "\u{F1719}" // md-robot-outline
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: heroName
|
||||||
|
anchors.left: heroGlyph.right
|
||||||
|
anchors.leftMargin: 9
|
||||||
|
anchors.right: tierChip.visible ? tierChip.left : parent.right
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.record?.name ?? "Agent usage"
|
||||||
|
color: Theme.fg
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plan, when the collector could name one. It is the only thing
|
||||||
|
// from the credential store allowed into a record.
|
||||||
|
Rectangle {
|
||||||
|
id: tierChip
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: String(root.record?.tierLabel ?? "") !== ""
|
||||||
|
implicitWidth: tierText.implicitWidth + 18
|
||||||
|
implicitHeight: tierText.implicitHeight + 6
|
||||||
|
radius: Theme.pillRadius
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.25)
|
||||||
|
color: Theme.alpha(Theme.accent, 0.1)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: tierText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: String(root.record?.tierLabel ?? "").toUpperCase()
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.agoText(root.record?.updatedAt)
|
||||||
|
color: Theme.fgMuted
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── An honest word when the numbers are not authoritative ───────────
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
visible: String(root.record?.usageStatusText ?? "") !== ""
|
||||||
|
implicitHeight: statusColumn.implicitHeight + 18
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
border.width: 0
|
||||||
|
color: Theme.alpha(Theme.warn, 0.12)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: statusColumn
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.record?.usageStatusText ?? ""
|
||||||
|
color: Theme.warn
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: String(root.record?.authHelpText ?? "") !== ""
|
||||||
|
text: root.record?.authHelpText ?? ""
|
||||||
|
color: Theme.fgDim
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Limits ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Model-scoped windows sit in the same list as the flat ones: the
|
||||||
|
// collector settles which window an entry belongs to and titles it, so
|
||||||
|
// "Fable Weekly" reads beside "Weekly (7-day)" rather than under it.
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.limits
|
||||||
|
|
||||||
|
delegate: Column {
|
||||||
|
id: limitRow
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: body.width
|
||||||
|
topPadding: 5
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: limitLabel.implicitHeight
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: limitLabel
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: limitValue.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
text: limitRow.modelData.label ?? "Limit"
|
||||||
|
color: Theme.fg
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: limitValue
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.baseline: limitLabel.baseline
|
||||||
|
text: {
|
||||||
|
const reset = root.resetText(limitRow.modelData.resetsAt);
|
||||||
|
const percent = root.percentText(limitRow.modelData.percent);
|
||||||
|
return reset ? reset + " · " + percent : percent;
|
||||||
|
}
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 8
|
||||||
|
radius: 4
|
||||||
|
border.width: 0
|
||||||
|
color: Theme.alpha(Theme.fg, 0.08)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: Math.max(0, Math.min(1, Number(limitRow.modelData.percent) || 0)) * parent.width
|
||||||
|
height: parent.height
|
||||||
|
radius: parent.radius
|
||||||
|
border.width: 0
|
||||||
|
color: root.meterColor(limitRow.modelData.percent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.days.length > 0 || root.models.length > 0
|
||||||
|
height: 1
|
||||||
|
color: Theme.alpha(Theme.fg, 0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tokens, last seven days ─────────────────────────────────────────
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.dayPeak > 0
|
||||||
|
implicitHeight: dayHeader.implicitHeight + 8 + 56
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: dayHeader
|
||||||
|
anchors.left: parent.left
|
||||||
|
text: "Tokens, last 7 days"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.baseline: dayHeader.baseline
|
||||||
|
text: "today " + root.tokenText(root.record?.todayTotalTokens ?? 0)
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 56
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.days
|
||||||
|
|
||||||
|
delegate: Column {
|
||||||
|
id: dayColumn
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool today: root.isToday(dayColumn.modelData.date)
|
||||||
|
|
||||||
|
width: (body.width - 6 * 6) / 7
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: 56 - 4 - dayLabel.implicitHeight
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: Math.max(
|
||||||
|
dayColumn.modelData.tokens > 0 ? 2 : 0,
|
||||||
|
root.dayPeak > 0 ? (dayColumn.modelData.tokens / root.dayPeak) * parent.height : 0)
|
||||||
|
radius: 4
|
||||||
|
border.width: 0
|
||||||
|
color: dayColumn.today ? Theme.accent : Theme.alpha(Theme.accent, 0.35)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: dayLabel
|
||||||
|
width: parent.width
|
||||||
|
text: root.weekdayText(dayColumn.modelData.date)
|
||||||
|
color: dayColumn.today ? Theme.fgDim : Theme.fgMuted
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
|
||||||
|
font.weight: dayColumn.today ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Where the tokens went ───────────────────────────────────────────
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.models.length > 0
|
||||||
|
topPadding: 4
|
||||||
|
text: "By model"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.models
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: modelRow
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: body.width
|
||||||
|
implicitHeight: 18
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: modelName
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 108
|
||||||
|
text: modelRow.modelData.name
|
||||||
|
color: Theme.fgDim
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.left: modelName.right
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.right: modelValue.left
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
height: 6
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: root.modelPeak > 0
|
||||||
|
? Math.max(2, (modelRow.modelData.tokens / root.modelPeak) * parent.width)
|
||||||
|
: 0
|
||||||
|
height: parent.height
|
||||||
|
radius: 3
|
||||||
|
border.width: 0
|
||||||
|
color: Theme.alpha(Theme.accentAlt, 0.55)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: modelValue
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 52
|
||||||
|
text: root.tokenText(modelRow.modelData.tokens)
|
||||||
|
color: Theme.fgDim
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sessions, when that is all there is ─────────────────────────────
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.dayPeak <= 0 && root.models.length === 0 && root.record !== null
|
||||||
|
text: `Sessions today: ${root.record?.todaySessions ?? 0}`
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.record === null
|
||||||
|
text: "No collector has anything to report yet."
|
||||||
|
color: Theme.fgDim
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// How much of the Claude subscription is gone, beside the other vitals.
|
// How much of the busiest agent subscription is gone, beside the other vitals.
|
||||||
//
|
//
|
||||||
// One number: whichever window is closer to its limit, because that is the one
|
// One number: whichever window across every collected agent is closest to its
|
||||||
// about to interrupt you.
|
// limit, because that is the one about to interrupt you.
|
||||||
//
|
//
|
||||||
// Hidden unless asked for AND the collector has real numbers. A bar indicator
|
// Hidden unless asked for AND a collector has real numbers. A bar indicator
|
||||||
// reading "unknown" is worse than an empty space, and this is off by default:
|
// 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
|
// it is a coding-tool readout, not something a general-purpose desktop shows
|
||||||
// without being asked.
|
// without being asked.
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
// came out sitting off-centre against the rest of the bar.
|
// came out sitting off-centre against the rest of the bar.
|
||||||
//
|
//
|
||||||
// Clickable, because a readout you cannot ask anything of is furniture. Left
|
// Clickable, because a readout you cannot ask anything of is furniture. Left
|
||||||
// click opens the settings that govern it; hovering says which window the
|
// click opens the panel behind the number; right click opens the settings that
|
||||||
// number belongs to and when it resets.
|
// govern it.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
@@ -27,8 +27,8 @@ Pill {
|
|||||||
|
|
||||||
visible: Settings.showAgentUsage && AgentUsage.available
|
visible: Settings.showAgentUsage && AgentUsage.available
|
||||||
|
|
||||||
onActivated: ShellState.openSettings("appearance")
|
onActivated: panel.visible = !panel.visible
|
||||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
onSecondaryActivated: ShellState.openSettings("bar")
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
spacing: 4
|
spacing: 4
|
||||||
@@ -39,7 +39,7 @@ Pill {
|
|||||||
color: {
|
color: {
|
||||||
if (AgentUsage.headline >= 90) return Theme.danger;
|
if (AgentUsage.headline >= 90) return Theme.danger;
|
||||||
if (AgentUsage.headline >= 75) return Theme.warn;
|
if (AgentUsage.headline >= 75) return Theme.warn;
|
||||||
return Theme.fgDim;
|
return Theme.barFgDim;
|
||||||
}
|
}
|
||||||
font.family: Theme.fontMono
|
font.family: Theme.fontMono
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
@@ -48,7 +48,7 @@ Pill {
|
|||||||
Text {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: AgentUsage.headline + "%"
|
text: AgentUsage.headline + "%"
|
||||||
color: Theme.fg
|
color: Theme.barFg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
@@ -58,4 +58,11 @@ Pill {
|
|||||||
width: 30
|
width: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hangs off this pill the way TrayMenu hangs off a tray icon. A PopupWindow
|
||||||
|
// is not an Item, so it takes no space in Pill's layout Row.
|
||||||
|
AgentUsagePanel {
|
||||||
|
id: panel
|
||||||
|
anchorItem: root
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import QtQuick.Effects
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.modules.clipboard
|
import qs.modules.clipboard
|
||||||
import qs.modules.focus
|
import qs.modules.focus
|
||||||
@@ -62,11 +63,44 @@ PanelWindow {
|
|||||||
item: barContent
|
item: barContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A scrim for wallpapers the bar cannot win against on tone alone. Drawn
|
||||||
|
// behind the content rows, fading out downward so the bar still has no
|
||||||
|
// hard edge of its own — the point is to darken what is under the text,
|
||||||
|
// not to give the bar a surface. Static: a gradient that never changes
|
||||||
|
// costs one paint, and this one has nothing to animate.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: Settings.barBackdrop
|
||||||
|
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop {
|
||||||
|
position: 0.0
|
||||||
|
color: Theme.alpha("#0c0e18", 0.55)
|
||||||
|
}
|
||||||
|
GradientStop {
|
||||||
|
position: 1.0
|
||||||
|
color: "transparent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: barContent
|
id: barContent
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
|
// One shadow for the whole bar rather than one per widget: the content
|
||||||
|
// is flattened into a single layer and the halo is drawn under it, so
|
||||||
|
// every glyph and label picks it up and nothing has to opt in. Off by
|
||||||
|
// default — it only earns its layer on a busy wallpaper.
|
||||||
|
layer.enabled: Settings.barTextShadow
|
||||||
|
layer.effect: MultiEffect {
|
||||||
|
shadowEnabled: true
|
||||||
|
shadowColor: "#0a0c14"
|
||||||
|
shadowBlur: 0.6
|
||||||
|
shadowVerticalOffset: 1
|
||||||
|
}
|
||||||
|
|
||||||
// ── Left ────────────────────────────────────────────────────────────
|
// ── Left ────────────────────────────────────────────────────────────
|
||||||
Row {
|
Row {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -78,7 +112,9 @@ PanelWindow {
|
|||||||
screen: root.screen
|
screen: root.screen
|
||||||
}
|
}
|
||||||
|
|
||||||
AgentUsageWidget {}
|
AgentUsageWidget {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
VitalsWidget {
|
VitalsWidget {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -118,6 +154,10 @@ PanelWindow {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WallpaperIndicator {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
FocusIndicator {
|
FocusIndicator {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import qs.widgets
|
|||||||
Pill {
|
Pill {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
visible: CalendarAgenda.capsuleVisible
|
visible: Settings.showCalendarCountdown && CalendarAgenda.capsuleVisible
|
||||||
opacity: visible ? 1 : 0
|
opacity: visible ? 1 : 0
|
||||||
horizontalPadding: 8
|
horizontalPadding: 8
|
||||||
onActivated: ShellState.openDateMenu("agenda")
|
onActivated: ShellState.openDateMenu("agenda")
|
||||||
@@ -47,7 +47,7 @@ Pill {
|
|||||||
Text {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: CalendarAgenda.capsuleText
|
text: CalendarAgenda.capsuleText
|
||||||
color: Theme.fg
|
color: Theme.barFg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Pill {
|
|||||||
// figures the whole clock shifts sideways on every digit change.
|
// figures the whole clock shifts sideways on every digit change.
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
color: Theme.fg
|
color: Theme.barFg
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Pill {
|
|||||||
return artist ? artist + " — " + title : title;
|
return artist ? artist + " — " + title : title;
|
||||||
}
|
}
|
||||||
|
|
||||||
visible: root.player !== null
|
visible: Settings.showMediaWidget && root.player !== null
|
||||||
horizontalPadding: 8
|
horizontalPadding: 8
|
||||||
|
|
||||||
onActivated: if (root.player?.canTogglePlaying)
|
onActivated: if (root.player?.canTogglePlaying)
|
||||||
@@ -63,7 +63,7 @@ Pill {
|
|||||||
text: root.label
|
text: root.label
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
color: Theme.fg
|
color: Theme.barFg
|
||||||
|
|
||||||
// Titles are unbounded; the bar is not. Elide rather than let one
|
// Titles are unbounded; the bar is not. Elide rather than let one
|
||||||
// podcast episode push the tray off the edge.
|
// podcast episode push the tray off the edge.
|
||||||
|
|||||||
@@ -77,13 +77,27 @@ Pill {
|
|||||||
|
|
||||||
color: {
|
color: {
|
||||||
if (root.wiredDevice)
|
if (root.wiredDevice)
|
||||||
return Theme.fg;
|
return Theme.barFg;
|
||||||
if (!root.wifiNetwork)
|
if (!root.wifiNetwork)
|
||||||
return Theme.fgMuted;
|
return Theme.barFgMuted;
|
||||||
return Networking.connectivity === NetworkConnectivity.Full ? Theme.fg : Theme.warn;
|
return Networking.connectivity === NetworkConnectivity.Full ? Theme.barFg : Theme.warn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The active layout, only where there is a choice to indicate: a machine
|
||||||
|
// with one layout knows what its keys say. GNOME shows the same short
|
||||||
|
// code in the same corner, which is the muscle memory this preserves.
|
||||||
|
Text {
|
||||||
|
visible: KeyboardLayout.multiple
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: KeyboardLayout.shortLabel
|
||||||
|
color: Theme.barFg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
// A tunnel that is up changes what every connection means, so it earns a
|
// 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,
|
// permanent glyph while active -- and its absence is the resting state,
|
||||||
// same shape as Bluetooth below.
|
// same shape as Bluetooth below.
|
||||||
@@ -103,7 +117,7 @@ Pill {
|
|||||||
return "\u{F0580}"; // md-volume_medium
|
return "\u{F0580}"; // md-volume_medium
|
||||||
return "\u{F057E}"; // md-volume_high
|
return "\u{F057E}"; // md-volume_high
|
||||||
}
|
}
|
||||||
color: root.muted ? Theme.fgMuted : Theme.fg
|
color: root.muted ? Theme.barFgMuted : Theme.barFg
|
||||||
}
|
}
|
||||||
|
|
||||||
StatusGlyph {
|
StatusGlyph {
|
||||||
@@ -111,7 +125,7 @@ Pill {
|
|||||||
// simply absent rather than shown crossed out.
|
// simply absent rather than shown crossed out.
|
||||||
visible: root.bluetoothOn
|
visible: root.bluetoothOn
|
||||||
glyph: root.bluetoothConnected ? "\u{F00B1}" : "\u{F00AF}" // md-bluetooth_connect / md-bluetooth
|
glyph: root.bluetoothConnected ? "\u{F00B1}" : "\u{F00AF}" // md-bluetooth_connect / md-bluetooth
|
||||||
color: root.bluetoothConnected ? Theme.accent : Theme.fg
|
color: root.bluetoothConnected ? Theme.accent : Theme.barFg
|
||||||
}
|
}
|
||||||
|
|
||||||
StatusGlyph {
|
StatusGlyph {
|
||||||
@@ -137,7 +151,7 @@ Pill {
|
|||||||
if (Battery.critical) return Theme.danger;
|
if (Battery.critical) return Theme.danger;
|
||||||
if (Battery.low) return Theme.warn;
|
if (Battery.low) return Theme.warn;
|
||||||
if (Battery.charging) return Theme.ok;
|
if (Battery.charging) return Theme.ok;
|
||||||
return Theme.fg;
|
return Theme.barFg;
|
||||||
}
|
}
|
||||||
|
|
||||||
StatusGlyph {
|
StatusGlyph {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Text {
|
|||||||
font.pixelSize: Theme.fontSizeLarge
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
width: 18
|
width: 18
|
||||||
color: Theme.fg
|
color: Theme.barFg
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
ColorAnimation {
|
ColorAnimation {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Row {
|
|||||||
text: root.glyph
|
text: root.glyph
|
||||||
font.family: Theme.fontMono
|
font.family: Theme.fontMono
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
color: Theme.fgDim
|
color: Theme.barFgDim
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
@@ -41,7 +41,7 @@ Row {
|
|||||||
return Theme.danger;
|
return Theme.danger;
|
||||||
if (root.value >= root.warnAt)
|
if (root.value >= root.warnAt)
|
||||||
return Theme.warn;
|
return Theme.warn;
|
||||||
return Theme.fg;
|
return Theme.barFg;
|
||||||
}
|
}
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ Pill {
|
|||||||
|
|
||||||
// Right-click opens the settings that govern this widget. Which readouts
|
// Right-click opens the settings that govern this widget. Which readouts
|
||||||
// appear in the bar, and how often they update.
|
// appear in the bar, and how often they update.
|
||||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
onSecondaryActivated: ShellState.openSettings("bar")
|
||||||
|
|
||||||
|
// Turning off all three readouts should remove the pill, not leave an empty
|
||||||
|
// one behind: an invisible child still occupies its Row, so without this the
|
||||||
|
// padding stays and the bar keeps a gap that reports nothing.
|
||||||
|
visible: Settings.showCpu || Settings.showMemory || (Settings.showGpu && Vitals.gpuAvailable)
|
||||||
|
|
||||||
interactive: false
|
interactive: false
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Visible only while a video wallpaper is active: one click pauses or resumes
|
||||||
|
// it. Exists because a playing wallpaper has real costs the user may want to
|
||||||
|
// stop right now — a remote desktop session, a recording, or just quiet —
|
||||||
|
// without opening Settings. Same conditional pattern as ActivityIndicator.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Pill {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
visible: VideoWallpaper.active
|
||||||
|
horizontalPadding: 10
|
||||||
|
|
||||||
|
onActivated: VideoWallpaper.togglePause()
|
||||||
|
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||||
|
|
||||||
|
Accessible.name: VideoWallpaper.paused ? "Resume video wallpaper" : "Pause video wallpaper"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: VideoWallpaper.paused ? "\u{F040A}" : "\u{F03E4}" // play / pause
|
||||||
|
color: VideoWallpaper.paused ? Theme.barFgDim : Theme.warn
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: VideoWallpaper.paused
|
||||||
|
? (VideoWallpaper.gamePaused ? "Paused for game"
|
||||||
|
: (VideoWallpaper.batteryPaused ? "Paused on battery" : "Paused"))
|
||||||
|
: "Wallpaper"
|
||||||
|
color: Theme.barFgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ Pill {
|
|||||||
onSecondaryActivated: ShellState.openSettings("home")
|
onSecondaryActivated: ShellState.openSettings("home")
|
||||||
|
|
||||||
interactive: false
|
interactive: false
|
||||||
visible: Weather.available
|
visible: Settings.showWeatherWidget && Weather.available
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -29,6 +29,6 @@ Pill {
|
|||||||
text: Math.round(Weather.temperature) + Weather.unitSuffix
|
text: Math.round(Weather.temperature) + Weather.unitSuffix
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
color: Theme.fg
|
color: Theme.barFg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ Item {
|
|||||||
color: {
|
color: {
|
||||||
if (slot.modelData.urgent)
|
if (slot.modelData.urgent)
|
||||||
return Theme.urgent;
|
return Theme.urgent;
|
||||||
return slot.isOccupied ? Theme.fg : Theme.alpha(Theme.fg, 0.3);
|
return slot.isOccupied ? Theme.barFg : Theme.alpha(Theme.barFg, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import qs.widgets
|
|||||||
Pill {
|
Pill {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// Hiding the button does not retire the feature: Super+V still opens the
|
||||||
|
// history. This only reclaims the space in the bar.
|
||||||
|
visible: Settings.showClipboardButton
|
||||||
|
|
||||||
implicitWidth: 30
|
implicitWidth: 30
|
||||||
implicitHeight: 28
|
implicitHeight: 28
|
||||||
|
|
||||||
@@ -19,6 +23,6 @@ Pill {
|
|||||||
size: 16
|
size: 16
|
||||||
icon: "edit-paste-symbolic"
|
icon: "edit-paste-symbolic"
|
||||||
iconFallback: "edit-copy-symbolic"
|
iconFallback: "edit-copy-symbolic"
|
||||||
tint: ShellState.clipboardOpen ? Theme.accent : Theme.fg
|
tint: ShellState.clipboardOpen ? Theme.accent : Theme.barFg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,16 @@ PanelWindow {
|
|||||||
return !!ws && ws.toplevels.values.length > 0;
|
return !!ws && ws.toplevels.values.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered
|
// Anything anchored to the dock that the dock would drag off-screen with
|
||||||
|
// it. The pointer leaves the dock the moment it enters an open menu -- the
|
||||||
|
// menu is its own surface -- so without this the dock slides away under
|
||||||
|
// the menu it opened, and the row the hand was reaching for goes with it.
|
||||||
|
// The same is true mid-drag, and while a preview is up.
|
||||||
|
readonly property bool interactionHeld: dockContextMenu.visible
|
||||||
|
|| body.dragging
|
||||||
|
|| dockPreviews.visible
|
||||||
|
|
||||||
|
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered || root.interactionHeld
|
||||||
|
|
||||||
property bool revealed: true
|
property bool revealed: true
|
||||||
|
|
||||||
@@ -120,16 +129,34 @@ PanelWindow {
|
|||||||
onTriggered: root.revealed = false
|
onTriggered: root.revealed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other modules (the bar, the capture overlay) read this. It is one
|
// ShellState.dockRevealed is one shared flag and there is one Dock per
|
||||||
// shared flag but there is one Dock per monitor, so only the instance on
|
// monitor, so exactly one instance may write it -- otherwise whichever
|
||||||
// the currently-focused monitor is allowed to write it -- otherwise
|
// instance last changed reveal state stomps the others and a reader gets an
|
||||||
// whichever instance last changed reveal state would stomp the others,
|
// arbitrary monitor's answer. Scoped this way the flag means "is the dock
|
||||||
// and a reader would see an arbitrary monitor's value. This scopes the
|
// revealed on the monitor the user is on", which is the only question a
|
||||||
// flag to mean "is the dock revealed on the monitor the user is on",
|
// reader outside the dock can sensibly ask of it. (A genuinely per-monitor
|
||||||
// which is what a capture overlay or the bar actually care about.
|
// answer would need the property itself keyed by screen.)
|
||||||
// (A true per-monitor flag would need ShellState.dockRevealed itself to
|
//
|
||||||
// become keyed by screen, which is out of scope here -- see the report.)
|
// Two things disqualify an instance. One is not being on the focused
|
||||||
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
|
// monitor, which is the whole point. The other is not being on screen at
|
||||||
|
// all: a Dock whose screen is not in `dockScreens` is invisible, and an
|
||||||
|
// invisible dock reporting itself as revealed is a lie a reader acts on. An
|
||||||
|
// instance whose monitor could not be resolved -- created standalone, or
|
||||||
|
// asked before Hyprland has reported the screen -- knows nothing about
|
||||||
|
// which monitor the user is on, so it writes only when there is no focused
|
||||||
|
// monitor to be wrong about rather than stomping the instance that knows.
|
||||||
|
//
|
||||||
|
// Nothing reads the flag today: the bar and the capture overlay it was
|
||||||
|
// written for both stopped. It is written correctly rather than left
|
||||||
|
// half-wrong, and when ShellState is next opened the property, this
|
||||||
|
// arbitration and _syncShellState should go together.
|
||||||
|
readonly property bool isFocusedMonitorInstance: {
|
||||||
|
if (!root.onThisScreen)
|
||||||
|
return false;
|
||||||
|
if (root.monitor !== null)
|
||||||
|
return root.monitor === Hyprland.focusedMonitor;
|
||||||
|
return Hyprland.focusedMonitor === null;
|
||||||
|
}
|
||||||
|
|
||||||
onRevealedChanged: root._syncShellState()
|
onRevealedChanged: root._syncShellState()
|
||||||
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
||||||
@@ -174,6 +201,33 @@ PanelWindow {
|
|||||||
Item {
|
Item {
|
||||||
id: maskItem
|
id: maskItem
|
||||||
|
|
||||||
|
// Where the body comes to rest once the slide finishes, mirroring
|
||||||
|
// DockBody's own x/y bindings in their revealed case.
|
||||||
|
//
|
||||||
|
// The revealed region is built from these rather than from the
|
||||||
|
// body's live position. The body slides in over a couple of hundred
|
||||||
|
// milliseconds, and a region that follows it in is a region that is
|
||||||
|
// a few pixels tall on the frame the dock is summoned -- the exact
|
||||||
|
// frame the pointer that summoned it needs to be inside, and the
|
||||||
|
// exact frame it drops the hover and sends the dock back. Following
|
||||||
|
// the animation also means an input-region commit to the compositor
|
||||||
|
// on every one of those frames, for a rectangle that is only right
|
||||||
|
// on the last of them.
|
||||||
|
//
|
||||||
|
// Clamped rather than assigned outright so the mask still tracks
|
||||||
|
// the body if anything else ever moves it: the reveal only ever
|
||||||
|
// approaches these values from outside the screen edge.
|
||||||
|
readonly property real settledX: {
|
||||||
|
if (!root.vertical)
|
||||||
|
return body.x;
|
||||||
|
return root.position === "left"
|
||||||
|
? Math.max(body.x, root.tooltipSpace)
|
||||||
|
: Math.min(body.x, surface.width - body.width - root.tooltipSpace);
|
||||||
|
}
|
||||||
|
readonly property real settledY: root.vertical
|
||||||
|
? body.y
|
||||||
|
: Math.min(body.y, root.tooltipSpace)
|
||||||
|
|
||||||
x: {
|
x: {
|
||||||
if (!root.revealed)
|
if (!root.revealed)
|
||||||
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
|
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
|
||||||
@@ -185,35 +239,41 @@ PanelWindow {
|
|||||||
// to its own edge, which is x 0 on the left and the body on
|
// to its own edge, which is x 0 on the left and the body on
|
||||||
// the right.
|
// the right.
|
||||||
if (!root.vertical)
|
if (!root.vertical)
|
||||||
return body.x;
|
return maskItem.settledX;
|
||||||
return root.position === "right" ? body.x : 0;
|
return root.position === "right" ? maskItem.settledX : 0;
|
||||||
}
|
}
|
||||||
y: {
|
y: {
|
||||||
if (!root.revealed)
|
if (!root.revealed)
|
||||||
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
||||||
return body.y;
|
return maskItem.settledY;
|
||||||
}
|
}
|
||||||
width: {
|
width: {
|
||||||
if (!root.revealed)
|
if (!root.revealed)
|
||||||
return root.vertical ? root.revealStripHeight : surface.width;
|
return root.vertical ? root.revealStripHeight : surface.width;
|
||||||
return root.vertical
|
return root.vertical
|
||||||
? (root.position === "right" ? surface.width - body.x : body.x + body.width)
|
? (root.position === "right"
|
||||||
|
? surface.width - maskItem.settledX
|
||||||
|
: maskItem.settledX + body.width)
|
||||||
: body.width;
|
: body.width;
|
||||||
}
|
}
|
||||||
height: {
|
height: {
|
||||||
if (!root.revealed)
|
if (!root.revealed)
|
||||||
return root.vertical ? surface.height : root.revealStripHeight;
|
return root.vertical ? surface.height : root.revealStripHeight;
|
||||||
return root.vertical ? body.height : surface.height - body.y;
|
return root.vertical ? body.height : surface.height - maskItem.settledY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DockBody {
|
DockBody {
|
||||||
id: body
|
id: body
|
||||||
|
|
||||||
onContextMenuRequested: (anchorItem, entry) => {
|
onContextMenuRequested: (anchorItem, app) => {
|
||||||
|
// The whole app object, not its desktop entry: the menu lists
|
||||||
|
// the app's own windows and offers to pin or unpin it, and
|
||||||
|
// neither fact survives being narrowed to an entry.
|
||||||
|
body.dismissPreview();
|
||||||
dockContextMenu.anchorItem = anchorItem;
|
dockContextMenu.anchorItem = anchorItem;
|
||||||
dockContextMenu.entry = entry;
|
dockContextMenu.app = app;
|
||||||
dockContextMenu.visible = true;
|
dockContextMenu.requested = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
vertical: root.vertical
|
vertical: root.vertical
|
||||||
@@ -270,4 +330,50 @@ PanelWindow {
|
|||||||
DockContextMenu {
|
DockContextMenu {
|
||||||
id: dockContextMenu
|
id: dockContextMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The safety net for a latched interaction flag.
|
||||||
|
//
|
||||||
|
// Every term of `interactionHeld` is cleared by an event that can go
|
||||||
|
// missing: a drag by its release, which a stolen grab eats; a preview by
|
||||||
|
// the pointer leaving, which a destroyed anchor never reports; a menu by a
|
||||||
|
// row being chosen, which a menu whose anchor died is never offered. Any
|
||||||
|
// one of them left set holds the dock revealed with the pointer nowhere
|
||||||
|
// near it, and there is no gesture that gets it back -- the flag is stuck,
|
||||||
|
// so the dock is out until the shell restarts.
|
||||||
|
//
|
||||||
|
// Eight seconds of held-but-untouched is not a gesture. The pointer being
|
||||||
|
// on the previews or on the menu counts as touched, because both are their
|
||||||
|
// own surfaces and leaving the dock is how you reach them.
|
||||||
|
Timer {
|
||||||
|
id: interactionWatchdog
|
||||||
|
|
||||||
|
interval: 8000
|
||||||
|
running: root.interactionHeld && !pointer.hovered
|
||||||
|
&& !dockPreviews.hovered && !dockContextMenu.hovered
|
||||||
|
|
||||||
|
onTriggered: {
|
||||||
|
body.cancelDrag();
|
||||||
|
body.dismissPreview();
|
||||||
|
dockContextMenu.requested = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its own surface rather than something drawn inside the dock: the dock's
|
||||||
|
// input mask is a thin strip when hidden and the bar's own rectangle when
|
||||||
|
// shown, and widening it to cover a preview would hand the dock every
|
||||||
|
// click in the empty space above it.
|
||||||
|
DockPreviews {
|
||||||
|
id: dockPreviews
|
||||||
|
|
||||||
|
anchorItem: body.previewAnchor
|
||||||
|
app: body.previewApp
|
||||||
|
position: root.position
|
||||||
|
|
||||||
|
// The pointer crossing from the icon to the previews leaves the dock
|
||||||
|
// entirely -- these are separate surfaces -- so the previews report
|
||||||
|
// their own hover back, and DockBody's grace timer uses it to tell
|
||||||
|
// "reaching for a preview" from "moved away".
|
||||||
|
onHoveredChanged: body.previewHovered = dockPreviews.hovered
|
||||||
|
onDismissed: body.dismissPreview()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ Rectangle {
|
|||||||
// ── Model ───────────────────────────────────────────────────────────────
|
// ── Model ───────────────────────────────────────────────────────────────
|
||||||
// One pass over the live toplevel list produces the whole dock: pinned
|
// One pass over the live toplevel list produces the whole dock: pinned
|
||||||
// apps first in Settings order, then anything else that is running.
|
// apps first in Settings order, then anything else that is running.
|
||||||
// Entries are plain JS objects: { entry, windows, appId }.
|
// Entries are plain JS objects: { entry, windows, appId, pinned }.
|
||||||
|
//
|
||||||
|
// `pinned` is carried rather than inferred later. Two things need it — the
|
||||||
|
// context menu, which cannot otherwise tell "Pin" from "Unpin", and the
|
||||||
|
// drag, which must refuse to reorder an icon that is only there because the
|
||||||
|
// app happens to be running.
|
||||||
readonly property var items: {
|
readonly property var items: {
|
||||||
// DesktopEntries is scanned asynchronously at startup, and byId() is a
|
// DesktopEntries is scanned asynchronously at startup, and byId() is a
|
||||||
// plain method call that creates no binding dependency. Reading the
|
// plain method call that creates no binding dependency. Reading the
|
||||||
@@ -63,7 +68,8 @@ Rectangle {
|
|||||||
out.push({
|
out.push({
|
||||||
entry: entry,
|
entry: entry,
|
||||||
windows: windows,
|
windows: windows,
|
||||||
appId: pinned[i]
|
appId: pinned[i],
|
||||||
|
pinned: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +80,8 @@ Rectangle {
|
|||||||
out.push({
|
out.push({
|
||||||
entry: DesktopEntries.heuristicLookup(appId),
|
entry: DesktopEntries.heuristicLookup(appId),
|
||||||
windows: groups[appId],
|
windows: groups[appId],
|
||||||
appId: appId
|
appId: appId,
|
||||||
|
pinned: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +123,11 @@ Rectangle {
|
|||||||
|
|
||||||
// The item the tooltip is currently describing, or null.
|
// The item the tooltip is currently describing, or null.
|
||||||
property Item hoveredItem: null
|
property Item hoveredItem: null
|
||||||
signal contextMenuRequested(Item anchorItem, var entry)
|
|
||||||
|
// The whole app object travels, not just its desktop entry: the menu has to
|
||||||
|
// be able to list the app's windows and tell a pin from something that is
|
||||||
|
// merely running, and neither fact survives being narrowed to an entry.
|
||||||
|
signal contextMenuRequested(Item anchorItem, var app)
|
||||||
|
|
||||||
// Set by the Dock. A side dock runs the same strip down the screen instead
|
// Set by the Dock. A side dock runs the same strip down the screen instead
|
||||||
// of across it.
|
// of across it.
|
||||||
@@ -126,6 +137,190 @@ Rectangle {
|
|||||||
// it never opens off-screen.
|
// it never opens off-screen.
|
||||||
property bool leftSide: true
|
property bool leftSide: true
|
||||||
|
|
||||||
|
// ── Reordering ──────────────────────────────────────────────────────────
|
||||||
|
// Dragging an icon along the dock moves its pin. Nothing is written while
|
||||||
|
// the gesture runs: the dragged icon is translated under the pointer, the
|
||||||
|
// icons it passes are translated the other way by exactly one slot, and the
|
||||||
|
// spliced list is committed once on release. Committing per slot crossed
|
||||||
|
// would rewrite settings.json a dozen times for one gesture, and every
|
||||||
|
// rewrite re-evaluates `items` underneath the drag.
|
||||||
|
//
|
||||||
|
// Translation rather than assigned x/y because a Grid owns its children's
|
||||||
|
// positions; a transform is a purely visual offset the positioner ignores.
|
||||||
|
property int dragIndex: -1
|
||||||
|
property string dragId: ""
|
||||||
|
property real dragTravel: 0
|
||||||
|
|
||||||
|
readonly property bool dragging: root.dragIndex >= 0
|
||||||
|
|
||||||
|
// One cell plus the gap after it: the distance the strip moves things by.
|
||||||
|
// Reported by the item that started the drag rather than recomputed here --
|
||||||
|
// a DockItem is taller than it is wide, so a slot down a side dock is not
|
||||||
|
// the same distance as a slot across a bottom one. The initial value only
|
||||||
|
// has to be non-zero; the first drag replaces it with the measured pitch.
|
||||||
|
property real dragStep: Theme.dockIconSize + Theme.dockGap
|
||||||
|
|
||||||
|
// How many pins are on the dock. Resolved pins are always the leading run
|
||||||
|
// of `items`, so this doubles as the last index a drag may land on.
|
||||||
|
readonly property int pinnedCount: {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < root.items.length; i++) {
|
||||||
|
if (!root.items[i].pinned)
|
||||||
|
break;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property int dropIndex: {
|
||||||
|
if (root.dragIndex < 0)
|
||||||
|
return -1;
|
||||||
|
const slots = Math.round(root.dragTravel / root.dragStep);
|
||||||
|
return Math.max(0, Math.min(root.pinnedCount - 1, root.dragIndex + slots));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where item `index` sits while a drag is in flight, relative to the slot
|
||||||
|
// the Grid put it in.
|
||||||
|
function dragShiftFor(index: int): real {
|
||||||
|
if (root.dragIndex < 0)
|
||||||
|
return 0;
|
||||||
|
if (index === root.dragIndex)
|
||||||
|
return root.dragTravel;
|
||||||
|
if (root.dropIndex > root.dragIndex && index > root.dragIndex && index <= root.dropIndex)
|
||||||
|
return -root.dragStep;
|
||||||
|
if (root.dropIndex < root.dragIndex && index >= root.dropIndex && index < root.dragIndex)
|
||||||
|
return root.dragStep;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginDrag(index: int, pitch: real): void {
|
||||||
|
if (index < 0 || index >= root.pinnedCount)
|
||||||
|
return;
|
||||||
|
// A preview anchored to an icon that is about to move under the pointer
|
||||||
|
// is a surface pointing at nothing.
|
||||||
|
root.dismissPreview();
|
||||||
|
if (pitch > 0)
|
||||||
|
root.dragStep = pitch;
|
||||||
|
root.dragIndex = index;
|
||||||
|
root.dragId = root.items[index].appId;
|
||||||
|
root.dragTravel = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDrag(travel: real): void {
|
||||||
|
if (root.dragIndex < 0)
|
||||||
|
return;
|
||||||
|
root.dragTravel = travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved by pin id rather than by index. `items` drops a pin that no
|
||||||
|
// longer resolves, so an index into the dock is not an index into the
|
||||||
|
// stored list, and a window opening mid-drag can shift both.
|
||||||
|
function endDrag(): void {
|
||||||
|
const target = root.dropIndex;
|
||||||
|
const from = root.dragId;
|
||||||
|
const to = target >= 0 && target < root.items.length ? root.items[target].appId : "";
|
||||||
|
root.dragIndex = -1;
|
||||||
|
root.dragId = "";
|
||||||
|
root.dragTravel = 0;
|
||||||
|
|
||||||
|
if (!from || !to || from === to)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const stored = Settings.dockPinned.slice();
|
||||||
|
const fromAt = stored.indexOf(from);
|
||||||
|
const toAt = stored.indexOf(to);
|
||||||
|
if (fromAt < 0 || toAt < 0)
|
||||||
|
return;
|
||||||
|
stored.splice(toAt, 0, stored.splice(fromAt, 1)[0]);
|
||||||
|
DesktopPreferences.set("dockPinned", stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDrag(): void {
|
||||||
|
root.dragIndex = -1;
|
||||||
|
root.dragId = "";
|
||||||
|
root.dragTravel = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Window previews ─────────────────────────────────────────────────────
|
||||||
|
// Hovering an icon long enough shows its windows. The dwell exists so that
|
||||||
|
// sweeping the pointer across the dock on the way somewhere else never
|
||||||
|
// opens anything, and the grace on the way out exists because the previews
|
||||||
|
// are their own surface: leaving the icon to reach them would otherwise
|
||||||
|
// close the thing being reached for.
|
||||||
|
property Item previewAnchor: null
|
||||||
|
|
||||||
|
// Looked up in the live model rather than snapshotted when the dwell fires.
|
||||||
|
// `items` is rebuilt into fresh objects whenever any window opens or
|
||||||
|
// closes, so a snapshot keeps the window list the app had when the pointer
|
||||||
|
// stopped moving: a window closed while its preview is up stays in the
|
||||||
|
// strip, and its ScreencopyView goes on holding a handle to a surface that
|
||||||
|
// no longer exists. Reading it back out of `items` means the strip empties
|
||||||
|
// itself, and an app whose last window closed drops the preview entirely.
|
||||||
|
readonly property var previewApp: {
|
||||||
|
const anchor = root.previewAnchor;
|
||||||
|
if (!anchor || !anchor.app)
|
||||||
|
return null;
|
||||||
|
const id = anchor.app.appId;
|
||||||
|
for (let i = 0; i < root.items.length; i++) {
|
||||||
|
if (root.items[i].appId === id)
|
||||||
|
return root.items[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written by the Dock from the preview popup's own hover.
|
||||||
|
property bool previewHovered: false
|
||||||
|
|
||||||
|
onHoveredItemChanged: root.reconsiderPreview()
|
||||||
|
onPreviewHoveredChanged: root.reconsiderPreview()
|
||||||
|
// A window opening or closing changes what the strip should be showing --
|
||||||
|
// including, when it was the last one, whether there should be a strip.
|
||||||
|
onItemsChanged: root.reconsiderPreview()
|
||||||
|
|
||||||
|
function reconsiderPreview(): void {
|
||||||
|
const item = root.hoveredItem;
|
||||||
|
const eligible = item && item.app && item.app.windows && item.app.windows.length > 0;
|
||||||
|
|
||||||
|
if (eligible && item !== root.previewAnchor) {
|
||||||
|
previewGrace.stop();
|
||||||
|
previewDwell.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewDwell.stop();
|
||||||
|
if (root.previewAnchor && !eligible && !root.previewHovered)
|
||||||
|
previewGrace.restart();
|
||||||
|
else if (eligible || root.previewHovered)
|
||||||
|
previewGrace.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissPreview(): void {
|
||||||
|
previewDwell.stop();
|
||||||
|
previewGrace.stop();
|
||||||
|
root.previewAnchor = null;
|
||||||
|
root.previewHovered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: previewDwell
|
||||||
|
interval: 400
|
||||||
|
onTriggered: {
|
||||||
|
const item = root.hoveredItem;
|
||||||
|
if (!item || !item.app || !item.app.windows || item.app.windows.length === 0)
|
||||||
|
return;
|
||||||
|
root.previewAnchor = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: previewGrace
|
||||||
|
interval: 220
|
||||||
|
onTriggered: {
|
||||||
|
if (!root.previewHovered)
|
||||||
|
root.dismissPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit rather than left to Grid's wrapping. This is always one line, so
|
// 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
|
// saying how many cells it holds is both simpler to read and immune to
|
||||||
// Grid's default column count quietly wrapping a long dock.
|
// Grid's default column count quietly wrapping a long dock.
|
||||||
@@ -162,16 +357,50 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.items
|
// `items` is a fresh array of fresh objects on every change -- a
|
||||||
|
// window opening or closing anywhere rebuilds all of it. Handing
|
||||||
|
// that straight to Repeater resets the model and rebuilds every
|
||||||
|
// delegate, which throws away hover state, restarts the grow
|
||||||
|
// animation on icons nothing happened to, and would drop the
|
||||||
|
// delegate out from under a drag in progress. ScriptModel keyed on
|
||||||
|
// appId turns the same rebuild into "these rows changed", so an
|
||||||
|
// icon whose window count went up keeps its delegate.
|
||||||
|
model: ScriptModel {
|
||||||
|
values: root.items
|
||||||
|
objectProp: "appId"
|
||||||
|
comparisonMode: ObjectComparison.Structure
|
||||||
|
}
|
||||||
|
|
||||||
DockItem {
|
DockItem {
|
||||||
id: dockItem
|
id: dockItem
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
app: modelData
|
app: modelData
|
||||||
|
vertical: root.vertical
|
||||||
|
|
||||||
|
// Only a pin can be reordered. An icon that is on the dock
|
||||||
|
// because its app happens to be running has no place in the
|
||||||
|
// stored list to move to.
|
||||||
|
draggable: modelData.pinned === true
|
||||||
|
dragShift: root.dragShiftFor(dockItem.index)
|
||||||
|
dragging: root.dragIndex === dockItem.index
|
||||||
|
|
||||||
onEntered: root.hoveredItem = dockItem
|
onEntered: root.hoveredItem = dockItem
|
||||||
onExited: if (root.hoveredItem === dockItem)
|
onExited: if (root.hoveredItem === dockItem)
|
||||||
root.hoveredItem = null
|
root.hoveredItem = null
|
||||||
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
|
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.app)
|
||||||
|
onDragStarted: pitch => root.beginDrag(dockItem.index, pitch)
|
||||||
|
onDragMoved: travel => root.moveDrag(travel)
|
||||||
|
onDragEnded: root.endDrag()
|
||||||
|
onDragCancelled: root.cancelDrag()
|
||||||
|
|
||||||
|
// Keying the model keeps a delegate alive through a rebuild,
|
||||||
|
// but not through the app's last window closing while it is
|
||||||
|
// being dragged: that row is gone, and the release it owed is
|
||||||
|
// gone with it. Without this the dock keeps dragIndex forever,
|
||||||
|
// which reads as a dock that will not hide again.
|
||||||
|
Component.onDestruction: if (root.dragIndex === dockItem.index) root.cancelDrag()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +415,9 @@ Rectangle {
|
|||||||
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
|
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
|
||||||
|
|
||||||
visible: opacity > 0
|
visible: opacity > 0
|
||||||
opacity: root.hoveredItem && tipLabel.text ? 1 : 0
|
// Yields to the window previews, which name the same app and more
|
||||||
|
// besides -- both at once is the same label twice.
|
||||||
|
opacity: root.hoveredItem && tipLabel.text && root.previewAnchor !== root.hoveredItem ? 1 : 0
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// The dock's app menu. Desktop-entry actions stay first; the shell-owned
|
// The dock's app menu: its open windows, then what the .desktop file offers,
|
||||||
// configuration route is deliberately last so it never displaces app actions.
|
// then what the dock itself can do with the app. The shell-owned configuration
|
||||||
|
// route is deliberately last so it never displaces an app action.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.modules.bar
|
import qs.modules.bar
|
||||||
@@ -12,19 +14,99 @@ PopupWindow {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property Item anchorItem: null
|
property Item anchorItem: null
|
||||||
property var entry: null
|
|
||||||
|
// The whole dock entry: { entry, windows, appId, pinned }. Narrowing this
|
||||||
|
// to a desktop entry on the way in is what used to stop the menu offering
|
||||||
|
// anything about the app's actual windows, or knowing whether it is pinned.
|
||||||
|
property var app: null
|
||||||
|
|
||||||
|
readonly property DesktopEntry entry: root.app && root.app.entry ? root.app.entry : null
|
||||||
|
readonly property var windows: root.app && root.app.windows ? root.app.windows : []
|
||||||
|
readonly property bool pinned: root.app ? root.app.pinned === true : false
|
||||||
|
|
||||||
anchor.item: root.anchorItem
|
anchor.item: root.anchorItem
|
||||||
anchor.edges: Edges.Top | Edges.Left
|
anchor.edges: Edges.Top | Edges.Left
|
||||||
anchor.gravity: Edges.Top | Edges.Right
|
anchor.gravity: Edges.Top | Edges.Right
|
||||||
anchor.margins.bottom: 8
|
anchor.margins.bottom: 8
|
||||||
|
|
||||||
implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240)
|
implicitWidth: Math.min(360, Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240))
|
||||||
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
|
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
visible: false
|
|
||||||
grabFocus: true
|
grabFocus: true
|
||||||
|
|
||||||
|
// Whether the dock has asked for the menu. Visibility is that AND a live
|
||||||
|
// anchor, rather than the request alone: the dock icon a menu is anchored
|
||||||
|
// to is a delegate, and a delegate dies when its app's last window closes.
|
||||||
|
// An anchored PopupWindow whose anchor has gone stays mapped with a null
|
||||||
|
// anchor -- a menu hanging over the desktop, attached to nothing, holding
|
||||||
|
// the dock revealed behind it.
|
||||||
|
property bool requested: false
|
||||||
|
visible: root.requested && root.anchorItem !== null
|
||||||
|
|
||||||
|
onAnchorItemChanged: if (root.anchorItem === null) root.requested = false
|
||||||
|
|
||||||
|
// Read by the Dock's interaction watchdog. A menu with the pointer on it is
|
||||||
|
// a menu being read, not a stuck flag -- and the pointer being here means
|
||||||
|
// it is not on the dock, which is the only other thing the dock can see.
|
||||||
|
readonly property bool hovered: menuPointer.hovered
|
||||||
|
|
||||||
|
// Long window titles are the one thing here that can be arbitrarily wide,
|
||||||
|
// and a menu as wide as a browser tab's title is not a menu.
|
||||||
|
function shortTitle(toplevel: var): string {
|
||||||
|
const title = String(toplevel?.title ?? "").trim();
|
||||||
|
if (!title)
|
||||||
|
return root.app && root.app.appId ? root.app.appId : "Untitled window";
|
||||||
|
return title.length > 42 ? title.slice(0, 41) + "…" : title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressOf(toplevel: var): string {
|
||||||
|
const raw = String(toplevel?.address ?? "");
|
||||||
|
if (!raw)
|
||||||
|
return "";
|
||||||
|
return raw.startsWith("0x") ? raw : "0x" + raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusToplevel(toplevel: var): void {
|
||||||
|
if (!toplevel)
|
||||||
|
return;
|
||||||
|
if (toplevel.workspace)
|
||||||
|
toplevel.workspace.activate();
|
||||||
|
|
||||||
|
const address = root.addressOf(toplevel);
|
||||||
|
if (address)
|
||||||
|
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
|
||||||
|
else if (toplevel.wayland)
|
||||||
|
toplevel.wayland.activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every window, not the focused one: "Quit" on a dock icon means the app,
|
||||||
|
// which is what the icon stands for.
|
||||||
|
function quit(): void {
|
||||||
|
for (const toplevel of root.windows) {
|
||||||
|
const address = root.addressOf(toplevel);
|
||||||
|
if (address)
|
||||||
|
Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${address}" })`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always the whole array through DesktopPreferences, which is the only
|
||||||
|
// thing the Settings page and the dock agree on.
|
||||||
|
function pin(): void {
|
||||||
|
if (!root.entry)
|
||||||
|
return;
|
||||||
|
const stored = Settings.dockPinned;
|
||||||
|
if (stored.indexOf(root.entry.id) >= 0)
|
||||||
|
return;
|
||||||
|
DesktopPreferences.set("dockPinned", stored.concat([root.entry.id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpin(): void {
|
||||||
|
const id = root.app && root.app.appId ? root.app.appId : "";
|
||||||
|
if (!id)
|
||||||
|
return;
|
||||||
|
DesktopPreferences.set("dockPinned", Settings.dockPinned.filter(other => other !== id));
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
radius: Theme.popoverRadius
|
radius: Theme.popoverRadius
|
||||||
@@ -32,6 +114,10 @@ PopupWindow {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: menuPointer
|
||||||
|
}
|
||||||
|
|
||||||
PrismEdge {
|
PrismEdge {
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.topMargin: 1
|
anchors.topMargin: 1
|
||||||
@@ -46,6 +132,33 @@ PopupWindow {
|
|||||||
anchors.margins: Theme.popoverPadding
|
anchors.margins: Theme.popoverPadding
|
||||||
spacing: 2
|
spacing: 2
|
||||||
|
|
||||||
|
// ── The app's own windows ───────────────────────────────────────
|
||||||
|
Repeater {
|
||||||
|
id: openWindows
|
||||||
|
model: root.windows
|
||||||
|
|
||||||
|
delegate: TrayMenuRow {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
label: root.shortTitle(modelData)
|
||||||
|
onActivated: {
|
||||||
|
root.focusToplevel(modelData);
|
||||||
|
root.requested = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
anchors.margins: 3
|
||||||
|
visible: openWindows.count > 0
|
||||||
|
border.width: 0
|
||||||
|
color: Theme.alpha(Theme.fg, 0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What the .desktop file offers ───────────────────────────────
|
||||||
Repeater {
|
Repeater {
|
||||||
id: applicationActions
|
id: applicationActions
|
||||||
model: root.entry ? root.entry.actions : []
|
model: root.entry ? root.entry.actions : []
|
||||||
@@ -57,7 +170,7 @@ PopupWindow {
|
|||||||
label: modelData.name
|
label: modelData.name
|
||||||
onActivated: {
|
onActivated: {
|
||||||
modelData.execute();
|
modelData.execute();
|
||||||
root.visible = false;
|
root.requested = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,12 +184,60 @@ PopupWindow {
|
|||||||
color: Theme.alpha(Theme.fg, 0.1)
|
color: Theme.alpha(Theme.fg, 0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── What the dock can do with it ────────────────────────────────
|
||||||
|
TrayMenuRow {
|
||||||
|
width: parent.width
|
||||||
|
label: "New window"
|
||||||
|
// A running application with no desktop entry cannot be
|
||||||
|
// launched again -- there is nothing that says how.
|
||||||
|
rowEnabled: root.entry !== null
|
||||||
|
onActivated: {
|
||||||
|
if (root.entry)
|
||||||
|
root.entry.execute();
|
||||||
|
root.requested = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TrayMenuRow {
|
||||||
|
width: parent.width
|
||||||
|
label: root.pinned ? "Unpin from dock" : "Pin to dock"
|
||||||
|
// Unpinning needs only the id the pin was stored under;
|
||||||
|
// pinning needs an entry to name, and an app whose id resolves
|
||||||
|
// to nothing would be pinned as a hole.
|
||||||
|
rowEnabled: root.pinned || root.entry !== null
|
||||||
|
onActivated: {
|
||||||
|
if (root.pinned)
|
||||||
|
root.unpin();
|
||||||
|
else
|
||||||
|
root.pin();
|
||||||
|
root.requested = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TrayMenuRow {
|
||||||
|
width: parent.width
|
||||||
|
label: root.windows.length > 1 ? "Quit all windows" : "Quit"
|
||||||
|
rowEnabled: root.windows.length > 0
|
||||||
|
onActivated: {
|
||||||
|
root.quit();
|
||||||
|
root.requested = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
anchors.margins: 3
|
||||||
|
border.width: 0
|
||||||
|
color: Theme.alpha(Theme.fg, 0.1)
|
||||||
|
}
|
||||||
|
|
||||||
TrayMenuRow {
|
TrayMenuRow {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: "Dock settings"
|
label: "Dock settings"
|
||||||
onActivated: {
|
onActivated: {
|
||||||
ShellState.openSettings("desktop");
|
ShellState.openSettings("dock");
|
||||||
root.visible = false;
|
root.requested = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import qs.config
|
|||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string }
|
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string,
|
||||||
// Built by DockBody so this file stays presentational.
|
// pinned: bool }. Built by DockBody so this file stays presentational.
|
||||||
required property var app
|
required property var app
|
||||||
|
|
||||||
readonly property DesktopEntry entry: app && app.entry ? app.entry : null
|
readonly property DesktopEntry entry: app && app.entry ? app.entry : null
|
||||||
@@ -21,16 +21,49 @@ Item {
|
|||||||
readonly property bool running: windows.length > 0
|
readonly property bool running: windows.length > 0
|
||||||
readonly property bool hovered: mouse.containsMouse
|
readonly property bool hovered: mouse.containsMouse
|
||||||
|
|
||||||
|
// Set by DockBody. Which way the dock runs decides which axis a drag reads.
|
||||||
|
property bool vertical: false
|
||||||
|
|
||||||
|
// Reordering, driven from DockBody: whether this icon may be dragged at
|
||||||
|
// all, how far it is currently displaced, and whether it is the one being
|
||||||
|
// dragged rather than one being pushed aside.
|
||||||
|
property bool draggable: false
|
||||||
|
property real dragShift: 0
|
||||||
|
property bool dragging: false
|
||||||
|
|
||||||
// Emitted so DockBody can drive the single shared tooltip.
|
// Emitted so DockBody can drive the single shared tooltip.
|
||||||
signal entered
|
signal entered
|
||||||
signal exited
|
signal exited
|
||||||
signal contextMenuRequested
|
signal contextMenuRequested
|
||||||
|
|
||||||
|
// The drag, reported as travel along the dock's own axis from where the
|
||||||
|
// press landed. DockBody owns what that means.
|
||||||
|
//
|
||||||
|
// The start also carries the pitch -- one cell plus the gap after it --
|
||||||
|
// because the cell size is this file's business: an item is taller than it
|
||||||
|
// is wide (the running dots sit under the icon), so a slot down a side dock
|
||||||
|
// is further than a slot across a bottom one. Measuring it here rather than
|
||||||
|
// recomputing it in DockBody keeps one copy of that arithmetic.
|
||||||
|
signal dragStarted(real pitch)
|
||||||
|
signal dragMoved(real travel)
|
||||||
|
signal dragEnded
|
||||||
|
signal dragCancelled
|
||||||
|
|
||||||
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
||||||
// size so the row doesn't reflow.
|
// size so the row doesn't reflow.
|
||||||
implicitWidth: Theme.dockIconSize
|
implicitWidth: Theme.dockIconSize
|
||||||
implicitHeight: Theme.dockIconSize + dots.height + 4
|
implicitHeight: Theme.dockIconSize + dots.height + 4
|
||||||
|
|
||||||
|
// Above the icons it is passing.
|
||||||
|
z: root.dragging ? 2 : 0
|
||||||
|
|
||||||
|
// A transform, not an x/y binding: the Grid owns those, and assigning them
|
||||||
|
// in a delegate fights the positioner rather than moving the icon.
|
||||||
|
transform: Translate {
|
||||||
|
x: root.vertical ? 0 : root.dragShift
|
||||||
|
y: root.vertical ? root.dragShift : 0
|
||||||
|
}
|
||||||
|
|
||||||
// Desktop entries usually carry a freedesktop icon *name*, but some ship an
|
// Desktop entries usually carry a freedesktop icon *name*, but some ship an
|
||||||
// absolute path. iconPath() only understands names, so branch on it. The
|
// absolute path. iconPath() only understands names, so branch on it. The
|
||||||
// `true` argument makes a missing icon return "" instead of a placeholder
|
// `true` argument makes a missing icon return "" instead of a placeholder
|
||||||
@@ -118,10 +151,94 @@ Item {
|
|||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||||
|
|
||||||
|
// Where the press landed, and whether it has travelled far enough to
|
||||||
|
// stop being a click. The threshold is the whole reason a drag can
|
||||||
|
// share this MouseArea with the launch click: a hand that means to
|
||||||
|
// click never moves eight pixels while the button is down.
|
||||||
|
readonly property int dragThreshold: 8
|
||||||
|
property real pressX: 0
|
||||||
|
property real pressY: 0
|
||||||
|
property bool dragActive: false
|
||||||
|
property bool dragConsumed: false
|
||||||
|
|
||||||
|
// A wheel notch is 120 units, but a touchpad sends far smaller ones and
|
||||||
|
// a free-spinning wheel sends larger. Accumulating and spending whole
|
||||||
|
// notches is what makes both feel the same -- reacting to every event
|
||||||
|
// would make a touchpad flick blur through every window an app owns.
|
||||||
|
property int wheelTravel: 0
|
||||||
|
|
||||||
onEntered: root.entered()
|
onEntered: root.entered()
|
||||||
onExited: root.exited()
|
onExited: root.exited()
|
||||||
|
|
||||||
|
onWheel: event => {
|
||||||
|
if (!root.running) {
|
||||||
|
mouse.wheelTravel = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mouse.wheelTravel += event.angleDelta.y;
|
||||||
|
while (mouse.wheelTravel >= 120) {
|
||||||
|
mouse.wheelTravel -= 120;
|
||||||
|
root.focusBy(-1);
|
||||||
|
}
|
||||||
|
while (mouse.wheelTravel <= -120) {
|
||||||
|
mouse.wheelTravel += 120;
|
||||||
|
root.focusBy(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPressed: mev => {
|
||||||
|
// A press arriving while a drag is still marked active means the
|
||||||
|
// last one never got its release -- a grab stolen by a surface that
|
||||||
|
// opened over the dock, most often. Clearing the flag alone would
|
||||||
|
// leave DockBody still holding a dragIndex, and with it the
|
||||||
|
// interaction hold that keeps the dock revealed forever; the drag
|
||||||
|
// has to be cancelled through the same path a stolen grab uses.
|
||||||
|
if (mouse.dragActive) {
|
||||||
|
mouse.dragActive = false;
|
||||||
|
mouse.dragConsumed = false;
|
||||||
|
root.dragCancelled();
|
||||||
|
}
|
||||||
|
mouse.pressX = mev.x;
|
||||||
|
mouse.pressY = mev.y;
|
||||||
|
mouse.dragActive = false;
|
||||||
|
mouse.dragConsumed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPositionChanged: mev => {
|
||||||
|
if (!root.draggable || !mouse.pressedButtons)
|
||||||
|
return;
|
||||||
|
const travel = root.vertical ? mev.y - mouse.pressY : mev.x - mouse.pressX;
|
||||||
|
if (!mouse.dragActive) {
|
||||||
|
if (Math.abs(travel) < mouse.dragThreshold)
|
||||||
|
return;
|
||||||
|
mouse.dragActive = true;
|
||||||
|
mouse.dragConsumed = true;
|
||||||
|
root.dragStarted((root.vertical ? root.height : root.width) + Theme.dockGap);
|
||||||
|
}
|
||||||
|
root.dragMoved(travel);
|
||||||
|
}
|
||||||
|
|
||||||
|
onReleased: {
|
||||||
|
if (!mouse.dragActive)
|
||||||
|
return;
|
||||||
|
mouse.dragActive = false;
|
||||||
|
root.dragEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
onCanceled: {
|
||||||
|
if (!mouse.dragActive)
|
||||||
|
return;
|
||||||
|
mouse.dragActive = false;
|
||||||
|
mouse.dragConsumed = false;
|
||||||
|
root.dragCancelled();
|
||||||
|
}
|
||||||
|
|
||||||
onClicked: mev => {
|
onClicked: mev => {
|
||||||
|
// A gesture that reordered the dock is not also a launch.
|
||||||
|
if (mouse.dragConsumed) {
|
||||||
|
mouse.dragConsumed = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Middle click always starts a new instance, as in GNOME.
|
// Middle click always starts a new instance, as in GNOME.
|
||||||
if (mev.button === Qt.MiddleButton) {
|
if (mev.button === Qt.MiddleButton) {
|
||||||
root.launch();
|
root.launch();
|
||||||
@@ -132,7 +249,7 @@ Item {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (root.running)
|
if (root.running)
|
||||||
root.focusNext();
|
root.focusBy(1);
|
||||||
else
|
else
|
||||||
root.launch();
|
root.launch();
|
||||||
}
|
}
|
||||||
@@ -143,19 +260,26 @@ Item {
|
|||||||
root.entry.execute();
|
root.entry.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clicking a running app cycles through its windows, matching GNOME's dash.
|
// Stepping through an app's windows: clicking a running app takes one step
|
||||||
function focusNext(): void {
|
// forward, matching GNOME's dash, and the wheel takes one in either
|
||||||
|
// direction. With nothing of this app focused, any step lands on its first
|
||||||
|
// window rather than counting from a window the user is not looking at.
|
||||||
|
function focusBy(delta: int): void {
|
||||||
const wins = root.windows;
|
const wins = root.windows;
|
||||||
if (wins.length === 0)
|
if (wins.length === 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let next = wins[0];
|
let current = -1;
|
||||||
for (let i = 0; i < wins.length; i++) {
|
for (let i = 0; i < wins.length; i++) {
|
||||||
if (wins[i].activated) {
|
if (wins[i].activated) {
|
||||||
next = wins[(i + 1) % wins.length];
|
current = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const next = current < 0
|
||||||
|
? wins[0]
|
||||||
|
: wins[((current + delta) % wins.length + wins.length) % wins.length];
|
||||||
if (!next)
|
if (!next)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// "Add application to dock", reachable without opening Settings.
|
||||||
|
//
|
||||||
|
// The Settings page is the place to curate the whole dock -- reorder it, unpin
|
||||||
|
// things, change how it hides. Adding one application is a single decision made
|
||||||
|
// while looking at the dock, and routing it through a settings window means
|
||||||
|
// finding the page, then the card, then the box. This is that box, on its own.
|
||||||
|
//
|
||||||
|
// It embeds the same DockAppPicker the Dock page uses rather than growing a
|
||||||
|
// second search: the exclusion of already-pinned applications, the icon lookup
|
||||||
|
// and the "nothing until you type" behavior are all already there, and two
|
||||||
|
// copies of them would drift.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.modules.settings
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property bool open: ShellState.dockPickerOpen
|
||||||
|
|
||||||
|
// The one place a pin is added from outside the Settings page. Returns
|
||||||
|
// false for an id nothing installs, so the IPC caller hears about a typo
|
||||||
|
// instead of the dock quietly gaining a hole -- DockBody drops a pin it
|
||||||
|
// cannot resolve, so a bad id is invisible at runtime.
|
||||||
|
function pin(id: string): bool {
|
||||||
|
const wanted = String(id ?? "").trim();
|
||||||
|
if (!wanted || !DesktopEntries.byId(wanted))
|
||||||
|
return false;
|
||||||
|
const stored = Settings.dockPinned;
|
||||||
|
if (stored.indexOf(wanted) >= 0)
|
||||||
|
return true;
|
||||||
|
DesktopPreferences.set("dockPinned", stored.concat([wanted]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
|
color: "transparent"
|
||||||
|
exclusiveZone: 0
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
|
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
|
||||||
|
// here rather than added to that rule, the same way the cheatsheet does it.
|
||||||
|
WlrLayershell.namespace: "qs-popover-dock-picker"
|
||||||
|
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;
|
||||||
|
picker.grab();
|
||||||
|
} 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 } }
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: ShellState.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: card
|
||||||
|
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
// High rather than centred: the list grows downwards as you type, and a
|
||||||
|
// centred card walks up the screen while you are reading it.
|
||||||
|
y: Math.round(parent.height * 0.18)
|
||||||
|
width: Math.min(root.width - 120, 520)
|
||||||
|
height: header.height + picker.implicitHeight + 56
|
||||||
|
|
||||||
|
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: 22
|
||||||
|
height: title.implicitHeight
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: title
|
||||||
|
text: "Add application to dock"
|
||||||
|
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: "Esc to close"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DockAppPicker {
|
||||||
|
id: picker
|
||||||
|
|
||||||
|
anchors.top: header.bottom
|
||||||
|
anchors.topMargin: 16
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 22
|
||||||
|
anchors.rightMargin: 22
|
||||||
|
|
||||||
|
pinned: Settings.dockPinned
|
||||||
|
onPicked: id => {
|
||||||
|
root.pin(id);
|
||||||
|
ShellState.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Shortcut rather than Keys.onEscapePressed on an item: the search box
|
||||||
|
// owns Escape while it has focus (it clears the query first), and a key
|
||||||
|
// handler on an ancestor would never see the second press.
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Escape"
|
||||||
|
enabled: root.open
|
||||||
|
onActivated: ShellState.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// What a dock icon has open, shown after a dwell on hover.
|
||||||
|
//
|
||||||
|
// Its own surface, not something drawn inside the dock. The dock's input mask
|
||||||
|
// is a thin strip when hidden and the bar's own rectangle when revealed;
|
||||||
|
// widening it to cover a preview strip would hand the dock every click in the
|
||||||
|
// empty space above it, which is most of the screen.
|
||||||
|
//
|
||||||
|
// Capture is one-shot -- `live: false` plus an explicit captureFrame() -- for
|
||||||
|
// the same reason the overview's thumbnails are: streaming four windows for as
|
||||||
|
// long as a pointer rests on an icon repaints continuously for nothing. The
|
||||||
|
// deferral around that first capture is copied from
|
||||||
|
// modules/overview/WindowThumbnail.qml, where the reasoning is written out.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import Quickshell.Widgets
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// The DockItem being hovered, and the app object behind it. Both are
|
||||||
|
// written by the Dock from DockBody's dwell timer.
|
||||||
|
property Item anchorItem: null
|
||||||
|
property var app: null
|
||||||
|
|
||||||
|
// Which edge the dock lives on, so the strip appears on the side of the
|
||||||
|
// icon that faces the screen rather than off the edge.
|
||||||
|
property string position: "bottom"
|
||||||
|
|
||||||
|
readonly property bool vertical: root.position === "left" || root.position === "right"
|
||||||
|
|
||||||
|
// Four is the cap. A strip of previews wider than the screen is not a
|
||||||
|
// preview of anything, and the point of this surface is to answer "which
|
||||||
|
// window do I want" at a glance -- past four, the answer is the overview.
|
||||||
|
readonly property int previewCap: 4
|
||||||
|
|
||||||
|
readonly property var allWindows: root.app && root.app.windows ? root.app.windows : []
|
||||||
|
readonly property var windows: root.allWindows.length > root.previewCap
|
||||||
|
? root.allWindows.slice(0, root.previewCap)
|
||||||
|
: root.allWindows
|
||||||
|
|
||||||
|
readonly property int overflow: root.allWindows.length - root.windows.length
|
||||||
|
|
||||||
|
// Read by the Dock, which feeds it back to DockBody's grace timer. Crossing
|
||||||
|
// from the icon to a preview leaves the dock entirely -- these are separate
|
||||||
|
// surfaces -- so without this the act of reaching for a preview closes it.
|
||||||
|
readonly property bool hovered: pointer.hovered
|
||||||
|
|
||||||
|
signal dismissed
|
||||||
|
|
||||||
|
// Bumped each time the strip opens; each bump re-captures, so a window that
|
||||||
|
// has changed since the last look is not shown as it was.
|
||||||
|
property int refreshToken: 0
|
||||||
|
|
||||||
|
anchor.item: root.anchorItem
|
||||||
|
// Bottom dock: above the icon. Side dock: alongside it, away from the edge.
|
||||||
|
anchor.edges: root.vertical
|
||||||
|
? (root.position === "left" ? Edges.Right : Edges.Left)
|
||||||
|
: Edges.Top
|
||||||
|
anchor.gravity: root.vertical
|
||||||
|
? (root.position === "left" ? Edges.Right : Edges.Left)
|
||||||
|
: Edges.Top
|
||||||
|
anchor.margins.bottom: root.vertical ? 0 : 10
|
||||||
|
anchor.margins.left: root.position === "left" ? 10 : 0
|
||||||
|
anchor.margins.right: root.position === "right" ? 10 : 0
|
||||||
|
|
||||||
|
implicitWidth: strip.implicitWidth + 12
|
||||||
|
implicitHeight: strip.implicitHeight + 12
|
||||||
|
|
||||||
|
color: "transparent"
|
||||||
|
visible: root.anchorItem !== null && root.windows.length > 0
|
||||||
|
|
||||||
|
// Deliberately NOT grabFocus. A grab would close the strip on the first
|
||||||
|
// click anywhere and take the pointer with it, which is exactly the
|
||||||
|
// gesture that is supposed to focus a window.
|
||||||
|
grabFocus: false
|
||||||
|
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (root.visible)
|
||||||
|
root.refreshToken++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressOf(toplevel: var): string {
|
||||||
|
const raw = String(toplevel?.address ?? "");
|
||||||
|
if (!raw)
|
||||||
|
return "";
|
||||||
|
return raw.startsWith("0x") ? raw : "0x" + raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusToplevel(toplevel: var): void {
|
||||||
|
if (!toplevel)
|
||||||
|
return;
|
||||||
|
if (toplevel.workspace)
|
||||||
|
toplevel.workspace.activate();
|
||||||
|
|
||||||
|
const address = root.addressOf(toplevel);
|
||||||
|
if (address)
|
||||||
|
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
|
||||||
|
else if (toplevel.wayland)
|
||||||
|
toplevel.wayland.activate();
|
||||||
|
|
||||||
|
root.dismissed();
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: pointer
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: strip
|
||||||
|
anchors.centerIn: parent
|
||||||
|
padding: 10
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.windows
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: card
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property var source: card.modelData ? card.modelData.wayland : null
|
||||||
|
|
||||||
|
width: 176
|
||||||
|
height: 132
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
border.width: 0 // QTBUG-137166
|
||||||
|
color: cardHover.hovered
|
||||||
|
? Theme.alpha(Theme.fg, Theme.hoverAlpha)
|
||||||
|
: Theme.alpha(Theme.bg, 0.5)
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: frame
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: caption.top
|
||||||
|
anchors.margins: 6
|
||||||
|
anchors.bottomMargin: 2
|
||||||
|
|
||||||
|
// Shown until a frame arrives, and forever on a
|
||||||
|
// compositor without screencopy -- an icon rather than
|
||||||
|
// an empty box or an error.
|
||||||
|
IconImage {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: root.app && root.app.entry && root.app.entry.icon
|
||||||
|
source: root.app && root.app.entry && root.app.entry.icon
|
||||||
|
? (String(root.app.entry.icon).startsWith("/")
|
||||||
|
? "file://" + root.app.entry.icon
|
||||||
|
: Quickshell.iconPath(root.app.entry.icon, true))
|
||||||
|
: ""
|
||||||
|
implicitSize: 32
|
||||||
|
asynchronous: true
|
||||||
|
mipmap: true
|
||||||
|
opacity: shotLoader.hasFrame ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: shotLoader
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
readonly property bool hasFrame: item ? item.hasContent : false
|
||||||
|
|
||||||
|
// Gated on refreshToken for the reason spelled out
|
||||||
|
// in WindowThumbnail: a ScreencopyView created
|
||||||
|
// before its surface has mapped has no recording
|
||||||
|
// context, and its first capture fails silently.
|
||||||
|
active: !!card.source && root.refreshToken > 0
|
||||||
|
sourceComponent: shotComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: shotComponent
|
||||||
|
|
||||||
|
ScreencopyView {
|
||||||
|
id: shot
|
||||||
|
captureSource: card.source
|
||||||
|
live: false
|
||||||
|
paintCursor: false
|
||||||
|
constraintSize: Qt.size(frame.width, frame.height)
|
||||||
|
|
||||||
|
readonly property real aspect: sourceSize.height > 0
|
||||||
|
? sourceSize.width / sourceSize.height
|
||||||
|
: 16 / 9
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Math.min(parent.width, parent.height * aspect)
|
||||||
|
height: aspect > 0 ? width / aspect : parent.height
|
||||||
|
opacity: hasContent ? 1 : 0
|
||||||
|
|
||||||
|
property bool recordingReady: false
|
||||||
|
|
||||||
|
function tryCapture(): void {
|
||||||
|
if (shot.hasContent)
|
||||||
|
return;
|
||||||
|
if (!shot.recordingReady) {
|
||||||
|
frameReady.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shot.captureFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One frame of the popup's own rendering is
|
||||||
|
// what makes the capture context exist.
|
||||||
|
FrameAnimation {
|
||||||
|
id: frameReady
|
||||||
|
running: false
|
||||||
|
onTriggered: {
|
||||||
|
running = false;
|
||||||
|
if (shot.hasContent)
|
||||||
|
return;
|
||||||
|
shot.recordingReady = true;
|
||||||
|
shot.tryCapture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: tryCapture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: 7
|
||||||
|
text: String(card.modelData?.title ?? "").trim() || (root.app ? root.app.appId : "")
|
||||||
|
elide: Text.ElideRight
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: cardHover
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: root.focusToplevel(card.modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only when there are more windows than fit. Says so rather than
|
||||||
|
// silently showing four of nine.
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: root.overflow > 0
|
||||||
|
width: visible ? implicitWidth : 0
|
||||||
|
text: "+" + root.overflow
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ Pill {
|
|||||||
Text {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: FocusSession.remainingText
|
text: FocusSession.remainingText
|
||||||
color: FocusSession.paused ? Theme.fgDim : Theme.fg
|
color: FocusSession.paused ? Theme.barFgDim : Theme.barFg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ Rectangle {
|
|||||||
|
|
||||||
// Critical notifications get a red edge rather than a color wash, so the
|
// Critical notifications get a red edge rather than a color wash, so the
|
||||||
// text contrast never changes.
|
// text contrast never changes.
|
||||||
|
//
|
||||||
|
// Read through Notifs.effectiveUrgency rather than off the notification, so
|
||||||
|
// an application the rules treat as critical is marked here too -- and one
|
||||||
|
// demoted to low is not. The bell, the popup timeout and the Do Not Disturb
|
||||||
|
// breakthrough all ask the same question the same way.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
@@ -65,7 +70,7 @@ Rectangle {
|
|||||||
radius: 1.5
|
radius: 1.5
|
||||||
border.width: 0
|
border.width: 0
|
||||||
color: Theme.urgent
|
color: Theme.urgent
|
||||||
visible: root.notification.urgency === NotificationUrgency.Critical
|
visible: Notifs.effectiveUrgency(root.notification) === NotificationUrgency.Critical
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var defaultAction: {
|
readonly property var defaultAction: {
|
||||||
@@ -85,17 +90,43 @@ Rectangle {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything except the buttons: clicking the body runs the notification's
|
// A command the notification carried as data, in the `panama-exec` hint.
|
||||||
// default action, which is what GNOME does.
|
// Panama's escalation ladder rides this: a crash watcher that has already
|
||||||
|
// exited, or an install that failed in a terminal, still gets a clickable
|
||||||
|
// "diagnose this with your agent" -- the command IS the notification, so
|
||||||
|
// nothing has to stay alive to service an action and the click survives a
|
||||||
|
// shell restart. Read once at delivery; see services/Notifs.qml for why
|
||||||
|
// that is safe and what it deliberately does not promise.
|
||||||
|
readonly property string execCommand: Notifs.execCommand(root.notification)
|
||||||
|
|
||||||
|
readonly property bool bodyActivates: root.defaultAction !== null || root.execCommand !== ""
|
||||||
|
|
||||||
|
// Clicking the body runs the notification's default action, which is what
|
||||||
|
// GNOME does. The sender's own action wins when a notification carries
|
||||||
|
// both: an application that registered one is asking for ITS handler, and
|
||||||
|
// the hint exists for senders that cannot stay alive to serve one.
|
||||||
|
//
|
||||||
|
// The command runs through `sh -c` because it arrives as a single string
|
||||||
|
// rather than an argv -- that is the shape the hint can carry. It runs
|
||||||
|
// detached, so a notification click never blocks or outlives the shell.
|
||||||
|
function activateBody(): void {
|
||||||
|
if (root.defaultAction) {
|
||||||
|
root.defaultAction.invoke();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.execCommand === "")
|
||||||
|
return;
|
||||||
|
Quickshell.execDetached(["sh", "-c", root.execCommand]);
|
||||||
|
root.dismissed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything except the buttons.
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: hover
|
id: hover
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: root.bodyActivates ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: {
|
onClicked: root.activateBody()
|
||||||
if (root.defaultAction)
|
|
||||||
root.defaultAction.invoke();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IconImage {
|
IconImage {
|
||||||
@@ -146,12 +177,20 @@ Rectangle {
|
|||||||
id: settingsMenu
|
id: settingsMenu
|
||||||
anchorItem: settingsMenuButton
|
anchorItem: settingsMenuButton
|
||||||
|
|
||||||
// A Column already measures itself from its children, and in Qt 6 both
|
// Popover's container is a plain Item and does not size itself from its
|
||||||
// implicit sizes are read-only on a positioner -- assigning them makes
|
// children, so the window dimensions come from the column's implicit
|
||||||
// the whole shell fail to load rather than just this menu.
|
// size — the same pattern TrayMenu uses. Rows take their *actual* width
|
||||||
|
// from the window in the other direction; the two chains are
|
||||||
|
// independent, so there is no binding loop.
|
||||||
|
implicitWidth: Math.max(menuBody.implicitWidth + contentPadding * 2, 200)
|
||||||
|
implicitHeight: menuBody.implicitHeight + contentPadding * 2
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
id: menuBody
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
TrayMenuRow {
|
TrayMenuRow {
|
||||||
id: notificationSettings
|
width: parent.width
|
||||||
label: "Notification settings"
|
label: "Notification settings"
|
||||||
onActivated: {
|
onActivated: {
|
||||||
ShellState.openSettings("notifications");
|
ShellState.openSettings("notifications");
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import qs.services
|
|||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
visible: !Notifs.doNotDisturb && Notifs.popups.length > 0
|
// Whether a notification is allowed to be a banner is decided once, in
|
||||||
|
// Notifs.handleNotification: a focus mode's allow-list and the
|
||||||
|
// critical-breakthrough switch are both exceptions to Do Not Disturb, and
|
||||||
|
// anything they let past is already in `popups`. Re-testing doNotDisturb
|
||||||
|
// here would override that three-way decision and leave an allowed app
|
||||||
|
// chiming at an empty screen.
|
||||||
|
visible: Notifs.popups.length > 0
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
anchors.top: true
|
anchors.top: true
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// The visual bell: one flash at the edges of the screen when a notification
|
||||||
|
// arrives that would have rung.
|
||||||
|
//
|
||||||
|
// For people who cannot hear the bell. It fires on exactly the notifications
|
||||||
|
// services/Notifs.qml calls bell-eligible -- same per-application switch, same
|
||||||
|
// low-urgency rule, same suppress-sound hint -- but NOT on the event-sounds
|
||||||
|
// switch, which would make this do nothing for the person it is for. That rule
|
||||||
|
// is pinned in Notifs.qml above `bellWouldRing`; this file only listens.
|
||||||
|
//
|
||||||
|
// Edges rather than the whole screen. A full-screen white flash is what X11's
|
||||||
|
// visual bell did, and it is genuinely unpleasant: it destroys dark adaptation,
|
||||||
|
// hides the thing you were reading at the moment it demands attention, and is
|
||||||
|
// the shape of flash that photosensitivity guidance warns about. A soft glow
|
||||||
|
// inward from the four edges is unmissable in peripheral vision and leaves the
|
||||||
|
// middle of the screen -- the part being read -- alone.
|
||||||
|
//
|
||||||
|
// ONE animation per notification. There is no `loops`, no Timer that restarts
|
||||||
|
// it, and a burst of notifications cannot stack flashes: while the animation is
|
||||||
|
// running, further triggers are ignored outright. A strobing screen is a
|
||||||
|
// seizure risk, not a notification.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var modelData: null
|
||||||
|
screen: root.modelData
|
||||||
|
|
||||||
|
// Mapped only while flashing. The rest of the session this costs nothing,
|
||||||
|
// and no surface sits over the desktop waiting for something to happen.
|
||||||
|
property bool mapped: false
|
||||||
|
visible: root.mapped
|
||||||
|
|
||||||
|
anchors.top: true
|
||||||
|
anchors.bottom: true
|
||||||
|
anchors.left: true
|
||||||
|
anchors.right: true
|
||||||
|
|
||||||
|
// Reserve nothing and respect nothing: the glow is drawn over the whole
|
||||||
|
// output including under the bar and the dock, which is what makes it
|
||||||
|
// visible from wherever the eyes happen to be.
|
||||||
|
exclusiveZone: 0
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
WlrLayershell.namespace: "qs-visual-bell"
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||||
|
|
||||||
|
// Entirely click-through: an empty mask means no part of this surface
|
||||||
|
// takes a pointer event, so a flash cannot swallow the click you were in
|
||||||
|
// the middle of making.
|
||||||
|
mask: Region {}
|
||||||
|
|
||||||
|
// How far the glow reaches in from each edge. Fixed pixels rather than a
|
||||||
|
// share of the screen: this is about peripheral vision, which does not
|
||||||
|
// scale with the size of the monitor.
|
||||||
|
readonly property int reach: 72
|
||||||
|
|
||||||
|
// Deliberately NOT Theme.durFast / Theme.durNormal. Those collapse to zero
|
||||||
|
// when Reduce motion is on, which would make the flash instantaneous and
|
||||||
|
// therefore invisible -- switching on Reduce motion would silently switch
|
||||||
|
// off Visual alerts. A flash is information, not decoration, so it keeps
|
||||||
|
// its own timings. They are slow enough not to strobe and quick enough to
|
||||||
|
// be over before it becomes irritating.
|
||||||
|
readonly property int riseMs: 110
|
||||||
|
readonly property int fallMs: 340
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: Notifs
|
||||||
|
function onBellEligible(notification: var): void { root.flash(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-shot. A trigger arriving mid-flash is dropped rather than
|
||||||
|
// queued or restarted, so ten notifications landing together are one
|
||||||
|
// flash -- the same coalescing the audible bell gets from its throttle.
|
||||||
|
function flash(): void {
|
||||||
|
if (!Settings.visualAlerts || pulse.running)
|
||||||
|
return;
|
||||||
|
root.mapped = true;
|
||||||
|
pulse.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: glow
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
opacity: 0
|
||||||
|
|
||||||
|
readonly property color tint: Theme.alpha(Theme.accent, 0.62)
|
||||||
|
readonly property color fade: Theme.alpha(Theme.accent, 0)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: root.reach
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: glow.tint }
|
||||||
|
GradientStop { position: 1.0; color: glow.fade }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: root.reach
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: glow.fade }
|
||||||
|
GradientStop { position: 1.0; color: glow.tint }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: root.reach
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: glow.tint }
|
||||||
|
GradientStop { position: 1.0; color: glow.fade }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: root.reach
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: glow.fade }
|
||||||
|
GradientStop { position: 1.0; color: glow.tint }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up, down, gone. One sequence, run once per notification: no `loops`, no
|
||||||
|
// repeat, and the window unmaps itself at the end so nothing is left over
|
||||||
|
// the desktop between notifications.
|
||||||
|
SequentialAnimation {
|
||||||
|
id: pulse
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
target: glow
|
||||||
|
property: "opacity"
|
||||||
|
from: 0
|
||||||
|
to: 1
|
||||||
|
duration: root.riseMs
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
NumberAnimation {
|
||||||
|
target: glow
|
||||||
|
property: "opacity"
|
||||||
|
to: 0
|
||||||
|
duration: root.fallMs
|
||||||
|
easing.type: Easing.InCubic
|
||||||
|
}
|
||||||
|
ScriptAction { script: root.mapped = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ function iconFor(kind, ratio) {
|
|||||||
return "audio-input-microphone-symbolic";
|
return "audio-input-microphone-symbolic";
|
||||||
if (name === "brightness")
|
if (name === "brightness")
|
||||||
return "display-brightness-symbolic";
|
return "display-brightness-symbolic";
|
||||||
|
// The magnifier, stepped from SUPER+ALT+= / - / 0. Ratio 0 is 1.00 ×,
|
||||||
|
// which is the magnifier switched off, so it gets the "actual size" icon
|
||||||
|
// rather than a magnifying glass claiming to be magnifying.
|
||||||
|
if (name === "zoom")
|
||||||
|
return ratio <= 0 ? "zoom-original-symbolic" : "zoom-in-symbolic";
|
||||||
if (name === "media-play" || name === "media-playing")
|
if (name === "media-play" || name === "media-playing")
|
||||||
return "media-playback-start-symbolic";
|
return "media-playback-start-symbolic";
|
||||||
if (name === "media-pause" || name === "media-paused")
|
if (name === "media-pause" || name === "media-paused")
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// One tile in the power menu: big glyph, label underneath.
|
// One tile in the power menu: big glyph, label underneath.
|
||||||
//
|
//
|
||||||
// Anything that ends the session arms on the first press and only fires on the
|
// Anything that ends the session arms on the first press and only fires on the
|
||||||
// second, with the label swapping to "Confirm" — an accidental Ctrl+Alt+Delete
|
// second — an accidental Ctrl+Alt+Delete should never be one click away from
|
||||||
// should never be one click away from losing everything that is open.
|
// losing everything that is open.
|
||||||
|
//
|
||||||
|
// Armed, the tile turns red and names the thing it is about to do ("Power off")
|
||||||
|
// rather than saying "Confirm". Six tiles could all say "Confirm"; only one of
|
||||||
|
// them is about to take the machine down, and the press that does it should say
|
||||||
|
// which one it is.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
@@ -21,6 +26,13 @@ Rectangle {
|
|||||||
|
|
||||||
readonly property bool armed: confirmTimer.running
|
readonly property bool armed: confirmTimer.running
|
||||||
|
|
||||||
|
// The tile's own label, said as a sentence rather than as a title: "Power
|
||||||
|
// Off" is the name of a menu entry, "Power off" is the thing the next press
|
||||||
|
// does. Derived, so an entry added to the menu cannot forget to name itself.
|
||||||
|
readonly property string armedLabel: root.label === ""
|
||||||
|
? "Confirm"
|
||||||
|
: root.label.charAt(0) + root.label.slice(1).toLowerCase()
|
||||||
|
|
||||||
implicitWidth: 136
|
implicitWidth: 136
|
||||||
implicitHeight: 136
|
implicitHeight: 136
|
||||||
radius: Theme.cardRadius + 6
|
radius: Theme.cardRadius + 6
|
||||||
@@ -68,7 +80,7 @@ Rectangle {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
text: root.armed ? "Confirm" : root.label
|
text: root.armed ? root.armedLabel : root.label
|
||||||
color: root.armed ? Theme.danger : Theme.fg
|
color: root.armed ? Theme.danger : Theme.fg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// (see PowerButton); Lock and Suspend are one press because neither loses work.
|
// (see PowerButton); Lock and Suspend are one press because neither loses work.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
@@ -31,6 +32,23 @@ PanelWindow {
|
|||||||
|
|
||||||
property int currentIndex: 0
|
property int currentIndex: 0
|
||||||
|
|
||||||
|
// Hibernate appears only where it can deliver: logind answers "yes" only
|
||||||
|
// with a resume-capable swap configured, and a menu entry that fails
|
||||||
|
// silently is worse than none. Checked once per shell run -- swap does
|
||||||
|
// not come and go.
|
||||||
|
property bool canHibernate: false
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: hibernateProbe
|
||||||
|
command: ["busctl", "call", "org.freedesktop.login1",
|
||||||
|
"/org/freedesktop/login1", "org.freedesktop.login1.Manager",
|
||||||
|
"CanHibernate"]
|
||||||
|
running: true
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: win.canHibernate = this.text.includes('"yes"')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A uwsm-managed session logs out by stopping the uwsm unit; a plain
|
// A uwsm-managed session logs out by stopping the uwsm unit; a plain
|
||||||
// "Hyprland" session must ask the compositor to exit instead. The branch
|
// "Hyprland" session must ask the compositor to exit instead. The branch
|
||||||
// used to test merely that the uwsm BINARY existed -- but uwsm is always
|
// used to test merely that the uwsm BINARY existed -- but uwsm is always
|
||||||
@@ -41,32 +59,53 @@ PanelWindow {
|
|||||||
// compositor unit, wayland-wm@*.service, so that is what decides.
|
// compositor unit, wayland-wm@*.service, so that is what decides.
|
||||||
readonly property string logoutScript: 'if systemctl --user list-units --no-legend --state=active "wayland-wm@*.service" 2>/dev/null | grep -q .; then exec uwsm stop; else exec hyprctl dispatch "hl.dsp.exit()"; fi'
|
readonly property string logoutScript: 'if systemctl --user list-units --no-legend --state=active "wayland-wm@*.service" 2>/dev/null | grep -q .; then exec uwsm stop; else exec hyprctl dispatch "hl.dsp.exit()"; fi'
|
||||||
|
|
||||||
readonly property var entries: [
|
// The rows actually shown: an entry may declare `available: false` to
|
||||||
|
// withdraw itself (hibernate on a machine with no resume swap).
|
||||||
|
readonly property var entries: allEntries.filter(entry => entry.available !== false)
|
||||||
|
|
||||||
|
// `entryId` is the stable name outside code can address an entry by --
|
||||||
|
// the power-button bind asks for "poweroff" and gets Power Off wherever it
|
||||||
|
// happens to sit. Labels are copy and hibernate comes and goes, so neither
|
||||||
|
// is something an IPC call can be built on.
|
||||||
|
readonly property var allEntries: [
|
||||||
{
|
{
|
||||||
|
entryId: "lock",
|
||||||
glyph: "",
|
glyph: "",
|
||||||
label: "Lock",
|
label: "Lock",
|
||||||
destructive: false,
|
destructive: false,
|
||||||
cmd: ["loginctl", "lock-session"]
|
cmd: ["loginctl", "lock-session"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
entryId: "logout",
|
||||||
glyph: "",
|
glyph: "",
|
||||||
label: "Log Out",
|
label: "Log Out",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
cmd: ["sh", "-c", win.logoutScript]
|
cmd: ["sh", "-c", win.logoutScript]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
entryId: "suspend",
|
||||||
glyph: "",
|
glyph: "",
|
||||||
label: "Suspend",
|
label: "Suspend",
|
||||||
destructive: false,
|
destructive: false,
|
||||||
cmd: ["systemctl", "suspend"]
|
cmd: ["systemctl", "suspend"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
entryId: "hibernate",
|
||||||
|
glyph: "",
|
||||||
|
label: "Hibernate",
|
||||||
|
destructive: false,
|
||||||
|
available: win.canHibernate,
|
||||||
|
cmd: ["systemctl", "hibernate"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entryId: "restart",
|
||||||
glyph: "",
|
glyph: "",
|
||||||
label: "Restart",
|
label: "Restart",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
cmd: ["systemctl", "reboot"]
|
cmd: ["systemctl", "reboot"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
entryId: "poweroff",
|
||||||
glyph: "",
|
glyph: "",
|
||||||
label: "Power Off",
|
label: "Power Off",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
@@ -74,14 +113,70 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
onVisibleChanged: {
|
// The entry to arm once the menu is on screen, set by preselect() before
|
||||||
if (!win.visible)
|
// opening. Cleared as soon as it is applied, and again on close, so an
|
||||||
|
// interrupted open can never arm something on the next unrelated one.
|
||||||
|
property string armOnOpen: ""
|
||||||
|
|
||||||
|
function indexOfEntry(entryId: string): int {
|
||||||
|
for (let i = 0; i < win.entries.length; i++) {
|
||||||
|
if (win.entries[i].entryId === entryId)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the menu with one entry pre-armed, addressed by its id.
|
||||||
|
//
|
||||||
|
// This is what the power button's "Powers off" setting binds to: the first
|
||||||
|
// press opens the menu with Power Off selected and armed, and the second
|
||||||
|
// press is the confirm the menu already asks for. Nothing here goes around
|
||||||
|
// that confirm -- it calls the same trigger() a click calls, so a
|
||||||
|
// destructive entry still takes two presses and a harmless one still takes
|
||||||
|
// one. Pre-arming only removes the reach for the mouse, not the question.
|
||||||
|
function preselect(entryId: string): void {
|
||||||
|
const index = win.indexOfEntry(entryId);
|
||||||
|
if (index < 0) {
|
||||||
|
// An id this machine has no entry for -- hibernate without a
|
||||||
|
// resume swap. Open the menu rather than doing nothing at all.
|
||||||
|
win.armOnOpen = "";
|
||||||
|
ShellState.open("powermenu");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
if (!win.visible) {
|
||||||
|
win.armOnOpen = entryId;
|
||||||
|
ShellState.open("powermenu");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Already open: this press is the next one in the sequence.
|
||||||
|
win.currentIndex = index;
|
||||||
|
const button = rep.itemAt(index);
|
||||||
|
if (button)
|
||||||
|
button.trigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (!win.visible) {
|
||||||
|
win.armOnOpen = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Never reopen with a destructive button still armed from last time.
|
// Never reopen with a destructive button still armed from last time.
|
||||||
win.currentIndex = 0;
|
win.currentIndex = 0;
|
||||||
for (let i = 0; i < rep.count; i++)
|
for (let i = 0; i < rep.count; i++)
|
||||||
rep.itemAt(i).disarm();
|
rep.itemAt(i).disarm();
|
||||||
keys.forceActiveFocus();
|
keys.forceActiveFocus();
|
||||||
|
|
||||||
|
const wanted = win.armOnOpen;
|
||||||
|
win.armOnOpen = "";
|
||||||
|
if (wanted === "")
|
||||||
|
return;
|
||||||
|
const index = win.indexOfEntry(wanted);
|
||||||
|
if (index < 0)
|
||||||
|
return;
|
||||||
|
win.currentIndex = index;
|
||||||
|
const button = rep.itemAt(index);
|
||||||
|
if (button)
|
||||||
|
button.trigger();
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(index: int): void {
|
function run(index: int): void {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ Item {
|
|||||||
readonly property real volume: root.node && root.node.audio ? root.node.audio.volume : 0
|
readonly property real volume: root.node && root.node.audio ? root.node.audio.volume : 0
|
||||||
readonly property bool muted: root.node && root.node.audio ? root.node.audio.muted : false
|
readonly property bool muted: root.node && root.node.audio ? root.node.audio.muted : false
|
||||||
|
|
||||||
|
// Over-amplification extends the output to 150%. The input is never
|
||||||
|
// extended: a microphone above 100% is gain on noise, not loudness.
|
||||||
|
readonly property real maximum: root.output && Settings.overAmplification ? 1.5 : 1
|
||||||
|
|
||||||
PwObjectTracker {
|
PwObjectTracker {
|
||||||
objects: root.node ? [root.node] : []
|
objects: root.node ? [root.node] : []
|
||||||
}
|
}
|
||||||
@@ -57,21 +61,46 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ValueSlider {
|
ValueSlider {
|
||||||
|
id: slider
|
||||||
anchors.left: muteButton.right
|
anchors.left: muteButton.right
|
||||||
anchors.leftMargin: 6
|
anchors.leftMargin: 6
|
||||||
anchors.right: chevron.left
|
anchors.right: chevron.left
|
||||||
anchors.rightMargin: 4
|
anchors.rightMargin: 4
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
value: root.muted ? 0 : root.volume
|
value: root.muted ? 0 : root.volume / root.maximum
|
||||||
onMoved: v => {
|
onMoved: v => {
|
||||||
if (!root.node || !root.node.audio)
|
if (!root.node || !root.node.audio)
|
||||||
return;
|
return;
|
||||||
// Nudging the slider is also how you unmute, same as GNOME.
|
// Nudging the slider is also how you unmute, same as GNOME.
|
||||||
root.node.audio.muted = false;
|
root.node.audio.muted = false;
|
||||||
root.node.audio.volume = v;
|
root.node.audio.volume = v * root.maximum;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything above 100%, marked over the track so the loud end of the
|
||||||
|
// slider is visibly the loud end rather than more of the same.
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: slider.right
|
||||||
|
anchors.verticalCenter: slider.verticalCenter
|
||||||
|
width: root.maximum > 1 ? slider.width * (1 - 1 / root.maximum) : 0
|
||||||
|
height: 10
|
||||||
|
radius: 2
|
||||||
|
visible: root.maximum > 1
|
||||||
|
color: Theme.alpha(Theme.warn, 0.30)
|
||||||
|
border.width: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: slider.verticalCenter
|
||||||
|
x: slider.x + slider.width / root.maximum - 1
|
||||||
|
width: 2
|
||||||
|
height: 16
|
||||||
|
radius: 1
|
||||||
|
visible: root.maximum > 1
|
||||||
|
color: Theme.alpha(Theme.warn, 0.7)
|
||||||
|
border.width: 0
|
||||||
|
}
|
||||||
|
|
||||||
IconButton {
|
IconButton {
|
||||||
id: chevron
|
id: chevron
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Quickshell
|
|||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
import Quickshell.Bluetooth
|
import Quickshell.Bluetooth
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -16,7 +17,15 @@ Item {
|
|||||||
property alias maxHeight: list.maxHeight
|
property alias maxHeight: list.maxHeight
|
||||||
|
|
||||||
readonly property var adapter: Bluetooth.defaultAdapter
|
readonly property var adapter: Bluetooth.defaultAdapter
|
||||||
property bool discoveryOwned: false
|
|
||||||
|
// Discovery is held, not switched. This picker and the settings page both
|
||||||
|
// list the same adapter, and each writing adapter.discovering from its own
|
||||||
|
// visibility flag meant the last one to change its mind decided for both --
|
||||||
|
// closing the settings page stopped discovery under this panel, which then
|
||||||
|
// said "Searching…" over a radio that had stopped. Connectivity counts the
|
||||||
|
// holds, owns the BlueZ write, and never stops a scan it did not start;
|
||||||
|
// nothing here touches the adapter.
|
||||||
|
readonly property string scanHold: "quicksettings-bluetooth"
|
||||||
|
|
||||||
implicitHeight: list.implicitHeight
|
implicitHeight: list.implicitHeight
|
||||||
|
|
||||||
@@ -32,29 +41,39 @@ Item {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDiscovery(): void {
|
// Forgetting is armed in place rather than through ConfirmAction: a 44px
|
||||||
if (!root.adapter)
|
// row inside a 300px popup has no width for a Keep/Forget it pair beside
|
||||||
return;
|
// the device name, so the trash glyph arms itself and the row's own
|
||||||
const shouldDiscover = root.active && root.adapter.enabled;
|
// sublabel becomes the question. The token is ShellState's, the same one
|
||||||
if (shouldDiscover && !root.adapter.discovering) {
|
// ConfirmAction uses, so arming here disarms whatever was armed elsewhere
|
||||||
root.adapter.discovering = true;
|
// and only one confirm is ever live app-wide.
|
||||||
root.discoveryOwned = true;
|
//
|
||||||
} else if (!shouldDiscover && root.discoveryOwned && root.adapter.discovering) {
|
// The token is keyed by device address rather than held on the delegate,
|
||||||
root.adapter.discovering = false;
|
// because discovery rebuilds this list -- and every delegate in it -- every
|
||||||
root.discoveryOwned = false;
|
// time BlueZ reports something new. An arm parked on the row object would
|
||||||
|
// be dropped by the next scan result; keyed by address it survives the
|
||||||
|
// rebuild and is only ever cleared by a press or by the section closing.
|
||||||
|
readonly property string forgetPrefix: "bluetooth-forget-quick:"
|
||||||
|
|
||||||
|
function forgetId(device): string {
|
||||||
|
return root.forgetPrefix + (device.address || device.name || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function disarmForget(): void {
|
||||||
|
if (ShellState.armedConfirm.indexOf(root.forgetPrefix) === 0)
|
||||||
|
ShellState.armedConfirm = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onActiveChanged: {
|
||||||
|
if (root.active) {
|
||||||
|
Connectivity.acquireDiscovery(root.scanHold);
|
||||||
|
} else {
|
||||||
|
Connectivity.releaseDiscovery(root.scanHold);
|
||||||
|
root.disarmForget();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onActiveChanged: root.syncDiscovery()
|
Component.onDestruction: Connectivity.releaseDiscovery(root.scanHold)
|
||||||
onAdapterChanged: {
|
|
||||||
if (root.active)
|
|
||||||
root.syncDiscovery();
|
|
||||||
}
|
|
||||||
|
|
||||||
Component.onDestruction: {
|
|
||||||
if (root.adapter && root.discoveryOwned && root.adapter.discovering)
|
|
||||||
root.adapter.discovering = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateText(device): string {
|
function stateText(device): string {
|
||||||
if (device.pairing)
|
if (device.pairing)
|
||||||
@@ -112,18 +131,36 @@ Item {
|
|||||||
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool forgetArmed:
|
||||||
|
ShellState.armedConfirm === root.forgetId(deviceRow.modelData)
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
// BlueZ reports a plain freedesktop name ("audio-headphones");
|
// BlueZ reports a plain freedesktop name ("audio-headphones");
|
||||||
// the symbolic variant is the one that can be recolored.
|
// the symbolic variant is the one that can be recolored.
|
||||||
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
|
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
|
||||||
iconFallback: "bluetooth-symbolic"
|
iconFallback: "bluetooth-symbolic"
|
||||||
label: deviceRow.modelData.name || deviceRow.modelData.address
|
label: deviceRow.modelData.name || deviceRow.modelData.address
|
||||||
sublabel: root.stateText(deviceRow.modelData)
|
// Armed, the row states the question and what it costs, since
|
||||||
|
// the glyph alone is too small to carry either.
|
||||||
|
sublabel: deviceRow.forgetArmed
|
||||||
|
? "Press again to drop the pairing"
|
||||||
|
: root.stateText(deviceRow.modelData)
|
||||||
selected: deviceRow.modelData.connected
|
selected: deviceRow.modelData.connected
|
||||||
onClicked: root.activate(deviceRow.modelData)
|
// While armed the row body is the way out: a press anywhere
|
||||||
|
// else on it takes the arming back instead of connecting, so
|
||||||
|
// the escape is the largest target on screen.
|
||||||
|
onClicked: {
|
||||||
|
if (deviceRow.forgetArmed) {
|
||||||
|
root.disarmForget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.activate(deviceRow.modelData);
|
||||||
|
}
|
||||||
|
|
||||||
// Forgetting is destructive enough that it gets its own
|
// Forgetting is destructive enough that it gets its own
|
||||||
// control rather than sharing the row click.
|
// control rather than sharing the row click -- and its own
|
||||||
|
// two presses. See root.forgetPrefix for why the arming
|
||||||
|
// happens in place here rather than through ConfirmAction.
|
||||||
IconButton {
|
IconButton {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: deviceRow.modelData.paired
|
visible: deviceRow.modelData.paired
|
||||||
@@ -131,7 +168,28 @@ Item {
|
|||||||
iconSize: 13
|
iconSize: 13
|
||||||
icon: "user-trash-symbolic"
|
icon: "user-trash-symbolic"
|
||||||
iconFallback: "window-close-symbolic"
|
iconFallback: "window-close-symbolic"
|
||||||
onClicked: deviceRow.modelData.forget()
|
tint: deviceRow.forgetArmed ? Theme.danger : Theme.fg
|
||||||
|
onClicked: {
|
||||||
|
if (!deviceRow.forgetArmed) {
|
||||||
|
ShellState.armedConfirm = root.forgetId(deviceRow.modelData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.disarmForget();
|
||||||
|
deviceRow.modelData.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behind the parent's own fill, so the hover tint still
|
||||||
|
// reads on top of it. Danger appears only once armed --
|
||||||
|
// the first press must not look like the last one.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
z: -1
|
||||||
|
radius: parent.radius
|
||||||
|
visible: deviceRow.forgetArmed
|
||||||
|
color: Theme.alpha(Theme.danger, 0.3)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.danger, 0.6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,6 +301,6 @@ Item {
|
|||||||
|
|
||||||
function openHomeSettings(): void {
|
function openHomeSettings(): void {
|
||||||
ShellState.close();
|
ShellState.close();
|
||||||
ShellState.openSettings("home-phone");
|
ShellState.openSettings("my-home");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,34 @@ Item {
|
|||||||
onToggled: Caffeine.toggle()
|
onToggled: Caffeine.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Do Not Disturb on its own, beside Presentation. The service, the
|
||||||
|
// IPC verb and the settings row all existed; only the tile was
|
||||||
|
// missing, so the one-press way to silence banners was a shortcut
|
||||||
|
// you had to already know. Presentation keeps its combined role --
|
||||||
|
// this is the half of it people want without the awake half.
|
||||||
|
Toggle {
|
||||||
|
width: root.cellWidth
|
||||||
|
icon: Notifs.doNotDisturb
|
||||||
|
? "notifications-disabled-symbolic"
|
||||||
|
: "preferences-system-notifications-symbolic"
|
||||||
|
label: "Do Not Disturb"
|
||||||
|
sublabel: Notifs.doNotDisturb ? "Banners held" : "Off"
|
||||||
|
active: Notifs.doNotDisturb
|
||||||
|
onToggled: Notifs.doNotDisturb = !Notifs.doNotDisturb
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caffeine plus Do Not Disturb as one switch, for the projector:
|
||||||
|
// the half you forget to arm is the one that fires a message
|
||||||
|
// preview onto the big screen. Restores both exactly as found.
|
||||||
|
Toggle {
|
||||||
|
width: root.cellWidth
|
||||||
|
icon: "video-display-symbolic"
|
||||||
|
label: "Presentation"
|
||||||
|
sublabel: PresentationMode.active ? "Awake · notifications held" : "Off"
|
||||||
|
active: PresentationMode.active
|
||||||
|
onToggled: PresentationMode.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
Toggle {
|
Toggle {
|
||||||
width: root.cellWidth
|
width: root.cellWidth
|
||||||
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
|
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
|
||||||
@@ -204,6 +232,7 @@ Item {
|
|||||||
|
|
||||||
RowButton {
|
RowButton {
|
||||||
width: content.width
|
width: content.width
|
||||||
|
visible: Settings.ccShowFocus
|
||||||
icon: "preferences-system-time-symbolic"
|
icon: "preferences-system-time-symbolic"
|
||||||
iconFallback: "appointment-soon-symbolic"
|
iconFallback: "appointment-soon-symbolic"
|
||||||
label: FocusSession.active ? "Focus · " + FocusSession.workspaceLabel : "Start focus session"
|
label: FocusSession.active ? "Focus · " + FocusSession.workspaceLabel : "Start focus session"
|
||||||
@@ -331,14 +360,18 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Connected life ─────────────────────────────────────────────────
|
// ── Connected life ─────────────────────────────────────────────────
|
||||||
|
// Hidden sections leave no gap: Column skips invisible children
|
||||||
|
// outright, so the panel closes up around them.
|
||||||
HomeControls {
|
HomeControls {
|
||||||
width: content.width
|
width: content.width
|
||||||
|
visible: Settings.ccShowHome
|
||||||
expanded: root.expandedSection === "home"
|
expanded: root.expandedSection === "home"
|
||||||
onToggleExpanded: root.expand("home")
|
onToggleExpanded: root.expand("home")
|
||||||
}
|
}
|
||||||
|
|
||||||
PhoneControls {
|
PhoneControls {
|
||||||
width: content.width
|
width: content.width
|
||||||
|
visible: Settings.ccShowPhone
|
||||||
expanded: root.expandedSection === "phone"
|
expanded: root.expandedSection === "phone"
|
||||||
onToggleExpanded: root.expand("phone")
|
onToggleExpanded: root.expand("phone")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Quickshell
|
|||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
import Quickshell.Networking
|
import Quickshell.Networking
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
@@ -45,18 +46,22 @@ Item {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncScanner(): void {
|
// The scan is held, not switched. This picker and the settings page both
|
||||||
if (root.device)
|
// list the same radio, and each writing scannerEnabled from its own
|
||||||
root.device.scannerEnabled = root.active;
|
// visibility flag meant the last one to change its mind decided for both --
|
||||||
|
// closing the settings page stopped the scan under this panel, which then
|
||||||
|
// said "Scanning…" over a radio that had stopped. Connectivity counts the
|
||||||
|
// holds; nothing here touches the device.
|
||||||
|
readonly property string scanHold: "quicksettings-wifi"
|
||||||
|
|
||||||
|
onActiveChanged: {
|
||||||
|
if (root.active)
|
||||||
|
Connectivity.acquireWifiScan(root.scanHold);
|
||||||
|
else
|
||||||
|
Connectivity.releaseWifiScan(root.scanHold);
|
||||||
}
|
}
|
||||||
|
|
||||||
onActiveChanged: root.syncScanner()
|
Component.onDestruction: Connectivity.releaseWifiScan(root.scanHold)
|
||||||
onDeviceChanged: root.syncScanner()
|
|
||||||
Component.onCompleted: root.syncScanner()
|
|
||||||
Component.onDestruction: {
|
|
||||||
if (root.device)
|
|
||||||
root.device.scannerEnabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalIcon(strength: real): string {
|
function signalIcon(strength: real): string {
|
||||||
if (strength >= 0.8)
|
if (strength >= 0.8)
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import QtQuick
|
// About.
|
||||||
|
//
|
||||||
|
// The landing page of the System category, and the answer to "what am I
|
||||||
|
// running". Every line here is read from the machine rather than written down:
|
||||||
|
// the versions block used to carry a hardcoded Quickshell version that had been
|
||||||
|
// wrong for two releases, and a Design principles card restating opinions the
|
||||||
|
// manual argues properly.
|
||||||
|
//
|
||||||
|
// Rows come from MachineInfo, except graphics, which is joined from
|
||||||
|
// GraphicsDevices rather than read a second time -- two readouts of the same
|
||||||
|
// hardware are two things that can disagree. Facts the cards below do not claim
|
||||||
|
// by name are still shown, in Software, so a row the helper learns to report
|
||||||
|
// cannot go missing here.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
objectName: "about"
|
||||||
|
|
||||||
title: "About"
|
title: "About"
|
||||||
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
lede: "This machine, plainly."
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (!MachineInfo.scanned)
|
if (!MachineInfo.scanned)
|
||||||
@@ -16,22 +33,298 @@ SettingsPage {
|
|||||||
GraphicsDevices.refresh();
|
GraphicsDevices.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Reading the machine ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fact(label: string): string {
|
||||||
|
const row = (MachineInfo.facts ?? []).find(entry => entry.label === label);
|
||||||
|
return row ? String(row.value ?? "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joined with a middle dot, skipping whatever is absent. Firmware and
|
||||||
|
// Secure Boot are both absent-tolerant on the helper's side, so either half
|
||||||
|
// of that row can be missing on a given machine.
|
||||||
|
function joined(parts: var): string {
|
||||||
|
return parts.filter(part => String(part ?? "") !== "").join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property string hostname: root.fact("Hostname")
|
||||||
|
// "Fedora Linux 44 (Workstation Edition)" says the edition twice for the
|
||||||
|
// one line where brevity matters most.
|
||||||
|
readonly property string operatingSystem:
|
||||||
|
root.fact("Operating system").replace(/\s*\(.*\)\s*$/, "")
|
||||||
|
readonly property string uptime: root.fact("Uptime").split(",")[0].trim()
|
||||||
|
|
||||||
|
readonly property string identity: root.joined([
|
||||||
|
root.operatingSystem === "" ? "" : "Panama on " + root.operatingSystem,
|
||||||
|
SystemSettings.hyprlandVersion === "" ? "" : "Hyprland " + SystemSettings.hyprlandVersion,
|
||||||
|
root.uptime === "" ? "" : "up " + root.uptime
|
||||||
|
])
|
||||||
|
|
||||||
|
readonly property var graphicsRows: {
|
||||||
|
const gpus = GraphicsDevices.devices ?? [];
|
||||||
|
return gpus.map((device, index) => ({
|
||||||
|
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
|
||||||
|
value: String(device.name ?? "")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property string displayValue: root.joined([
|
||||||
|
SystemSettings.monitorName, root.fact("Resolution")
|
||||||
|
])
|
||||||
|
|
||||||
|
// Everything the three cards below name explicitly. Whatever the helper
|
||||||
|
// reports that is not in here lands in Software rather than nowhere.
|
||||||
|
readonly property var claimedFacts: [
|
||||||
|
"Hostname", "Operating system", "Uptime", "Panama", "Kernel",
|
||||||
|
"Firmware", "Secure Boot", "Model", "Processor", "Memory", "Swap",
|
||||||
|
"Disk", "Resolution"
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly property var otherFacts: (MachineInfo.facts ?? [])
|
||||||
|
.filter(entry => root.claimedFacts.indexOf(String(entry.label ?? "")) < 0)
|
||||||
|
|
||||||
|
// ── Copying it ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The same facts, as plain text, for a bug report or a message to somebody
|
||||||
|
// trying to help. Built from what is on screen so the two cannot disagree.
|
||||||
|
function systemInfoText(): string {
|
||||||
|
const lines = [];
|
||||||
|
if (root.hostname !== "")
|
||||||
|
lines.push(root.hostname);
|
||||||
|
if (root.identity !== "")
|
||||||
|
lines.push(root.identity);
|
||||||
|
lines.push("");
|
||||||
|
for (const row of root.versionRows.concat(root.hardwareRows, root.otherFacts)) {
|
||||||
|
if (String(row.value ?? "") !== "")
|
||||||
|
lines.push(row.label + ": " + row.value);
|
||||||
|
}
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
property string copyResult: ""
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: copyRun
|
||||||
|
|
||||||
|
property string payload: ""
|
||||||
|
|
||||||
|
command: ["wl-copy"]
|
||||||
|
stdinEnabled: true
|
||||||
|
onStarted: {
|
||||||
|
copyRun.write(copyRun.payload);
|
||||||
|
// wl-copy reads stdin until EOF before it exits; leaving the
|
||||||
|
// channel open would hang it forever waiting for more. Same
|
||||||
|
// stdinEnabled close that Health.copyReport uses.
|
||||||
|
copyRun.stdinEnabled = false;
|
||||||
|
}
|
||||||
|
onExited: exitCode => {
|
||||||
|
root.copyResult = exitCode === 0
|
||||||
|
? "Copied."
|
||||||
|
: "Could not reach the clipboard.";
|
||||||
|
copyRun.payload = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySystemInfo(): void {
|
||||||
|
if (copyRun.running)
|
||||||
|
return;
|
||||||
|
root.copyResult = "";
|
||||||
|
copyRun.payload = root.systemInfoText();
|
||||||
|
copyRun.stdinEnabled = true;
|
||||||
|
copyRun.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The hero ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
readonly property var versionRows: [
|
||||||
|
{ label: "Panama", value: root.fact("Panama") },
|
||||||
|
{ label: "Quickshell", value: SystemSettings.quickshellVersion },
|
||||||
|
{ label: "Kernel", value: root.fact("Kernel") },
|
||||||
|
{ label: "Firmware", value: root.joined([
|
||||||
|
root.fact("Firmware"),
|
||||||
|
root.fact("Secure Boot") === "" ? "" : "Secure Boot " + root.fact("Secure Boot")
|
||||||
|
]) }
|
||||||
|
].filter(row => String(row.value ?? "") !== "")
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Desktop"
|
Item {
|
||||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
width: parent.width
|
||||||
|
implicitHeight: Math.max(96, heroCopy.implicitHeight + 16)
|
||||||
|
|
||||||
|
// The house tile: a rounded plate carrying the System glyph the
|
||||||
|
// sidebar uses, in the accent, drawn rather than shipped as an
|
||||||
|
// image so it follows the theme like everything else.
|
||||||
|
Rectangle {
|
||||||
|
id: heroArt
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 84
|
||||||
|
height: 84
|
||||||
|
radius: 24
|
||||||
|
color: Theme.alpha(Theme.accent, 0.13)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.3)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
color: "transparent"
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Vertical
|
||||||
|
GradientStop { position: 0; color: Theme.alpha(Theme.accentSecondary, 0.14) }
|
||||||
|
GradientStop { position: 1; color: Theme.alpha(Theme.accent, 0.02) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "\u{F02FD}"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 38
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: heroCopy
|
||||||
|
|
||||||
|
anchors.left: heroArt.right
|
||||||
|
anchors.leftMargin: 20
|
||||||
|
anchors.right: copyButton.left
|
||||||
|
anchors.rightMargin: 18
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 5
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.hostname === "" ? "This machine" : root.hostname
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeTitle
|
||||||
|
font.weight: Font.Bold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.identity !== ""
|
||||||
|
text: root.identity
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.copyResult !== ""
|
||||||
|
text: root.copyResult
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: copyButton
|
||||||
|
|
||||||
|
objectName: "about-copy-button"
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Copy system info"
|
||||||
|
enabled: !copyRun.running
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: root.copySystemInfo()
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: "Copy system info"
|
||||||
|
Keys.onReturnPressed: root.copySystemInfo()
|
||||||
|
Keys.onSpacePressed: root.copySystemInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.versionRows
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Hyprland"
|
required property var modelData
|
||||||
value: SystemSettings.hyprlandVersion || "Detecting…"
|
required property int index
|
||||||
|
|
||||||
|
label: modelData.label
|
||||||
|
value: modelData.value
|
||||||
|
divider: index < root.versionRows.length - 1
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hardware ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
readonly property var hardwareRows: [
|
||||||
|
{ label: "Model", value: root.fact("Model") },
|
||||||
|
{ label: "Processor", value: root.fact("Processor") }
|
||||||
|
].concat(root.graphicsRows, [
|
||||||
|
{ label: "Memory", value: root.joined([
|
||||||
|
root.fact("Memory"),
|
||||||
|
root.fact("Swap") === "" ? "" : root.fact("Swap") + " swap"
|
||||||
|
]) },
|
||||||
|
{ label: "Disk", value: root.fact("Disk") },
|
||||||
|
{ label: "Display", value: root.displayValue }
|
||||||
|
]).filter(row => String(row.value ?? "") !== "")
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Hardware"
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.hardwareRows
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Quickshell"
|
required property var modelData
|
||||||
value: SystemSettings.quickshellVersion
|
|
||||||
|
label: modelData.label
|
||||||
|
value: modelData.value
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Display"
|
visible: MachineInfo.scanned && root.hardwareRows.length === 0
|
||||||
value: SystemSettings.monitorName || "Detecting…"
|
label: "Hardware"
|
||||||
|
detail: "The system did not report anything readable"
|
||||||
|
value: "Unavailable"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The machine's name is a network-facing setting rather than a fact
|
||||||
|
// about the hardware, and Sharing is the page that owns it. Saying so
|
||||||
|
// here is cheaper than a second field that writes the same hostname.
|
||||||
|
ActionRow {
|
||||||
|
label: "Device name"
|
||||||
|
detail: root.hostname === ""
|
||||||
|
? "What this machine calls itself on the network"
|
||||||
|
: root.hostname + " — what this machine calls itself on the network"
|
||||||
|
action: "Open Sharing"
|
||||||
|
divider: false
|
||||||
|
onTriggered: ShellState.openSettings("sharing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Software ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Software"
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.otherFacts
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
label: String(modelData.label ?? "")
|
||||||
|
value: String(modelData.value ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Configuration"
|
label: "Configuration"
|
||||||
detail: Quickshell.shellDir
|
detail: Quickshell.shellDir
|
||||||
@@ -40,74 +333,34 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What GNOME's About panel answers and this page did not: what am I running
|
// ── Manual ───────────────────────────────────────────────────────────────
|
||||||
// on. Rows come from MachineInfo, except graphics, which is joined from
|
|
||||||
// GraphicsDevices rather than read a second time -- two readouts of the same
|
|
||||||
// hardware are two things that can disagree.
|
|
||||||
//
|
|
||||||
// The join splices the GPU rows in directly after Processor rather than
|
|
||||||
// appending them, so the card reads the way fastfetch does: what the system
|
|
||||||
// is, then what is installed on it, then the hardware underneath. A GPU
|
|
||||||
// listed after "Disk" reads as an afterthought.
|
|
||||||
readonly property var machineRows: {
|
|
||||||
const rows = (MachineInfo.facts ?? []).slice();
|
|
||||||
const gpus = GraphicsDevices.devices ?? [];
|
|
||||||
if (gpus.length === 0)
|
|
||||||
return rows;
|
|
||||||
|
|
||||||
const graphics = gpus.map((device, index) => ({
|
ManualChapters { id: contents }
|
||||||
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
|
|
||||||
value: device.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
const after = rows.findIndex(row => row.label === "Processor");
|
|
||||||
if (after < 0)
|
|
||||||
return rows.concat(graphics);
|
|
||||||
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "This machine"
|
title: "Manual"
|
||||||
subtitle: "Hardware and system, as the kernel reports it."
|
subtitle: "How this desktop works, written for the person using it. Links inside a chapter open the settings page they name."
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.machineRows
|
id: chapterRows
|
||||||
|
model: contents.titled
|
||||||
|
|
||||||
TextRow {
|
ActionRow {
|
||||||
id: machineRow
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
label: machineRow.modelData.label
|
label: modelData.title
|
||||||
value: machineRow.modelData.value
|
action: "Read"
|
||||||
divider: machineRow.index < root.machineRows.length - 1
|
onTriggered: ShellState.openSettingsSection("manual", modelData.file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TextRow {
|
ActionRow {
|
||||||
visible: MachineInfo.scanned && root.machineRows.length === 0
|
label: "Open the manual"
|
||||||
label: "Hardware"
|
detail: "Starts at the beginning"
|
||||||
detail: "The system did not report anything readable"
|
action: "Open"
|
||||||
value: "Unavailable"
|
|
||||||
divider: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Design principles"
|
|
||||||
|
|
||||||
TextRow {
|
|
||||||
label: "Curated by default"
|
|
||||||
detail: "Strong choices instead of an incoherent matrix of switches"
|
|
||||||
}
|
|
||||||
TextRow {
|
|
||||||
label: "Quiet while idle"
|
|
||||||
detail: "No continuous decorative repaint loops"
|
|
||||||
}
|
|
||||||
TextRow {
|
|
||||||
label: "Real system boundaries"
|
|
||||||
detail: "Every control either works or clearly hands off to its owner"
|
|
||||||
divider: false
|
divider: false
|
||||||
|
onTriggered: ShellState.openSettings("manual")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
// Advanced accent editing remains a labelled, keyboard-operable extension of
|
// Hue, saturation and value for both ends of the Prism accent.
|
||||||
// the named fast path. Hue is always accompanied by saturation, value, a
|
//
|
||||||
// numeric readout, and the two-colour preview supplied by Appearance.
|
// This is the fine-tune behind a disclosure, not the fast path: the four wells
|
||||||
|
// above cover what most edits are, and these six rows are for the last few
|
||||||
|
// degrees. Each row is a labelled, keyboard-operable slider with a numeric
|
||||||
|
// readout, because a hue ring alone tells someone with a color vision
|
||||||
|
// deficiency nothing.
|
||||||
|
//
|
||||||
|
// Committed on a debounce rather than per move. Each move used to write BOTH
|
||||||
|
// accent preferences, so a single drag across the hue row spent the whole
|
||||||
|
// gesture in apply-and-verify round trips and left the desktop repainting
|
||||||
|
// behind the pointer. The sliders now track the drag locally and one commit
|
||||||
|
// lands once it stops.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
|
||||||
import Quickshell.Io
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
@@ -16,13 +24,18 @@ Column {
|
|||||||
width: parent ? parent.width : 620
|
width: parent ? parent.width : 620
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
property string pickerTarget: "primary"
|
// The pair being dragged; null means "nothing pending, show what is stored".
|
||||||
property string lastError: ""
|
property var pending: null
|
||||||
|
|
||||||
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(
|
readonly property string shownAccent: editor.pending
|
||||||
ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 })
|
? editor.pending.accent : String(ThemeProfiles.activeProfile.accent)
|
||||||
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(
|
readonly property string shownSecondary: editor.pending
|
||||||
ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 })
|
? editor.pending.secondary : String(ThemeProfiles.activeProfile.secondary)
|
||||||
|
|
||||||
|
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(editor.shownAccent)
|
||||||
|
|| ({ h: 0, s: 0, v: 0 })
|
||||||
|
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(editor.shownSecondary)
|
||||||
|
|| ({ h: 0, s: 0, v: 0 })
|
||||||
|
|
||||||
function changeChannel(target: string, channel: string, ratio: real): void {
|
function changeChannel(target: string, channel: string, ratio: real): void {
|
||||||
const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv;
|
const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv;
|
||||||
@@ -30,34 +43,17 @@ Column {
|
|||||||
next[channel] = Math.round(Math.max(0, Math.min(1, ratio))
|
next[channel] = Math.round(Math.max(0, Math.min(1, ratio))
|
||||||
* (channel === "h" ? 360 : 100));
|
* (channel === "h" ? 360 : 100));
|
||||||
const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v);
|
const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v);
|
||||||
const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent;
|
if (!changed)
|
||||||
const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary;
|
|
||||||
ThemeProfiles.setAccentPair(primary, secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(target: string): void {
|
|
||||||
if (screenPicker.running)
|
|
||||||
return;
|
return;
|
||||||
editor.pickerTarget = target;
|
editor.pending = {
|
||||||
editor.lastError = "";
|
accent: target === "primary" ? changed : editor.shownAccent,
|
||||||
screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
|
secondary: target === "secondary" ? changed : editor.shownSecondary
|
||||||
}
|
};
|
||||||
|
commitTimer.restart();
|
||||||
function acceptPicked(value: string): void {
|
|
||||||
const picked = String(value).trim().toLowerCase();
|
|
||||||
if (ThemeProfileModel.hexToHsv(picked) === null) {
|
|
||||||
editor.lastError = "The sampled colour was not valid.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const primary = editor.pickerTarget === "primary"
|
|
||||||
? picked : ThemeProfiles.activeProfile.accent;
|
|
||||||
const secondary = editor.pickerTarget === "secondary"
|
|
||||||
? picked : ThemeProfiles.activeProfile.secondary;
|
|
||||||
ThemeProfiles.setAccentPair(primary, secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
component HsvRow: SettingRow {
|
component HsvRow: SettingRow {
|
||||||
id: root
|
id: row
|
||||||
|
|
||||||
required property string target
|
required property string target
|
||||||
required property string channel
|
required property string channel
|
||||||
@@ -77,19 +73,18 @@ Column {
|
|||||||
activeFocusOnTab: true
|
activeFocusOnTab: true
|
||||||
|
|
||||||
Accessible.role: Accessible.Slider
|
Accessible.role: Accessible.Slider
|
||||||
Accessible.name: root.label
|
Accessible.name: row.label
|
||||||
Accessible.description: Math.round(root.channelValue) + root.suffix
|
Accessible.description: Math.round(row.channelValue) + row.suffix
|
||||||
+ ", range 0 to " + root.channelMaximum
|
+ ", range 0 to " + row.channelMaximum
|
||||||
Accessible.focusable: true
|
Accessible.focusable: true
|
||||||
Accessible.focused: activeFocus
|
Accessible.focused: activeFocus
|
||||||
Accessible.onIncreaseAction: keyboardSlider.step(1)
|
Accessible.onIncreaseAction: keyboardSlider.step(1)
|
||||||
Accessible.onDecreaseAction: keyboardSlider.step(-1)
|
Accessible.onDecreaseAction: keyboardSlider.step(-1)
|
||||||
|
|
||||||
function step(direction: int): void {
|
function step(direction: int): void {
|
||||||
const increment = root.channel === "h" ? 1 : 1;
|
const value = Math.max(0, Math.min(row.channelMaximum,
|
||||||
const value = Math.max(0, Math.min(root.channelMaximum,
|
row.channelValue + direction));
|
||||||
root.channelValue + direction * increment));
|
editor.changeChannel(row.target, row.channel, value / row.channelMaximum);
|
||||||
editor.changeChannel(root.target, root.channel, value / root.channelMaximum);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Keys.onPressed: event => {
|
Keys.onPressed: event => {
|
||||||
@@ -100,10 +95,10 @@ Column {
|
|||||||
keyboardSlider.step(1);
|
keyboardSlider.step(1);
|
||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
} else if (event.key === Qt.Key_Home) {
|
} else if (event.key === Qt.Key_Home) {
|
||||||
editor.changeChannel(root.target, root.channel, 0);
|
editor.changeChannel(row.target, row.channel, 0);
|
||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
} else if (event.key === Qt.Key_End) {
|
} else if (event.key === Qt.Key_End) {
|
||||||
editor.changeChannel(root.target, root.channel, 1);
|
editor.changeChannel(row.target, row.channel, 1);
|
||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,13 +112,14 @@ Column {
|
|||||||
radius: 9
|
radius: 9
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
border.width: keyboardSlider.activeFocus ? 2 : 1
|
border.width: keyboardSlider.activeFocus ? 2 : 1
|
||||||
border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
|
border.color: keyboardSlider.activeFocus
|
||||||
|
? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
|
||||||
ValueSlider {
|
ValueSlider {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 4
|
anchors.margins: 4
|
||||||
value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0
|
value: row.channelMaximum > 0 ? row.channelValue / row.channelMaximum : 0
|
||||||
onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio)
|
onMoved: ratio => editor.changeChannel(row.target, row.channel, ratio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +129,7 @@ Column {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: 56
|
width: 56
|
||||||
horizontalAlignment: Text.AlignRight
|
horizontalAlignment: Text.AlignRight
|
||||||
text: Math.round(root.channelValue) + root.suffix
|
text: Math.round(row.channelValue) + row.suffix
|
||||||
color: Theme.fgDim
|
color: Theme.fgDim
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
@@ -144,75 +140,47 @@ Column {
|
|||||||
|
|
||||||
HsvRow {
|
HsvRow {
|
||||||
target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360
|
target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360
|
||||||
suffix: "°"; label: "Primary hue"; detail: "Colour family, measured from 0 to 360 degrees"
|
suffix: "°"; label: "Primary hue"; detail: "Color family, measured from 0 to 360 degrees"
|
||||||
}
|
}
|
||||||
HsvRow {
|
HsvRow {
|
||||||
target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100
|
target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100
|
||||||
label: "Primary saturation"; detail: "Colour intensity from grey to vivid"
|
label: "Primary saturation"; detail: "Color intensity from grey to vivid"
|
||||||
}
|
}
|
||||||
HsvRow {
|
HsvRow {
|
||||||
target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100
|
target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100
|
||||||
label: "Primary value"; detail: "Brightness from black to full colour"
|
label: "Primary value"; detail: "Brightness from black to full color"
|
||||||
}
|
}
|
||||||
HsvRow {
|
HsvRow {
|
||||||
target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360
|
target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360
|
||||||
suffix: "°"; label: "Secondary hue"; detail: "Colour family at the far end of the Prism gradient"
|
suffix: "°"; label: "Secondary hue"; detail: "Color family at the far end of the Prism gradient"
|
||||||
}
|
}
|
||||||
HsvRow {
|
HsvRow {
|
||||||
target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100
|
target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100
|
||||||
label: "Secondary saturation"; detail: "Colour intensity from grey to vivid"
|
label: "Secondary saturation"; detail: "Color intensity from grey to vivid"
|
||||||
}
|
}
|
||||||
HsvRow {
|
HsvRow {
|
||||||
target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100
|
target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100
|
||||||
label: "Secondary value"; detail: "Brightness from black to full colour"
|
label: "Secondary value"; detail: "Brightness from black to full color"
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
label: "Pick colour from screen"
|
|
||||||
detail: editor.lastError !== ""
|
|
||||||
? editor.lastError
|
|
||||||
: "Sample either end of the accent gradient with hyprpicker"
|
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 330
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 7
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Pick primary from screen"
|
|
||||||
enabled: !screenPicker.running
|
|
||||||
activeFocusOnTab: enabled
|
|
||||||
border.width: activeFocus ? 2 : 1
|
|
||||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
|
||||||
onClicked: editor.pick("primary")
|
|
||||||
Keys.onReturnPressed: if (enabled) editor.pick("primary")
|
|
||||||
Keys.onSpacePressed: if (enabled) editor.pick("primary")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsButton {
|
Timer {
|
||||||
text: "Pick secondary from screen"
|
id: commitTimer
|
||||||
enabled: !screenPicker.running
|
interval: 200
|
||||||
activeFocusOnTab: enabled
|
onTriggered: {
|
||||||
border.width: activeFocus ? 2 : 1
|
if (editor.pending === null)
|
||||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
return;
|
||||||
onClicked: editor.pick("secondary")
|
ThemeProfiles.setAccentPair(editor.pending.accent, editor.pending.secondary);
|
||||||
Keys.onReturnPressed: if (enabled) editor.pick("secondary")
|
releaseTimer.restart();
|
||||||
Keys.onSpacePressed: if (enabled) editor.pick("secondary")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
// Hands the sliders back to the stored pair. If the write was refused they
|
||||||
id: screenPicker
|
// snap back to what is really in effect rather than showing a color
|
||||||
|
// nothing accepted.
|
||||||
stdout: StdioCollector {
|
Timer {
|
||||||
onStreamFinished: editor.acceptPicked(this.text)
|
id: releaseTimer
|
||||||
}
|
interval: 160
|
||||||
onExited: (exitCode, exitStatus) => {
|
onTriggered: editor.pending = null
|
||||||
if (exitCode !== 0)
|
|
||||||
editor.lastError = "Screen colour picking was cancelled or unavailable.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
// Choosing the desktop accent.
|
|
||||||
//
|
|
||||||
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
|
|
||||||
// dot, because the gradient is the thing being chosen -- the focused window
|
|
||||||
// border, the bar hairline and every active state are the two colors meeting.
|
|
||||||
// A row of flat circles would misrepresent all of them.
|
|
||||||
//
|
|
||||||
// Named accents rather than a color wheel: each name carries a curated pair
|
|
||||||
// per scheme, so every choice stays legible in both light and dark. See
|
|
||||||
// config/Theme.qml for the palette and the reasoning.
|
|
||||||
|
|
||||||
import QtQuick
|
|
||||||
import qs.config
|
|
||||||
import qs.services
|
|
||||||
|
|
||||||
Flow {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
spacing: 10
|
|
||||||
|
|
||||||
readonly property string current: ThemeProfiles.activeAccentName
|
|
||||||
|
|
||||||
// The schema's own option list, not Theme.accents directly -- two lists
|
|
||||||
// hand-kept in sync is how they drift. This is the same pattern
|
|
||||||
// ChoiceRow.qml uses for every other enum row.
|
|
||||||
readonly property var spec: PreferenceSchema.spec("accentName")
|
|
||||||
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
// The schema's option order is the palette's order, so blue is first
|
|
||||||
// because it is what Panama ships.
|
|
||||||
model: root.options
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: entry
|
|
||||||
|
|
||||||
required property var modelData
|
|
||||||
|
|
||||||
readonly property string name: entry.modelData.value
|
|
||||||
readonly property var pair: Theme.accents[entry.name]
|
|
||||||
readonly property bool selected: entry.name === root.current
|
|
||||||
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
|
|
||||||
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
|
|
||||||
|
|
||||||
spacing: 5
|
|
||||||
|
|
||||||
// The hit target is the whole swatch+label unit, not just the
|
|
||||||
// 46px circle: the label exists specifically so someone with a
|
|
||||||
// color vision deficiency can identify an accent without it, and
|
|
||||||
// a label that cannot itself be tapped defeats that.
|
|
||||||
HoverHandler {
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
}
|
|
||||||
|
|
||||||
TapHandler {
|
|
||||||
onTapped: {
|
|
||||||
if (!SystemSettings.commitPreference("accentName", entry.name))
|
|
||||||
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: swatch
|
|
||||||
|
|
||||||
width: 46
|
|
||||||
height: 46
|
|
||||||
radius: 23
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
color: "transparent"
|
|
||||||
// The ring sits outside the gradient rather than over it, so a
|
|
||||||
// selected swatch still shows its true colors.
|
|
||||||
border.width: swatch.activeFocus || entry.selected ? 2 : 1
|
|
||||||
border.color: swatch.activeFocus
|
|
||||||
? Theme.accentSecondary
|
|
||||||
: (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14))
|
|
||||||
activeFocusOnTab: true
|
|
||||||
|
|
||||||
Accessible.role: Accessible.Button
|
|
||||||
Accessible.name: entry.pair.label + " accent"
|
|
||||||
Accessible.description: String(entry.start) + " to " + String(entry.end)
|
|
||||||
Accessible.focusable: true
|
|
||||||
Accessible.focused: activeFocus
|
|
||||||
|
|
||||||
function choose(): void {
|
|
||||||
if (!ThemeProfiles.useCuratedAccent(entry.modelData))
|
|
||||||
console.warn("AccentPicker: accent was rejected", entry.modelData);
|
|
||||||
}
|
|
||||||
|
|
||||||
Keys.onReturnPressed: swatch.choose()
|
|
||||||
Keys.onSpacePressed: swatch.choose()
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: entry.selected ? 4 : 3
|
|
||||||
radius: width / 2
|
|
||||||
border.width: 0
|
|
||||||
gradient: Gradient {
|
|
||||||
orientation: Gradient.Horizontal
|
|
||||||
GradientStop { position: 0.0; color: entry.start }
|
|
||||||
GradientStop { position: 1.0; color: entry.end }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
|
||||||
TapHandler { onTapped: swatch.choose() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always shown, not a tooltip. Telling swatches apart by color is
|
|
||||||
// exactly what someone with a color vision deficiency cannot do,
|
|
||||||
// and it is the reason the palette is named rather than freeform --
|
|
||||||
// hiding the names behind a hover would waste that.
|
|
||||||
Text {
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
text: entry.modelData.label
|
|
||||||
color: entry.selected ? Theme.fg : Theme.fgMuted
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
font.weight: entry.selected ? Font.DemiBold : Font.Normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
// Accessibility.
|
// Accessibility.
|
||||||
//
|
//
|
||||||
|
// Organized by what a person came here unable to do -- see it, tolerate the
|
||||||
|
// motion, hear it, reach it from the keyboard, have it read aloud -- rather
|
||||||
|
// than by which subsystem happens to implement each control. The old page was
|
||||||
|
// grouped by mechanism (Pointer / Text / Motion / Magnifier / Contrast), which
|
||||||
|
// is the shape of the code and not the shape of the question.
|
||||||
|
//
|
||||||
|
// Everything on this page acts on this session. Two things deliberately do not
|
||||||
|
// ship as switches -- mono audio, and sticky/slow/bounce keys -- and both say
|
||||||
|
// why in place instead of being quietly absent or, worse, present and dead.
|
||||||
|
//
|
||||||
// Pointer size and text scale have to agree across three consumers that share
|
// Pointer size and text scale have to agree across three consumers that share
|
||||||
// no configuration system -- the compositor, GTK applications, and the shell.
|
// no configuration system -- the compositor, GTK applications, and the shell.
|
||||||
// Panama's store is the source of truth and services/Accessibility.qml pushes
|
// Panama's store is the source of truth and services/Accessibility.qml pushes
|
||||||
@@ -13,81 +23,232 @@ SettingsPage {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
title: "Accessibility"
|
title: "Accessibility"
|
||||||
lede: "Make the desktop easier to see and easier to hit."
|
lede: "Every switch on this page does something on this desktop — and the ones that cannot yet say why."
|
||||||
|
|
||||||
SettingsCard {
|
// The zoom chords come from the compositor's live keymap, matched on the
|
||||||
title: "Pointer"
|
// descriptions hypr/keybinds.lua gives them, so rebinding a zoom key
|
||||||
subtitle: "Applied to the compositor and to applications at the same time."
|
// changes what this page says instead of quietly making it wrong. The
|
||||||
|
// literals are the shipped chords, standing in only until the keymap has
|
||||||
|
// loaded -- not a second source of truth.
|
||||||
|
function chordFor(needle: string, fallback: string): string {
|
||||||
|
for (const bind of Keybinds.binds) {
|
||||||
|
if (String(bind.description).toLowerCase().indexOf(needle) >= 0)
|
||||||
|
return String(bind.chord);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
SliderRow { setting: "cursorSize" }
|
readonly property string zoomInChord: root.chordFor("zoom in", "Super + Alt + =")
|
||||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
readonly property string zoomOutChord: root.chordFor("zoom out", "Super + Alt + -")
|
||||||
|
readonly property string zoomResetChord: root.chordFor("reset zoom", "Super + Alt + 0")
|
||||||
|
|
||||||
|
// Probed when the page opens rather than polled all session: nothing else
|
||||||
|
// on this desktop needs to know whether Orca is running.
|
||||||
|
Component.onCompleted: Accessibility.refreshScreenReader()
|
||||||
|
|
||||||
|
readonly property string screenReaderDetail: {
|
||||||
|
if (Accessibility.orcaRunning)
|
||||||
|
return "Running — reading the focused application";
|
||||||
|
if (Accessibility.accessibilityBusRunning)
|
||||||
|
return "Not running · the accessibility bus is up, so applications are ready to be read";
|
||||||
|
return "Not running · the accessibility bus is not up, so Orca would start and read nothing";
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Text"
|
title: "Vision"
|
||||||
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
|
subtitle: "Magnification is the compositor's own, so it follows the pointer across every window and every screen."
|
||||||
|
|
||||||
|
SliderRow { setting: "magnifierFactor" }
|
||||||
|
|
||||||
|
// One row per chord rather than three chords crammed into one row's
|
||||||
|
// trailing slot: three keycap chords side by side are wider than the
|
||||||
|
// control column, and squeezing them there costs the label its line.
|
||||||
|
SettingRow {
|
||||||
|
label: "Zoom in"
|
||||||
|
detail: "Works from anywhere — the OSD shows the magnification you land on"
|
||||||
|
controlWidth: 210
|
||||||
|
|
||||||
|
KeycapChord {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
chord: root.zoomInChord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Zoom out"
|
||||||
|
controlWidth: 210
|
||||||
|
|
||||||
|
KeycapChord {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
chord: root.zoomOutChord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Back to 1.00 ×"
|
||||||
|
detail: "Turns the magnifier off without coming back to Settings for it"
|
||||||
|
controlWidth: 210
|
||||||
|
|
||||||
|
KeycapChord {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
chord: root.zoomResetChord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleRow { setting: "magnifierRigid" }
|
||||||
SliderRow { setting: "textScale" }
|
SliderRow { setting: "textScale" }
|
||||||
// Reaches GTK4 applications through the desktop portal, which
|
SliderRow { setting: "cursorSize" }
|
||||||
// republishes it as org.freedesktop.appearance contrast. No
|
ToggleRow { setting: "highContrast" }
|
||||||
// high-contrast theme is involved, and none is installed here -- older
|
|
||||||
// GTK3 applications will not change.
|
// The stored value is the enum; what the compositor wants is a shader
|
||||||
ToggleRow { setting: "highContrast"; divider: false }
|
// path. SystemSettings owns that mapping and applies it live, and
|
||||||
|
// hypr/looks.lua does the same lookup at config time -- so the filter
|
||||||
|
// survives a reload without this page having to reload anything.
|
||||||
|
//
|
||||||
|
// Applied on change rather than on load: the compositor already read
|
||||||
|
// the preference at launch, and re-applying the value it is already
|
||||||
|
// running would be a hyprctl call for nothing every time this page
|
||||||
|
// opens.
|
||||||
|
//
|
||||||
|
// A picker rather than a segmented control: five options with a
|
||||||
|
// sentence under each get ninety pixels apiece in a strip, which is
|
||||||
|
// room for neither.
|
||||||
|
OptionPickerRow {
|
||||||
|
id: colorFilterRow
|
||||||
|
|
||||||
|
property string appliedFilter: ""
|
||||||
|
|
||||||
|
setting: "colorFilter"
|
||||||
|
divider: false
|
||||||
|
|
||||||
|
Component.onCompleted: colorFilterRow.appliedFilter = String(colorFilterRow.current)
|
||||||
|
|
||||||
|
onCurrentChanged: {
|
||||||
|
const next = String(colorFilterRow.current);
|
||||||
|
if (next === colorFilterRow.appliedFilter)
|
||||||
|
return;
|
||||||
|
colorFilterRow.appliedFilter = next;
|
||||||
|
SystemSettings.applyColorFilter(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Motion"
|
title: "Motion"
|
||||||
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
|
subtitle: "Nothing animates while idle. This is the motion you asked for — windows opening, workspaces sliding, panels appearing."
|
||||||
|
|
||||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
// The detail says more than the schema's does because on this page the
|
||||||
|
// claim is the point: the shell's own durations now collapse to zero
|
||||||
|
// when this is off, so the bar, the dock and the panels genuinely stop
|
||||||
|
// moving. Until that landed, this switch reached the compositor and
|
||||||
|
// left the shell animating over the top of it.
|
||||||
|
ToggleRow {
|
||||||
|
setting: "animationsEnabled"
|
||||||
|
detail: "Window, workspace and panel motion — including the shell's own bar, dock and panels, which now stop with everything else"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zoom, done by the compositor rather than handed to GNOME. Hyprland has a
|
|
||||||
// real magnifier (cursor:zoom_factor) that follows the pointer, so there is
|
|
||||||
// no reason to send someone to another application for it.
|
|
||||||
SettingsCard {
|
|
||||||
title: "Magnifier"
|
|
||||||
subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
|
|
||||||
|
|
||||||
SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
|
|
||||||
ToggleRow { setting: "magnifierRigid"; divider: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Contrast"
|
|
||||||
subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read."
|
|
||||||
|
|
||||||
SliderRow { setting: "inactiveOpacity" }
|
|
||||||
ToggleRow { setting: "dimInactive" }
|
ToggleRow { setting: "dimInactive" }
|
||||||
SliderRow { setting: "dimStrength"; divider: false }
|
|
||||||
|
// The amount only means anything while dimming is on, so it goes quiet
|
||||||
|
// rather than disappearing: a row that vanishes takes the explanation
|
||||||
|
// of what the switch above it does with it.
|
||||||
|
SliderRow {
|
||||||
|
setting: "dimStrength"
|
||||||
|
enabled: DesktopPreferences.get("dimInactive") === true
|
||||||
|
opacity: enabled ? 1 : 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "inactiveOpacity"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// What this session genuinely cannot do, said plainly.
|
|
||||||
//
|
|
||||||
// Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is
|
|
||||||
// an X11 SERVER feature. XKB under Wayland has no accessx option group at
|
|
||||||
// all, and Hyprland does not implement one. The compositor will accept
|
|
||||||
// "accessx:enable" as a keyboard option and store it, and nothing will ever
|
|
||||||
// act on it -- so there is no switch here, and pointing at GNOME's panel
|
|
||||||
// would be no better, since the daemon that applies those keys is not
|
|
||||||
// running either.
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Keyboard accessibility"
|
title: "Hearing"
|
||||||
subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on."
|
subtitle: "What the desktop does instead of making a sound."
|
||||||
|
|
||||||
ActionRow {
|
ToggleRow { setting: "visualAlerts"; divider: false }
|
||||||
label: "Screen reader"
|
|
||||||
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
|
Item { width: 1; height: 10 }
|
||||||
action: "Start Orca"
|
|
||||||
onTriggered: SystemSettings.openApplication("orca")
|
// Said the same way the keyboard card says its absence, rather than as
|
||||||
|
// a row wearing a badge that stands for a control it never had.
|
||||||
|
SettingsNote {
|
||||||
|
headline: "Mono audio is not offered yet"
|
||||||
|
body: "Folding stereo into one channel is a real change to the PipeWire graph, and a switch that only looked like it did that would be worse than its absence. Balance lives on the Sound page meanwhile."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Keyboard & pointer"
|
||||||
|
subtitle: "Key behavior lives with the Keyboard settings; what the pointer does when you stop moving it lives here."
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "GNOME accessibility settings"
|
label: "Key repeat"
|
||||||
detail: "For the parts GNOME's own stack still owns"
|
detail: "Delay and speed are on the Keyboard page, with the rest of the keymap"
|
||||||
action: "Open"
|
action: "Open Keyboard"
|
||||||
|
onTriggered: ShellState.openSettings("shortcuts")
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||||
|
|
||||||
|
// The card's rows sit flush against each other; the note is a
|
||||||
|
// separate thing and needs the gap to read as one.
|
||||||
|
Item { width: 1; height: 10 }
|
||||||
|
|
||||||
|
// The receipts, not a warning. See SettingsNote for why this is quiet.
|
||||||
|
SettingsNote {
|
||||||
|
headline: "Sticky, slow and bounce keys are not offered"
|
||||||
|
body: "Hyprland has no such options — asked of the running compositor rather than assumed — and GNOME's switches for them are applied by a daemon this session does not run, so a switch here would be wired to nothing. On Wayland each compositor implements these for itself; if Hyprland grows them, they land on this page."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Screen reader"
|
||||||
|
subtitle: "Orca is a separate application and is reported as one: either its process is running or it is not."
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Orca"
|
||||||
|
detail: root.screenReaderDetail
|
||||||
|
controlWidth: 200
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 9
|
||||||
|
|
||||||
|
StatusBadge {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: Accessibility.orcaRunning ? "Running" : "Stopped"
|
||||||
|
tone: Accessibility.orcaRunning ? Theme.ok : Theme.fgMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: Accessibility.orcaRunning ? "Stop" : "Start"
|
||||||
|
onClicked: {
|
||||||
|
if (Accessibility.orcaRunning)
|
||||||
|
Accessibility.stopOrca();
|
||||||
|
else
|
||||||
|
Accessibility.startOrca();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "This application, read aloud"
|
||||||
|
detail: "Every settings row carries a spoken name and takes keyboard focus — Tab walks the page, Space flips a switch, the arrows move a slider or step a choice"
|
||||||
|
controlWidth: 90
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: SystemSettings.openGnomePanel("universal-access")
|
|
||||||
|
StatusBadge {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Built in"
|
||||||
|
tone: Theme.ok
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ SettingRow {
|
|||||||
|
|
||||||
controlWidth: Math.max(110, button.implicitWidth + 8)
|
controlWidth: Math.max(110, button.implicitWidth + 8)
|
||||||
|
|
||||||
|
function press(): void {
|
||||||
|
if (root.enabled)
|
||||||
|
root.triggered();
|
||||||
|
}
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
id: button
|
id: button
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
@@ -30,5 +35,33 @@ SettingRow {
|
|||||||
text: root.action
|
text: root.action
|
||||||
enabled: root.enabled
|
enabled: root.enabled
|
||||||
onClicked: root.triggered()
|
onClicked: root.triggered()
|
||||||
|
|
||||||
|
activeFocusOnTab: root.enabled
|
||||||
|
|
||||||
|
// The row's label is what the button is FOR; the button's own caption
|
||||||
|
// is what pressing it does. Both are said, in that order.
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: root.label
|
||||||
|
Accessible.description: root.detail === ""
|
||||||
|
? root.action
|
||||||
|
: `${root.detail} — ${root.action}`
|
||||||
|
Accessible.focusable: root.enabled
|
||||||
|
Accessible.focused: button.activeFocus
|
||||||
|
Accessible.onPressAction: root.press()
|
||||||
|
|
||||||
|
Keys.onReturnPressed: root.press()
|
||||||
|
Keys.onEnterPressed: root.press()
|
||||||
|
Keys.onSpacePressed: root.press()
|
||||||
|
|
||||||
|
// Focus ring only -- the button keeps its own border at rest.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 11
|
||||||
|
color: "transparent"
|
||||||
|
visible: button.activeFocus
|
||||||
|
border.width: 2
|
||||||
|
border.color: Theme.accentSecondary
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// Agents.
|
||||||
|
//
|
||||||
|
// Which AI tool answers when the desktop offers to investigate something, what
|
||||||
|
// the desktop is allowed to hand it, and how much of your subscription is left.
|
||||||
|
//
|
||||||
|
// The preferred agent ships as "none" and that is not a placeholder: until one
|
||||||
|
// is chosen, every rung of the escalation ladder stays silent -- a crash
|
||||||
|
// notification carries no action, System Health grows no button, a failed
|
||||||
|
// reload says only that it failed. A desktop that volunteered a tool nobody
|
||||||
|
// installed would be worse than one that says nothing.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
objectName: "agents-page"
|
||||||
|
|
||||||
|
title: "Agents"
|
||||||
|
lede: "Your AI tools, and what the desktop is allowed to hand them."
|
||||||
|
|
||||||
|
// The options come from the schema rather than from a list here, so this
|
||||||
|
// page cannot offer an agent the preference would refuse.
|
||||||
|
readonly property var agentOptions: PreferenceSchema.spec("preferredAgent")?.options ?? []
|
||||||
|
readonly property string preferred: String(DesktopPreferences.get("preferredAgent") ?? "none")
|
||||||
|
|
||||||
|
// ── Install state, or no claim at all ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// A tile says "Not installed" only once something has actually looked. Any
|
||||||
|
// other order gets it wrong in the direction that matters: a page telling
|
||||||
|
// somebody their agent is missing, when it is sitting right there, teaches
|
||||||
|
// them not to believe the page.
|
||||||
|
//
|
||||||
|
// Probed through a LOGIN shell rather than this one. Quickshell is started
|
||||||
|
// by systemd, whose PATH does not include ~/.local/bin -- where both of
|
||||||
|
// these usually land -- and a login shell is the environment the launcher
|
||||||
|
// hands the agent when it spawns a terminal. Asking with the shell's own
|
||||||
|
// PATH would report "not installed" for an agent that starts perfectly.
|
||||||
|
property var installed: ({})
|
||||||
|
property bool probed: false
|
||||||
|
|
||||||
|
function absorbProbe(text: string): void {
|
||||||
|
const found = {};
|
||||||
|
for (const line of String(text).split("\n")) {
|
||||||
|
const name = line.trim();
|
||||||
|
if (name !== "")
|
||||||
|
found[name] = true;
|
||||||
|
}
|
||||||
|
root.installed = found;
|
||||||
|
root.probed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: agentProbe
|
||||||
|
|
||||||
|
running: true
|
||||||
|
command: ["bash", "-lc",
|
||||||
|
"for agent in claude codex; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.absorbProbe(this.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconFor(value: string): string {
|
||||||
|
switch (value) {
|
||||||
|
case "claude": return "starred-symbolic";
|
||||||
|
case "codex": return "utilities-terminal-symbolic";
|
||||||
|
default: return "notifications-disabled-symbolic";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileDetail(value: string): string {
|
||||||
|
if (value === "none")
|
||||||
|
return "Stay quiet";
|
||||||
|
if (!root.probed)
|
||||||
|
return "";
|
||||||
|
return root.installed[value] === true ? "Installed" : "Not installed";
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Preferred agent"
|
||||||
|
subtitle: "Who answers when the desktop offers to investigate something. Until one is chosen, crash notifications carry no action — the desktop stays quiet rather than volunteering a tool you do not use."
|
||||||
|
|
||||||
|
// The same tile shape the power profiles use: three rows with the word
|
||||||
|
// "Active" in one of them is a list you have to read to find out what
|
||||||
|
// is set; three tiles with one lit answers that without reading.
|
||||||
|
Flow {
|
||||||
|
id: tiles
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
spacing: 10
|
||||||
|
bottomPadding: 12
|
||||||
|
|
||||||
|
readonly property int columns: tiles.width >= 460 ? 3 : 1
|
||||||
|
readonly property real tileWidth:
|
||||||
|
(tiles.width - tiles.spacing * (tiles.columns - 1)) / tiles.columns
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.agentOptions
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: tile
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property string value: String(tile.modelData.value)
|
||||||
|
readonly property bool selected: tile.value === root.preferred
|
||||||
|
readonly property string detail: root.tileDetail(tile.value)
|
||||||
|
|
||||||
|
objectName: `agent-tile:${tile.value}`
|
||||||
|
width: tiles.tileWidth
|
||||||
|
implicitHeight: tileBody.implicitHeight + 24
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: tile.selected
|
||||||
|
? Theme.alpha(Theme.accent, 0.09)
|
||||||
|
: Theme.alpha(Theme.fg, tileHover.hovered ? 0.08 : 0.04)
|
||||||
|
border.width: tile.selected || tile.activeFocus ? 2 : 1
|
||||||
|
border.color: tile.activeFocus
|
||||||
|
? Theme.accentSecondary
|
||||||
|
: (tile.selected ? Theme.alpha(Theme.accent, 0.6) : Theme.alpha(Theme.fg, 0.08))
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
Accessible.role: Accessible.RadioButton
|
||||||
|
Accessible.name: String(tile.modelData.label)
|
||||||
|
Accessible.description: tile.detail
|
||||||
|
Accessible.checked: tile.selected
|
||||||
|
|
||||||
|
// An agent this machine cannot start is still selectable:
|
||||||
|
// the probe answers for THIS session's login shell, and
|
||||||
|
// being wrong about that must not lock somebody out of a
|
||||||
|
// choice they are entitled to make. The tile says what it
|
||||||
|
// found; the person decides.
|
||||||
|
function choose(): void {
|
||||||
|
if (!tile.selected)
|
||||||
|
SystemSettings.commitPreference("preferredAgent", tile.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onReturnPressed: tile.choose()
|
||||||
|
Keys.onSpacePressed: tile.choose()
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: tileBody
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
ThemedIcon {
|
||||||
|
icon: root.iconFor(tile.value)
|
||||||
|
iconFallback: "system-run-symbolic"
|
||||||
|
size: 20
|
||||||
|
tint: tile.selected ? Theme.accent : Theme.fgDim
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: String(tile.modelData.label)
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: tile.detail !== ""
|
||||||
|
text: tile.detail
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: tileHover
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: {
|
||||||
|
tile.choose();
|
||||||
|
tile.forceActiveFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "When something breaks"
|
||||||
|
subtitle: "Each of these is a failure that used to be a dead end. Every one still needs an agent chosen above before it offers anything."
|
||||||
|
|
||||||
|
ToggleRow { setting: "crashDiagnoseOffer" }
|
||||||
|
ToggleRow { setting: "reloadFailureOffer" }
|
||||||
|
ToggleRow { setting: "healthAgentHandoff" }
|
||||||
|
ToggleRow { setting: "agentAutoApprove"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Usage in the bar"
|
||||||
|
subtitle: "The bar shows the fullest limit — the one that stops your next prompt. Clicking it opens the whole picture."
|
||||||
|
|
||||||
|
// The same switch the Bar page carries, deliberately: this is the page
|
||||||
|
// somebody is on when they wonder where the number went, and the Bar
|
||||||
|
// page is the page they are on when they are choosing what the bar
|
||||||
|
// contains. Declared as an intentional mirror in
|
||||||
|
// tests/quickshell/settings-ownership-contract.
|
||||||
|
ToggleRow { setting: "showAgentUsage" }
|
||||||
|
ToggleRow { setting: "agentUsageClaude" }
|
||||||
|
ToggleRow { setting: "agentUsageCodex" }
|
||||||
|
SliderRow { setting: "agentUsageRefreshMinutes"; divider: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,16 +19,25 @@ SettingsPage {
|
|||||||
|
|
||||||
property string expandedPicker: ""
|
property string expandedPicker: ""
|
||||||
|
|
||||||
// Which group of cards is on screen. Theme leads deliberately: light and
|
// Whether the accent's HSV fine-tune is open. Collapsed by default: it is
|
||||||
|
// six sliders that answer a question the four color wells above have
|
||||||
|
// usually already answered.
|
||||||
|
property bool fineTuneOpen: false
|
||||||
|
|
||||||
|
// Which group of cards is on screen. Themes leads deliberately: light and
|
||||||
// dark is the control reached most often, and it used to be the third
|
// dark is the control reached most often, and it used to be the third
|
||||||
// section down, below a wallpaper grid and the whole lock screen.
|
// section down, below a wallpaper grid and the whole lock screen.
|
||||||
property string tab: "theme"
|
property string tab: "themes"
|
||||||
|
|
||||||
// Arriving from another page that named a section opens on it. Taken once
|
// Arriving from another page that named a section opens on it. Taken once
|
||||||
// rather than bound, so the tabs still work normally afterwards.
|
// rather than bound, so the tabs still work normally afterwards. The old
|
||||||
|
// `theme` id still resolves: deep links, IPC calls and muscle memory all
|
||||||
|
// hold it, and it means the same thing this tab does.
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
const section = ShellState.takeSettingsSection();
|
const section = ShellState.takeSettingsSection();
|
||||||
if (section !== "")
|
if (section === "theme")
|
||||||
|
root.tab = "themes";
|
||||||
|
else if (section !== "")
|
||||||
root.tab = section;
|
root.tab = section;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +68,11 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsTabs {
|
SettingsTabs {
|
||||||
tabs: [
|
tabs: [
|
||||||
{ value: "theme", label: "Theme" },
|
{ value: "themes", label: "Themes" },
|
||||||
|
{ value: "editor", label: "Theme editor" },
|
||||||
{ value: "background", label: "Background" },
|
{ value: "background", label: "Background" },
|
||||||
{ value: "type", label: "Typography" },
|
{ value: "type", label: "Typography" },
|
||||||
{ value: "windows", label: "Windows" },
|
{ value: "windows", label: "Windows" },
|
||||||
{ value: "shell", label: "Shell" },
|
|
||||||
]
|
]
|
||||||
current: root.tab
|
current: root.tab
|
||||||
onSelected: value => root.tab = value
|
onSelected: value => root.tab = value
|
||||||
@@ -110,6 +119,47 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.tab === "background"
|
||||||
|
title: "Video playback"
|
||||||
|
subtitle: VideoWallpaper.lastError !== ""
|
||||||
|
? VideoWallpaper.lastError
|
||||||
|
: "Videos loop muted and decode on the GPU. Considerate by default — a video wallpaper should never cost you a frame you care about."
|
||||||
|
|
||||||
|
TextEntryRow { setting: "videoWallpaperDir"; placeholder: "Videos/Wallpapers" }
|
||||||
|
|
||||||
|
ToggleRow { setting: "videoWallpaperPauseOnBattery" }
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "During games and fullscreen"
|
||||||
|
detail: "Pauses automatically — not optional, and free"
|
||||||
|
value: "Automatic"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Pause from the bar"
|
||||||
|
detail: "While a video plays, a pill sits in the bar — one click pauses it, for remote desktop or just quiet"
|
||||||
|
value: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Lock screen"
|
||||||
|
detail: "Uses a still frame of the video — hyprlock cannot play motion"
|
||||||
|
value: "Still frame"
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Look for new videos"
|
||||||
|
detail: VideoWallpaper.available
|
||||||
|
? VideoWallpaper.candidates.length + " video" + (VideoWallpaper.candidates.length === 1 ? "" : "s") + " found"
|
||||||
|
: "mpvpaper is not installed — run panama update to pick it up"
|
||||||
|
action: "Rescan"
|
||||||
|
divider: false
|
||||||
|
enabled: VideoWallpaper.available
|
||||||
|
onTriggered: VideoWallpaper.rescan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: root.tab === "background"
|
visible: root.tab === "background"
|
||||||
title: "Lock screen"
|
title: "Lock screen"
|
||||||
@@ -129,45 +179,215 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
|
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
// ── Themes ──────────────────────────────────────────────────────────────
|
||||||
visible: root.tab === "theme"
|
//
|
||||||
// Not "Color scheme" any more: the card holds the saved themes as well
|
// The gallery is the page, not a card on it. Cards would put a heading and
|
||||||
// as the scheme, and a theme carries both ends of the accent with it.
|
// a border around each of two lists that are already visually separate, and
|
||||||
title: "Theme"
|
// the theme cards themselves are the only surfaces here that need edges.
|
||||||
subtitle: ColorScheme.lastError !== ""
|
|
||||||
? ColorScheme.lastError
|
|
||||||
: "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent."
|
|
||||||
|
|
||||||
ThemeProfilePicker {
|
ThemeModeHero {
|
||||||
|
visible: root.tab === "themes"
|
||||||
width: parent.width
|
width: parent.width
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drawn as the gradient each accent produces rather than a flat dot,
|
Text {
|
||||||
// because the gradient is what is being chosen.
|
visible: root.tab === "themes" && ThemeCatalog.lastError !== ""
|
||||||
AccentPicker {
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
text: ThemeCatalog.lastError
|
||||||
|
color: Theme.warn
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeGallery {
|
||||||
|
visible: root.tab === "themes"
|
||||||
|
width: parent.width
|
||||||
|
scheme: "dark"
|
||||||
|
heading: "Dark themes"
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeGallery {
|
||||||
|
visible: root.tab === "themes"
|
||||||
|
width: parent.width
|
||||||
|
scheme: "light"
|
||||||
|
heading: "Light themes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashed rather than solid, and the width of the page: this is a door out
|
||||||
|
// of the gallery, not an eleventh theme in it.
|
||||||
|
Rectangle {
|
||||||
|
id: buildLink
|
||||||
|
|
||||||
|
visible: root.tab === "themes"
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: buildCopy.implicitHeight + 30
|
||||||
|
radius: Theme.cardRadius + 2
|
||||||
|
color: Theme.alpha(Theme.accent, buildHover.hovered ? 0.12 : 0.07)
|
||||||
|
border.width: buildLink.activeFocus ? 2 : 1
|
||||||
|
border.color: buildLink.activeFocus
|
||||||
|
? Theme.accentSecondary : Theme.alpha(Theme.accent, 0.35)
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: "Build your own theme"
|
||||||
|
Accessible.description: "Opens the theme editor"
|
||||||
|
Accessible.focusable: true
|
||||||
|
Accessible.focused: buildLink.activeFocus
|
||||||
|
|
||||||
|
Keys.onReturnPressed: root.tab = "editor"
|
||||||
|
Keys.onSpacePressed: root.tab = "editor"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: buildGlyph
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 17
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "◐"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 20
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: buildCopy
|
||||||
|
|
||||||
|
anchors.left: buildGlyph.right
|
||||||
|
anchors.leftMargin: 13
|
||||||
|
anchors.right: buildArrow.left
|
||||||
|
anchors.rightMargin: 13
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Build your own theme"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Primary, secondary, background, foreground, effects — every color yours, saved as a theme that lives in the galleries above"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: buildArrow
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 17
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "→"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 17
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: buildHover
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: root.tab = "editor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Theme editor ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
id: editorCard
|
||||||
|
|
||||||
|
visible: root.tab === "editor"
|
||||||
|
title: "Your theme"
|
||||||
|
// The identity of what is being edited, always. Failures get their own
|
||||||
|
// line below rather than displacing it -- a subtitle that sometimes
|
||||||
|
// says which theme you are editing and sometimes says something went
|
||||||
|
// wrong answers neither question reliably.
|
||||||
|
subtitle: ThemeProfiles.activeProfile.shipped === true
|
||||||
|
? ThemeProfiles.activeProfile.name
|
||||||
|
+ " — editing it forks a copy you can name below"
|
||||||
|
: "Based on " + ThemeProfiles.activeProfile.name
|
||||||
|
+ " · every change previews live on the real desktop"
|
||||||
|
|
||||||
|
ErrorRow {
|
||||||
|
label: "The theme needs attention"
|
||||||
|
message: wells.lastError !== "" ? wells.lastError : ColorScheme.lastError
|
||||||
|
divider: true
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeStartChips {
|
||||||
|
width: parent.width
|
||||||
|
bottomPadding: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
SegmentRow {
|
||||||
|
label: "Scheme"
|
||||||
|
detail: "Flipping sides returns to the theme you last chose there"
|
||||||
|
options: [{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]
|
||||||
|
value: ThemeProfiles.scheme
|
||||||
|
onSelected: value => ThemeProfiles.setScheme(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { width: 1; height: 13 }
|
||||||
|
|
||||||
|
ThemeEditorWells {
|
||||||
|
id: wells
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { width: 1; height: 13 }
|
||||||
|
|
||||||
|
ThemeSaturationRow {
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Fine-tune"
|
||||||
|
detail: "Hue, saturation and value for each end of the accent"
|
||||||
|
action: root.fineTuneOpen ? "Close" : "Open"
|
||||||
|
onTriggered: root.fineTuneOpen = !root.fineTuneOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
AccentEditor {
|
AccentEditor {
|
||||||
|
visible: root.fineTuneOpen
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeSaveRow {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One selector for every font. The two always-open lists and the
|
||||||
|
// open-one-at-a-time disclosure both retire: a closed dropdown per row
|
||||||
|
// reads as five choices instead of a wall of samples, and each row names
|
||||||
|
// what it actually controls — the old tab never said which font was the
|
||||||
|
// shell's and which was the applications'.
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: root.tab === "type"
|
visible: root.tab === "type"
|
||||||
title: "Shell typography"
|
title: "Fonts"
|
||||||
subtitle: Fonts.lastError !== ""
|
subtitle: {
|
||||||
? Fonts.lastError
|
if (Fonts.lastError !== "")
|
||||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
return Fonts.lastError;
|
||||||
|
if (DesktopStyle.lastError !== "")
|
||||||
|
return DesktopStyle.lastError;
|
||||||
|
return "Each row names what it controls. The menu shows every family in its own face — type to filter.";
|
||||||
|
}
|
||||||
|
|
||||||
SliderRow { setting: "interfaceFontSize" }
|
SettingRow {
|
||||||
|
label: "Interface"
|
||||||
TextRow {
|
|
||||||
label: "Interface font"
|
|
||||||
detail: Fonts.interfaceMissing
|
detail: Fonts.interfaceMissing
|
||||||
? "Not installed on this machine — fontconfig is substituting something else"
|
? "Not installed — fontconfig is substituting something else"
|
||||||
: "Used for all shell text"
|
: "The shell — bar, dock, notifications, this window"
|
||||||
value: Fonts.interfaceFont
|
divider: false
|
||||||
|
controlWidth: 8
|
||||||
}
|
}
|
||||||
|
|
||||||
FontPicker {
|
FontPicker {
|
||||||
@@ -178,12 +398,13 @@ SettingsPage {
|
|||||||
onPicked: family => Fonts.setInterface(family)
|
onPicked: family => Fonts.setInterface(family)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextRow {
|
SettingRow {
|
||||||
label: "Icon font"
|
label: "Icons"
|
||||||
detail: Fonts.iconMissing
|
detail: Fonts.iconMissing
|
||||||
? "Not installed — the shell's glyphs will not draw correctly"
|
? "Not installed — the shell's glyphs will not draw correctly"
|
||||||
: "Draws the shell's glyphs, so only Nerd Fonts are offered"
|
: "Glyphs only, never text — needs a Nerd Font"
|
||||||
value: Fonts.iconFont
|
divider: false
|
||||||
|
controlWidth: 8
|
||||||
}
|
}
|
||||||
|
|
||||||
FontPicker {
|
FontPicker {
|
||||||
@@ -193,77 +414,68 @@ SettingsPage {
|
|||||||
emptyText: "No Nerd Fonts installed"
|
emptyText: "No Nerd Fonts installed"
|
||||||
onPicked: family => Fonts.setIcon(family)
|
onPicked: family => Fonts.setIcon(family)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingRow {
|
||||||
visible: root.tab === "type"
|
label: "Application"
|
||||||
title: "Application typography"
|
detail: "GTK and libadwaita applications that follow the desktop default"
|
||||||
subtitle: DesktopStyle.lastError !== ""
|
divider: false
|
||||||
? DesktopStyle.lastError
|
controlWidth: 8
|
||||||
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
|
|
||||||
|
|
||||||
ActionRow {
|
|
||||||
label: "Application font"
|
|
||||||
detail: DesktopStyle.applicationFont
|
|
||||||
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
|
|
||||||
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FontPicker {
|
FontPicker {
|
||||||
visible: root.expandedPicker === "application-font"
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
families: Fonts.interfaceFonts
|
families: Fonts.interfaceFonts
|
||||||
current: DesktopStyle.applicationFont
|
current: DesktopStyle.applicationFont
|
||||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
|
||||||
onPicked: family => {
|
onPicked: family => DesktopStyle.setApplicationFont(family)
|
||||||
if (DesktopStyle.setApplicationFont(family))
|
|
||||||
root.expandedPicker = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SliderRow { setting: "applicationFontSize" }
|
SettingRow {
|
||||||
|
label: "Document"
|
||||||
ActionRow {
|
detail: "Long-form reading, where applications ask for it"
|
||||||
label: "Document font"
|
divider: false
|
||||||
detail: DesktopStyle.documentFont
|
controlWidth: 8
|
||||||
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
|
|
||||||
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FontPicker {
|
FontPicker {
|
||||||
visible: root.expandedPicker === "document-font"
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
families: Fonts.interfaceFonts
|
families: Fonts.interfaceFonts
|
||||||
current: DesktopStyle.documentFont
|
current: DesktopStyle.documentFont
|
||||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
|
||||||
onPicked: family => {
|
onPicked: family => DesktopStyle.setDocumentFont(family)
|
||||||
if (DesktopStyle.setDocumentFont(family))
|
|
||||||
root.expandedPicker = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SliderRow { setting: "documentFontSize" }
|
SettingRow {
|
||||||
|
label: "Monospace"
|
||||||
ActionRow {
|
detail: "Terminals and code, wherever fixed width is asked for"
|
||||||
label: "Monospace font"
|
divider: false
|
||||||
detail: DesktopStyle.monospaceFont
|
controlWidth: 8
|
||||||
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
|
|
||||||
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FontPicker {
|
FontPicker {
|
||||||
visible: root.expandedPicker === "monospace-font"
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
families: Fonts.monospaceFonts
|
families: Fonts.monospaceFonts
|
||||||
current: DesktopStyle.monospaceFont
|
current: DesktopStyle.monospaceFont
|
||||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
|
||||||
onPicked: family => {
|
onPicked: family => DesktopStyle.setMonospaceFont(family)
|
||||||
if (DesktopStyle.setMonospaceFont(family))
|
|
||||||
root.expandedPicker = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SliderRow { setting: "monospaceFontSize" }
|
SettingsCard {
|
||||||
|
visible: root.tab === "type"
|
||||||
|
title: "Sizes"
|
||||||
|
subtitle: "The interface size moves the shell's whole scale; the rest are per role."
|
||||||
|
|
||||||
|
SliderRow { setting: "interfaceFontSize" }
|
||||||
|
SliderRow { setting: "applicationFontSize" }
|
||||||
|
SliderRow { setting: "documentFontSize" }
|
||||||
|
SliderRow { setting: "monospaceFontSize"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.tab === "type"
|
||||||
|
title: "Rendering"
|
||||||
|
|
||||||
ChoiceRow { setting: "fontHinting" }
|
ChoiceRow { setting: "fontHinting" }
|
||||||
ChoiceRow { setting: "fontAntialiasing"; divider: false }
|
ChoiceRow { setting: "fontAntialiasing"; divider: false }
|
||||||
}
|
}
|
||||||
@@ -322,11 +534,10 @@ SettingsPage {
|
|||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: root.tab === "windows"
|
visible: root.tab === "windows"
|
||||||
title: "Titlebars"
|
title: "Titlebars"
|
||||||
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
subtitle: "One rule for GNOME applications and Panama's own windows. There is no minimize or maximize toggle: Hyprland has no minimize, so the buttons would be lies."
|
||||||
|
|
||||||
ChoiceRow { setting: "titlebarButtonSide" }
|
ToggleRow { setting: "panamaTitlebar" }
|
||||||
ToggleRow { setting: "titlebarMaximizeButton" }
|
ChoiceRow { setting: "titlebarButtonSide"; divider: false }
|
||||||
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -344,10 +555,13 @@ SettingsPage {
|
|||||||
SliderRow { setting: "fullscreenOpacity"; divider: false }
|
SliderRow { setting: "fullscreenOpacity"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effects belong to the theme now: saving snapshots all ten values into the
|
||||||
|
// record, and applying a theme that carries them puts them back. They sit
|
||||||
|
// in the editor rather than beside the galleries for exactly that reason.
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: root.tab === "theme"
|
visible: root.tab === "editor"
|
||||||
title: "Effects"
|
title: "Effects"
|
||||||
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
|
subtitle: "Part of your theme — corners, blur, shadows, glow and motion save with it. Each costs frame time; turning one off is a legitimate way to buy it back while gaming."
|
||||||
|
|
||||||
ToggleRow { setting: "blurEnabled" }
|
ToggleRow { setting: "blurEnabled" }
|
||||||
SliderRow { setting: "blurSize" }
|
SliderRow { setting: "blurSize" }
|
||||||
@@ -361,48 +575,4 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
visible: root.tab === "shell"
|
|
||||||
title: "Clock"
|
|
||||||
|
|
||||||
ToggleRow { setting: "use24Hour" }
|
|
||||||
ToggleRow { setting: "showSeconds" }
|
|
||||||
ToggleRow { setting: "showWeekday"; divider: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
visible: root.tab === "shell"
|
|
||||||
title: "System vitals"
|
|
||||||
subtitle: "Choose what appears beside the workspace indicator."
|
|
||||||
|
|
||||||
ToggleRow { setting: "showCpu" }
|
|
||||||
ToggleRow { setting: "showMemory" }
|
|
||||||
ToggleRow { setting: "showGpu" }
|
|
||||||
// Only where there is a battery to report on. A desktop should not be
|
|
||||||
// offered a switch for a readout it can never show.
|
|
||||||
ToggleRow { setting: "showBattery"; visible: Battery.available }
|
|
||||||
ToggleRow { setting: "showBatteryPercent"; visible: Battery.available && Settings.showBattery }
|
|
||||||
ToggleRow { setting: "showAgentUsage"; divider: true }
|
|
||||||
// Refresh interval was on the Home page, which split one concept across
|
|
||||||
// two pages -- what the vitals show here, how often they update there.
|
|
||||||
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
|
||||||
|
|
||||||
// Only worth asking when there is a choice to make.
|
|
||||||
ChoiceGrid {
|
|
||||||
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
|
|
||||||
width: parent.width
|
|
||||||
label: "Graphics device"
|
|
||||||
detail: GraphicsDevices.selectionMissing
|
|
||||||
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
|
|
||||||
: "Which GPU the graphics readout measures."
|
|
||||||
options: GraphicsDevices.devices.map(device => ({
|
|
||||||
value: device.path,
|
|
||||||
label: GraphicsDevices.shortName(device.name)
|
|
||||||
}))
|
|
||||||
current: GraphicsDevices.selectedPath
|
|
||||||
divider: false
|
|
||||||
onPicked: value => GraphicsDevices.select(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
// One application's own level, and where it plays.
|
||||||
|
//
|
||||||
|
// The destination is part of the row because it is part of the same question:
|
||||||
|
// an application is too loud, or it is coming out of the wrong speakers, and
|
||||||
|
// both are answered here rather than in a mixer somebody else ships. Picking a
|
||||||
|
// device moves every stream the application owns; "System default" hands it
|
||||||
|
// back to following whatever the Output card says.
|
||||||
|
|
||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
@@ -11,11 +19,25 @@ Rectangle {
|
|||||||
|
|
||||||
required property var application
|
required property var application
|
||||||
|
|
||||||
|
// Open only while a destination is being chosen. The row is otherwise one
|
||||||
|
// line tall, because the list of sinks is longer than the mixer usually is.
|
||||||
|
property bool picking: false
|
||||||
|
|
||||||
readonly property real volume: AudioDevices.applicationVolume(root.application)
|
readonly property real volume: AudioDevices.applicationVolume(root.application)
|
||||||
readonly property bool muted: AudioDevices.applicationMuted(root.application)
|
readonly property bool muted: AudioDevices.applicationMuted(root.application)
|
||||||
|
|
||||||
|
// "" means the application follows the default sink rather than naming one.
|
||||||
|
readonly property string sinkName: String(SoundRouting.currentSinkFor(root.application) ?? "")
|
||||||
|
readonly property string sinkLabel: {
|
||||||
|
if (root.sinkName === "")
|
||||||
|
return "System default";
|
||||||
|
const node = AudioDevices.outputs.find(candidate =>
|
||||||
|
String(candidate?.name ?? "") === root.sinkName);
|
||||||
|
return node ? AudioDevices.label(node) : root.sinkName;
|
||||||
|
}
|
||||||
|
|
||||||
width: parent ? parent.width : 620
|
width: parent ? parent.width : 620
|
||||||
implicitHeight: 78
|
implicitHeight: 78 + (root.picking ? sinkMenu.implicitHeight + 6 : 0)
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius
|
||||||
color: Theme.alpha(Theme.fg, 0.025)
|
color: Theme.alpha(Theme.fg, 0.025)
|
||||||
border.width: 1
|
border.width: 1
|
||||||
@@ -37,10 +59,20 @@ Rectangle {
|
|||||||
tint: Theme.fg
|
tint: Theme.fg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: sinkButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||||
|
text: root.sinkLabel + (root.picking ? " ▴" : " ▾")
|
||||||
|
enabled: !SoundRouting.busy
|
||||||
|
onClicked: root.picking = !root.picking
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.left: applicationIcon.right
|
anchors.left: applicationIcon.right
|
||||||
anchors.leftMargin: 10
|
anchors.leftMargin: 10
|
||||||
anchors.right: parent.right
|
anchors.right: sinkButton.left
|
||||||
anchors.rightMargin: 12
|
anchors.rightMargin: 12
|
||||||
anchors.verticalCenter: applicationIcon.verticalCenter
|
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||||
spacing: 1
|
spacing: 1
|
||||||
@@ -69,8 +101,8 @@ Rectangle {
|
|||||||
id: muteButton
|
id: muteButton
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 8
|
anchors.leftMargin: 8
|
||||||
anchors.bottom: parent.bottom
|
anchors.top: parent.top
|
||||||
anchors.bottomMargin: 6
|
anchors.topMargin: 42
|
||||||
size: 30
|
size: 30
|
||||||
iconSize: 17
|
iconSize: 17
|
||||||
icon: root.muted || root.volume <= 0.001
|
icon: root.muted || root.volume <= 0.001
|
||||||
@@ -103,4 +135,85 @@ Rectangle {
|
|||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
horizontalAlignment: Text.AlignRight
|
horizontalAlignment: Text.AlignRight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The destinations: following the default, or any present output by name.
|
||||||
|
Column {
|
||||||
|
id: sinkMenu
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 78
|
||||||
|
visible: root.picking
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: [{
|
||||||
|
name: "",
|
||||||
|
label: "System default"
|
||||||
|
}].concat(AudioDevices.outputs.map(node => ({
|
||||||
|
name: String(node?.name ?? ""),
|
||||||
|
label: AudioDevices.label(node)
|
||||||
|
})))
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: option
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool chosen: String(option.modelData.name) === root.sinkName
|
||||||
|
|
||||||
|
width: sinkMenu.width
|
||||||
|
implicitHeight: 30
|
||||||
|
radius: 8
|
||||||
|
color: optionHover.hovered
|
||||||
|
? Theme.alpha(Theme.fg, 0.07)
|
||||||
|
: "transparent"
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.right: mark.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: String(option.modelData.label)
|
||||||
|
color: option.chosen ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: mark
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: option.chosen
|
||||||
|
text: "✓"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: optionHover
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: {
|
||||||
|
if (option.modelData.name === "")
|
||||||
|
SoundRouting.routeToDefault(root.application);
|
||||||
|
else
|
||||||
|
SoundRouting.moveApplication(root.application, String(option.modelData.name));
|
||||||
|
root.picking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,56 @@
|
|||||||
// Applications and session startup.
|
// Applications: what is installed, what opens your files, and what starts with
|
||||||
|
// your session.
|
||||||
|
//
|
||||||
|
// This page used to answer only the middle question. That made it the one place
|
||||||
|
// in Settings you could not do the obvious thing -- see what is on the machine
|
||||||
|
// and remove something -- so the list came first and everything else arranged
|
||||||
|
// itself around it.
|
||||||
|
//
|
||||||
|
// Two rules the page keeps to:
|
||||||
|
//
|
||||||
|
// It never pretends to own a system package. A Flatpak can be inspected and
|
||||||
|
// removed here because that is a per-user operation with per-user blast
|
||||||
|
// radius. `dnf remove` is not: removing the wrong package takes the desktop
|
||||||
|
// with it, and a settings pane should not put that one press away. The row
|
||||||
|
// says so and prints the command, which is more useful than a button that
|
||||||
|
// refuses.
|
||||||
|
//
|
||||||
|
// It never links somewhere with nothing on it. The "Elsewhere in Settings"
|
||||||
|
// chips appear per application, and only where that application already has a
|
||||||
|
// rule on the page being linked to.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
objectName: "applications"
|
objectName: "applications"
|
||||||
title: "Applications"
|
title: "Applications"
|
||||||
lede: "Choose what opens your files and links, and what starts with your session."
|
lede: "What is installed, what opens your files, and what starts with your session."
|
||||||
|
|
||||||
property string expandedRole: ""
|
// Which installed application is unfolded. One at a time: the body is tall
|
||||||
property bool addingAutostart: false
|
// enough that two open rows would push the third off the screen.
|
||||||
|
property string expandedApp: ""
|
||||||
|
|
||||||
|
// Uninstalling deletes an application, so it never happens on a first
|
||||||
|
// press. Holds the Flatpak id that is one press away from being removed.
|
||||||
|
property string pendingUninstall: ""
|
||||||
|
|
||||||
// Which entry has been asked to be removed. Removal deletes a file, so
|
// Which entry has been asked to be removed. Removal deletes a file, so
|
||||||
// it never happens on a first press.
|
// it never happens on a first press.
|
||||||
property string confirmingAutostartRemoval: ""
|
property string confirmingAutostartRemoval: ""
|
||||||
|
|
||||||
|
property bool addingAutostart: false
|
||||||
|
property bool showingFileTypes: false
|
||||||
|
|
||||||
|
// Empty means "whichever category the catalog lists first", so the card is
|
||||||
|
// never blank before anything has been chosen.
|
||||||
|
property string catalogCategory: ""
|
||||||
|
|
||||||
readonly property var applications: DesktopEntries.applications.values
|
readonly property var applications: DesktopEntries.applications.values
|
||||||
// Each role governs a whole family of types, not one representative: setting
|
// Each role governs a whole family of types, not one representative: setting
|
||||||
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
|
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
|
||||||
@@ -90,149 +124,400 @@ SettingsPage {
|
|||||||
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
|
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
|
||||||
}
|
}
|
||||||
|
|
||||||
TextRow {
|
// ── The installed list ───────────────────────────────────────────────────
|
||||||
visible: DefaultApps.lastError !== ""
|
|
||||||
|
// How many rows the card draws before it stops and says how many are left.
|
||||||
|
// A machine with 243 applications is a scroll, not a list; the search field
|
||||||
|
// is what actually finds one.
|
||||||
|
readonly property int installedLimit: 10
|
||||||
|
|
||||||
|
readonly property var installedApps: (AppLibrary.apps ?? []).filter(
|
||||||
|
app => AppLibrary.matches(app, installedSearch.text))
|
||||||
|
|
||||||
|
readonly property var expandedAppRecord:
|
||||||
|
(AppLibrary.apps ?? []).find(app => String(app.entryId ?? "") === root.expandedApp) ?? null
|
||||||
|
|
||||||
|
// Read for the open row only. The cache is read directly so this binding
|
||||||
|
// has a dependency on it -- that is what turns "Reading…" into the summary
|
||||||
|
// when the answer lands -- and permissionsFor() is what asks for an answer
|
||||||
|
// that is not there yet.
|
||||||
|
readonly property var expandedPermissions: {
|
||||||
|
const cache = AppLibrary.permissionCache;
|
||||||
|
const app = root.expandedAppRecord;
|
||||||
|
if (!app || String(app.kind ?? "") !== "flatpak")
|
||||||
|
return null;
|
||||||
|
const key = String(app.flatpakId ?? "");
|
||||||
|
const info = cache[key] ?? AppLibrary.permissionsFor(key);
|
||||||
|
return info && Array.isArray(info.summary) ? info : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatseal is the tool that edits these; offering it when it is not
|
||||||
|
// installed would be a button that does nothing.
|
||||||
|
readonly property bool flatsealAvailable: root.applications.some(
|
||||||
|
entry => String(entry.id ?? "").replace(/\.desktop$/, "") === "com.github.tchx84.Flatseal")
|
||||||
|
|
||||||
|
function autostartId(app: var): string {
|
||||||
|
const id = String(app?.entryId ?? "");
|
||||||
|
return id.endsWith(".desktop") ? id : id + ".desktop";
|
||||||
|
}
|
||||||
|
|
||||||
|
function autostartEntry(desktopId: string): var {
|
||||||
|
return DefaultApps.autostartEntries.find(entry => String(entry.id) === desktopId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autostartOn(app: var): bool {
|
||||||
|
const entry = root.autostartEntry(root.autostartId(app));
|
||||||
|
return entry !== null && entry.enabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turning it on for an application that has never had an entry writes one;
|
||||||
|
// there is no separate "add" step from here, because from the row's point
|
||||||
|
// of view there is one switch and it means one thing.
|
||||||
|
function setAppAutostart(app: var, value: bool): void {
|
||||||
|
const desktopId = root.autostartId(app);
|
||||||
|
if (root.autostartEntry(desktopId) === null) {
|
||||||
|
if (value)
|
||||||
|
DefaultApps.addAutostart(desktopId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DefaultApps.setAutostart(desktopId, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Where else this application already has a rule ───────────────────────
|
||||||
|
|
||||||
|
function appIdentifiers(app: var): var {
|
||||||
|
const out = [];
|
||||||
|
const entryId = String(app?.entryId ?? "").replace(/\.desktop$/, "");
|
||||||
|
const flatpakId = String(app?.flatpakId ?? "");
|
||||||
|
const name = String(app?.name ?? "");
|
||||||
|
for (const value of [entryId, flatpakId, name]) {
|
||||||
|
if (value !== "")
|
||||||
|
out.push(value.toLowerCase());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNotificationRule(app: var): bool {
|
||||||
|
const identifiers = root.appIdentifiers(app);
|
||||||
|
return (Notifs.applications ?? []).some(
|
||||||
|
known => identifiers.indexOf(String(known.id ?? "").toLowerCase()) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stream, not a stored rule: the Sound page's per-application rows come
|
||||||
|
// from what is playing right now, so the chip is honest only while there is
|
||||||
|
// something there to change.
|
||||||
|
function hasSoundRule(app: var): bool {
|
||||||
|
const identifiers = root.appIdentifiers(app);
|
||||||
|
return (AudioDevices.applications ?? []).some(stream =>
|
||||||
|
identifiers.indexOf(String(stream.key ?? "").toLowerCase()) >= 0
|
||||||
|
|| identifiers.indexOf(String(stream.label ?? "").toLowerCase()) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The portal's permission store records answers under the application's own
|
||||||
|
// id, which is the same id this list carries -- so the match is exact and
|
||||||
|
// costs nothing.
|
||||||
|
function hasPrivacyRule(app: var): bool {
|
||||||
|
const identifiers = root.appIdentifiers(app);
|
||||||
|
return (Permissions.devices ?? []).some(device =>
|
||||||
|
(device.applications ?? []).some(
|
||||||
|
recorded => identifiers.indexOf(String(recorded.app ?? "").toLowerCase()) >= 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The catalog ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
readonly property var catalogCategories: AppLibrary.categories ?? []
|
||||||
|
|
||||||
|
// A category arrives either as a plain name or as an object carrying its
|
||||||
|
// entries with it, so both are read rather than one being assumed.
|
||||||
|
function categoryId(category: var): string {
|
||||||
|
return String(category?.id ?? category?.name ?? category ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryLabel(category: var): string {
|
||||||
|
const label = String(category?.label ?? category?.name ?? category?.id ?? category ?? "");
|
||||||
|
const count = Number(category?.count ?? (category?.entries ?? []).length ?? 0);
|
||||||
|
return count > 0 ? label + " · " + count : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property string activeCategory: {
|
||||||
|
if (root.catalogCategory !== "")
|
||||||
|
return root.catalogCategory;
|
||||||
|
const categories = root.catalogCategories;
|
||||||
|
return categories.length > 0 ? root.categoryId(categories[0]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var catalogEntries: {
|
||||||
|
// Reading the category list is what makes this re-run once the catalog
|
||||||
|
// has been parsed; entriesFor() alone is a function call and would not.
|
||||||
|
const categories = root.catalogCategories;
|
||||||
|
if (categories.length === 0 || root.activeCategory === "")
|
||||||
|
return [];
|
||||||
|
return AppLibrary.entriesFor(root.activeCategory) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The launcher's own chord, read from the keymap ───────────────────────
|
||||||
|
|
||||||
|
// Hardcoding "Super+Space" survived two rebinds of the launcher and told
|
||||||
|
// the wrong story both times. The keymap is already loaded; this reads it.
|
||||||
|
readonly property var launcherChords: (Keybinds.binds ?? [])
|
||||||
|
.filter(bind => String(bind.description ?? "") === "Launcher")
|
||||||
|
.map(bind => String(bind.chord ?? ""))
|
||||||
|
.filter(chord => chord !== "")
|
||||||
|
|
||||||
|
// AppLibrary loads nothing on its own: `flatpak list` and the catalog cost
|
||||||
|
// a process each and only this page wants them, so a shell start where
|
||||||
|
// nobody opens Applications pays for neither.
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (!AppLibrary.flatpaksLoaded)
|
||||||
|
AppLibrary.refresh();
|
||||||
|
if (!AppLibrary.catalogLoaded)
|
||||||
|
AppLibrary.refreshCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorRow {
|
||||||
label: "Application settings need attention"
|
label: "Application settings need attention"
|
||||||
detail: DefaultApps.lastError
|
message: DefaultApps.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorRow {
|
||||||
|
label: "The application list needs attention"
|
||||||
|
message: String(AppLibrary.lastError ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What is installed ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Installed applications"
|
||||||
|
subtitle: !AppLibrary.flatpaksLoaded
|
||||||
|
? "Reading the applications installed on this machine…"
|
||||||
|
: (AppLibrary.apps ?? []).length + " installed · "
|
||||||
|
+ AppLibrary.flatpakCount + " through Flatpak"
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: installedSearch
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
placeholder: "Search installed applications"
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { width: 1; height: 8 }
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.installedApps.slice(0, root.installedLimit)
|
||||||
|
|
||||||
|
delegate: InstalledAppRow {
|
||||||
|
id: installedRow
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool open: root.expandedApp === String(installedRow.modelData.entryId ?? "")
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
app: installedRow.modelData
|
||||||
|
expanded: installedRow.open
|
||||||
|
// A write in flight, not a read: the catalog refresh this page
|
||||||
|
// starts on open must not grey out every row for a second.
|
||||||
|
busy: AppLibrary.busyEntryId !== "" || DefaultApps.busy
|
||||||
|
divider: installedRow.index
|
||||||
|
< Math.min(root.installedApps.length, root.installedLimit) - 1
|
||||||
|
autostart: root.autostartOn(installedRow.modelData)
|
||||||
|
flatsealAvailable: root.flatsealAvailable
|
||||||
|
permissionsKnown: installedRow.open && root.expandedPermissions !== null
|
||||||
|
permissionSummary: installedRow.open && root.expandedPermissions
|
||||||
|
? root.expandedPermissions.summary
|
||||||
|
: []
|
||||||
|
hasNotificationRule: root.hasNotificationRule(installedRow.modelData)
|
||||||
|
hasSoundRule: root.hasSoundRule(installedRow.modelData)
|
||||||
|
hasPrivacyRule: root.hasPrivacyRule(installedRow.modelData)
|
||||||
|
confirmingUninstall: root.pendingUninstall !== ""
|
||||||
|
&& root.pendingUninstall === String(installedRow.modelData.flatpakId ?? "")
|
||||||
|
|
||||||
|
onActivated: {
|
||||||
|
root.pendingUninstall = "";
|
||||||
|
root.expandedApp = installedRow.open
|
||||||
|
? ""
|
||||||
|
: String(installedRow.modelData.entryId ?? "");
|
||||||
|
}
|
||||||
|
onAutostartToggled: value => root.setAppAutostart(installedRow.modelData, value)
|
||||||
|
onJumped: page => ShellState.openSettings(page)
|
||||||
|
onFlatsealRequested: Quickshell.execDetached(
|
||||||
|
["flatpak", "run", "com.github.tchx84.Flatseal"])
|
||||||
|
onUninstallArmed: root.pendingUninstall = String(installedRow.modelData.flatpakId ?? "")
|
||||||
|
onUninstallCancelled: root.pendingUninstall = ""
|
||||||
|
onUninstallConfirmed: {
|
||||||
|
const flatpakId = String(installedRow.modelData.flatpakId ?? "");
|
||||||
|
root.pendingUninstall = "";
|
||||||
|
root.expandedApp = "";
|
||||||
|
AppLibrary.uninstall(flatpakId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: root.installedApps.length > root.installedLimit
|
||||||
|
label: (root.installedApps.length - root.installedLimit) + " more"
|
||||||
|
detail: "Search finds any of them by name or by application id"
|
||||||
value: ""
|
value: ""
|
||||||
divider: false
|
divider: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: (AppLibrary.apps ?? []).length > 0 && root.installedApps.length === 0
|
||||||
|
label: "Nothing matches that"
|
||||||
|
detail: "Both the application's name and its id are searched"
|
||||||
|
value: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What could be installed ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Browse the catalog"
|
||||||
|
subtitle: "Panama's curated applications — the same list `panama apps` offers, installed the same way."
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.catalogCategories
|
||||||
|
|
||||||
|
delegate: SettingsChip {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
text: root.categoryLabel(modelData)
|
||||||
|
active: root.categoryId(modelData) === root.activeCategory
|
||||||
|
onClicked: root.catalogCategory = root.categoryId(modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { width: 1; height: 10 }
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.catalogEntries
|
||||||
|
|
||||||
|
delegate: SettingRow {
|
||||||
|
id: catalogRow
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool installed: catalogRow.modelData.installed === true
|
||||||
|
readonly property bool flatpak: String(catalogRow.modelData.kind ?? "") === "flatpak"
|
||||||
|
// `id` is the catalog line verbatim, "flatpak:" prefix and all,
|
||||||
|
// because that is what install matches. `ref` is the same thing
|
||||||
|
// with the prefix taken off, which is what a person reads.
|
||||||
|
readonly property string ref: String(catalogRow.modelData.ref ?? catalogRow.modelData.id ?? "")
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
label: String(catalogRow.modelData.label ?? "") !== ""
|
||||||
|
? String(catalogRow.modelData.label)
|
||||||
|
: catalogRow.ref
|
||||||
|
detail: catalogRow.flatpak
|
||||||
|
? catalogRow.ref + " · Flatpak, from Flathub"
|
||||||
|
: catalogRow.ref + " · system package, so installing asks for your password"
|
||||||
|
value: catalogRow.installed ? "Installed" : ""
|
||||||
|
controlWidth: 110
|
||||||
|
divider: catalogRow.index < root.catalogEntries.length - 1
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: !catalogRow.installed
|
||||||
|
// Only the row being installed says so, rather than the
|
||||||
|
// whole card greying out around one press.
|
||||||
|
text: AppLibrary.busyEntryId === String(catalogRow.modelData.id ?? "")
|
||||||
|
? "Installing…"
|
||||||
|
: "Install"
|
||||||
|
tone: "accent"
|
||||||
|
enabled: !AppLibrary.busy
|
||||||
|
onClicked: AppLibrary.install(root.activeCategory,
|
||||||
|
String(catalogRow.modelData.id ?? ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: root.catalogCategories.length === 0
|
||||||
|
label: AppLibrary.catalogLoaded
|
||||||
|
? "The catalog is empty"
|
||||||
|
: "Reading the catalog…"
|
||||||
|
detail: "It is parsed from setup/packages/extras, the same files the command line uses"
|
||||||
|
value: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What opens what ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Default applications"
|
title: "Default applications"
|
||||||
subtitle: "Open a row to choose from applications that advertise the matching role."
|
subtitle: "Each role sets its whole family of types together, so one picture never opens somewhere different from the next."
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.roles
|
model: root.roles
|
||||||
|
|
||||||
delegate: Column {
|
delegate: OptionPickerRow {
|
||||||
id: roleBlock
|
|
||||||
|
|
||||||
required property var modelData
|
|
||||||
required property int index
|
|
||||||
readonly property var choices: root.choicesForRole(roleBlock.modelData)
|
|
||||||
readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key)
|
|
||||||
|
|
||||||
width: parent.width
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
id: roleRow
|
id: roleRow
|
||||||
|
|
||||||
readonly property bool open: root.expandedRole === roleBlock.modelData.key
|
|
||||||
|
|
||||||
label: roleBlock.modelData.label
|
|
||||||
detail: roleBlock.modelData.detail
|
|
||||||
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
|
|
||||||
controlWidth: 210
|
|
||||||
|
|
||||||
// Drawn rather than left to SettingRow's plain value text, so
|
|
||||||
// the row carries the same chevron a PickerRow does. These
|
|
||||||
// open a chooser but looked completely inert without it.
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 9
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: DefaultApps.busy ? "Loading…" : (
|
|
||||||
roleBlock.selectedEntry
|
|
||||||
? root.displayName(roleBlock.selectedEntry)
|
|
||||||
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
|
|
||||||
)
|
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSize
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
visible: roleBlock.choices.length > 0
|
|
||||||
text: roleRow.open ? "\u25B4" : "\u25BE"
|
|
||||||
color: Theme.fgMuted
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
|
|
||||||
onActivated: {
|
|
||||||
root.expandedRole = root.expandedRole === roleBlock.modelData.key
|
|
||||||
? ""
|
|
||||||
: roleBlock.modelData.key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
width: parent.width
|
|
||||||
visible: root.expandedRole === roleBlock.modelData.key
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: roleBlock.choices
|
|
||||||
|
|
||||||
delegate: SettingRow {
|
|
||||||
id: candidateRow
|
|
||||||
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
readonly property string candidateId: root.desktopId(candidateRow.modelData)
|
readonly property var choices: root.choicesForRole(roleRow.modelData)
|
||||||
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
|
|
||||||
|
|
||||||
label: root.displayName(candidateRow.modelData)
|
width: parent.width
|
||||||
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
|
label: roleRow.modelData.label
|
||||||
value: candidateRow.selected ? "Current" : ""
|
detail: roleRow.modelData.detail
|
||||||
activatable: !candidateRow.selected && !DefaultApps.busy
|
options: roleRow.choices.map(entry => ({
|
||||||
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
|
value: root.desktopId(entry),
|
||||||
onActivated: {
|
label: root.displayName(entry),
|
||||||
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
|
detail: String(entry.genericName || entry.comment || root.desktopId(entry))
|
||||||
root.expandedRole = "";
|
}))
|
||||||
}
|
current: root.currentHandler(roleRow.modelData.key)
|
||||||
}
|
enabled: roleRow.choices.length > 0 && !DefaultApps.busy
|
||||||
}
|
value: DefaultApps.busy ? "Loading…" : (
|
||||||
}
|
roleRow.currentOption
|
||||||
}
|
? String(roleRow.currentOption.label)
|
||||||
|
: (root.currentHandler(roleRow.modelData.key) || "Not set")
|
||||||
|
)
|
||||||
|
onPicked: value => DefaultApps.setDefault(roleRow.modelData.key, String(value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GNOME's Search panel, answered honestly.
|
// The escape hatch from the paragraph above: one type, on its own.
|
||||||
//
|
|
||||||
// It configures which applications provide results in gnome-shell's
|
|
||||||
// overview and which folders are indexed. gnome-shell does not run here,
|
|
||||||
// so those settings would do nothing. Under Panama searching is the
|
|
||||||
// launcher's job, and Vicinae carries its own preferences -- reimplementing
|
|
||||||
// them here would give two places to change one thing.
|
|
||||||
SettingsCard {
|
|
||||||
title: "Search"
|
|
||||||
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
|
|
||||||
|
|
||||||
TextRow {
|
|
||||||
label: "Launcher"
|
|
||||||
detail: SystemSettings.vicinaeActive
|
|
||||||
? "Running as a user service"
|
|
||||||
: "Not running — Super+Shift+R opens the fallback launcher"
|
|
||||||
value: "Vicinae"
|
|
||||||
}
|
|
||||||
|
|
||||||
TextRow {
|
|
||||||
label: "Open search"
|
|
||||||
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
|
|
||||||
value: "Super+Space"
|
|
||||||
}
|
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Change these shortcuts"
|
label: "One file type"
|
||||||
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
|
detail: root.showingFileTypes
|
||||||
action: "Open keyboard"
|
? "Search the type database and give a single type its own application"
|
||||||
divider: false
|
: "Override a single type when a role's family is too broad"
|
||||||
onTriggered: ShellState.openSettings("shortcuts")
|
action: root.showingFileTypes ? "Close" : "Choose"
|
||||||
|
divider: root.showingFileTypes
|
||||||
|
onTriggered: {
|
||||||
|
root.showingFileTypes = !root.showingFileTypes;
|
||||||
|
if (!root.showingFileTypes)
|
||||||
|
DefaultApps.clearTypeSearch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileTypePicker {
|
||||||
|
visible: root.showingFileTypes
|
||||||
|
width: parent.width
|
||||||
|
matches: DefaultApps.typeMatches ?? []
|
||||||
|
busy: DefaultApps.searchingTypes
|
||||||
|
truncated: DefaultApps.typeSearchTruncated
|
||||||
|
onQueried: query => DefaultApps.searchTypes(query)
|
||||||
|
onChosen: (mime, desktopId) => DefaultApps.setType(mime, desktopId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What starts with the session ─────────────────────────────────────────
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "User autostart"
|
title: "Autostart"
|
||||||
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
|
subtitle: "Entries live in your user configuration. The switch in an application's own row above writes the same files."
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Add an application"
|
label: "Add an application"
|
||||||
@@ -258,9 +543,8 @@ SettingsPage {
|
|||||||
TextRow {
|
TextRow {
|
||||||
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
||||||
label: "No user autostart entries"
|
label: "No user autostart entries"
|
||||||
detail: "Applications can add entries to ~/.config/autostart."
|
detail: "Applications can add entries to ~/.config/autostart"
|
||||||
value: ""
|
value: ""
|
||||||
divider: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
@@ -279,7 +563,6 @@ SettingsPage {
|
|||||||
detail: autostartRow.confirming
|
detail: autostartRow.confirming
|
||||||
? "Removing deletes this entry. Turning it off instead is reversible."
|
? "Removing deletes this entry. Turning it off instead is reversible."
|
||||||
: autostartRow.modelData.id
|
: autostartRow.modelData.id
|
||||||
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
|
|
||||||
controlWidth: 210
|
controlWidth: 210
|
||||||
|
|
||||||
// A switch, not the words "Enabled"/"Disabled". The row always
|
// A switch, not the words "Enabled"/"Disabled". The row always
|
||||||
@@ -318,16 +601,24 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
// The compositor's own autostart, described rather than offered. These
|
||||||
title: "Compositor autostart"
|
// are started from config/dot/hypr/autostart.lua, which is where they
|
||||||
subtitle: "These are started from the Hyprland configuration. They are read-only here."
|
// are changed -- a switch here would edit a file the compositor reads
|
||||||
|
// once at launch and give no sign that nothing had happened.
|
||||||
|
TextRow {
|
||||||
|
label: "Compositor autostart"
|
||||||
|
detail: "Started from the Hyprland configuration, and read-only here"
|
||||||
|
value: DefaultApps.luaAutostartEntries.length > 0
|
||||||
|
? DefaultApps.luaAutostartEntries.length
|
||||||
|
+ (DefaultApps.luaAutostartEntries.length === 1 ? " entry" : " entries")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
||||||
label: "No compositor entries found"
|
label: "No compositor entries found"
|
||||||
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua."
|
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua"
|
||||||
value: ""
|
value: ""
|
||||||
divider: false
|
divider: false
|
||||||
}
|
}
|
||||||
@@ -349,16 +640,63 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Searching from the launcher ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// GNOME's Search panel, answered honestly.
|
||||||
|
//
|
||||||
|
// It configures which applications provide results in gnome-shell's
|
||||||
|
// overview and which folders are indexed. gnome-shell does not run here,
|
||||||
|
// so those settings would do nothing. Under Panama searching is the
|
||||||
|
// launcher's job, and Vicinae carries its own preferences -- reimplementing
|
||||||
|
// them here would give two places to change one thing.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Search"
|
||||||
|
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
|
||||||
|
|
||||||
|
TextEntryRow { setting: "webSearchUrl"; placeholder: "https://duckduckgo.com/?q=" }
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Launcher"
|
||||||
|
detail: SystemSettings.vicinaeActive
|
||||||
|
? "Running as a user service"
|
||||||
|
: "Not running — Super+Shift+R opens the fallback launcher"
|
||||||
|
value: "Vicinae"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Open search"
|
||||||
|
detail: root.launcherChords.length > 1
|
||||||
|
? root.launcherChords.join(", ") + " all open it, because Super+A and Super+R"
|
||||||
|
+ " were GNOME's app grid and run dialog"
|
||||||
|
: "Rebindable from the keyboard page, like every other launcher chord"
|
||||||
|
// The keymap is the source of truth; the literal is what shows
|
||||||
|
// while it is still being read.
|
||||||
|
value: root.launcherChords.length > 0 ? root.launcherChords[0] : "Super + Space"
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Change these shortcuts"
|
||||||
|
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
|
||||||
|
action: "Open keyboard"
|
||||||
|
divider: false
|
||||||
|
onTriggered: ShellState.openSettings("shortcuts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Refresh"
|
title: "Refresh"
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Reload application settings"
|
label: "Reload application settings"
|
||||||
detail: "Re-read desktop entries, defaults, and user autostart files"
|
detail: "Re-read desktop entries, defaults, user autostart files, and the installed list"
|
||||||
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
|
action: DefaultApps.busy || AppLibrary.busy ? "Refreshing…" : "Refresh"
|
||||||
enabled: !DefaultApps.busy
|
enabled: !DefaultApps.busy && !AppLibrary.busy
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: DefaultApps.refresh()
|
onTriggered: {
|
||||||
|
DefaultApps.refresh();
|
||||||
|
AppLibrary.refresh();
|
||||||
|
AppLibrary.refreshCatalog();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ SettingRow {
|
|||||||
detail: "Adjust the left and right channels"
|
detail: "Adjust the left and right channels"
|
||||||
controlWidth: 270
|
controlWidth: 270
|
||||||
visible: root.available
|
visible: root.available
|
||||||
divider: false
|
|
||||||
|
|
||||||
PwObjectTracker {
|
PwObjectTracker {
|
||||||
objects: root.node ? [root.node] : []
|
objects: root.node ? [root.node] : []
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user