99 Commits
Author SHA1 Message Date
Gabriel Brown 933c197060 Capture ticket proof with Playwright instead of the browser extension 2026-09-22 15:18:38 -04:00
Gabriel Brown de3591a25b Add capture-proof for sharp Playwright proof screenshots 2026-09-22 15:18:38 -04:00
Gabriel Brown 88032490c0 Drive the ticket skill through the live ticket page 2026-09-22 14:54:05 -04:00
Gabriel Brown fd24b69039 Add the live ticket page and epic index renderer 2026-09-22 14:54:05 -04:00
Gabriel Brown dfc009e422 Add the ticket page data model and its ticket-page CLI 2026-09-22 14:54:05 -04:00
Gabriel Brown 1843c0495a Fix: Teach the update contract about the link-mcp stage 2026-09-22 14:04:32 -04:00
Gabriel Brown 43ae5bb53b Test: Fail the provenance contract when a case writes into the real nvm tree 2026-09-22 14:04:32 -04:00
Gabriel Brown 05054afdc9 Fix: Leave logind alone while a desktop session is running 2026-09-22 14:04:32 -04:00
Gabriel Brown 9022847971 Add a plan.html review page to the ticket skill 2026-09-22 12:54:32 -04:00
Gabriel Brown 9b06e9e24b Docs: Add a spelling rule to unslop
American spelling is the default and British forms turn up in generated
prose. Catch the common endings and the irregulars, with two exceptions:
project vocabulary that the client's documents, ticket titles and codebase
all spell the British way, such as "enrolment" on APSCA, and existing code
identifiers, which are never renamed to fix spelling.

The rules after it shift by one.
2026-09-18 17:28:56 -04:00
Gabriel Brown 59da2bb2fc Docs: Scope the Ksense sections to the Ksense folder
The merge request conventions and the coworker list only apply to Ksense
work, but they load in every project including personal ones. Name the
folder that work lives in so an agent reads those sections as conditional
rather than as always in force.
2026-09-18 17:28:56 -04:00
Gabriel Brown 5ebecd59bb Register Atlassian's MCP server
Jira and Confluence reached Claude Code through two claude.ai connectors,
which are configured per account rather than per machine and had been added
twice. Atlassian hosts its own MCP server, so it belongs in this list with
the others and a new machine picks it up from the installer.

It carries no token. The server runs its own OAuth and Claude Code signs in
through /mcp on first use.
2026-09-18 17:28:56 -04:00
Gabriel Brown 501f7306ed Point every ssh host at the desktop keyring agent
Claude Code and Codex start their shells without SSH_AUTH_SOCK. id_ed25519
has a passphrase, so batch ssh from a harness had no way to unlock it and
failed with "Permission denied (publickey)" against server.gib, junior.gib
and git.gbrown.org. The key and the per-host blocks were right the whole
time, which made it read as a key problem and cost several rounds of
checking keys that were never wrong.

The desktop keyring agent at $XDG_RUNTIME_DIR/keyring/ssh already holds the
key unlocked for the interactive session. A Host * block with IdentityAgent
points every host at that socket, so a shell that inherits no agent gets the
same one the terminal uses.
2026-09-18 17:28:56 -04:00
gibandClaude Opus 5 130137485e Guard the mesa driver swap on its end state
mesa-va-drivers-freeworld Provides mesa-va-drivers, so once the swap has run
once it becomes the only thing providing the name we ask dnf to remove. The
command then resolves to removing and installing the same package in a single
transaction and exits with "conflicting requests", which reported
install-packages as a failed stage on every run after the first even though
the machine was already in the desired state.

The failure also produced misleading output, because dnf explored removing
mesa-dri-drivers and surfaced unrelated complaints from steam, lutris and
tigervnc-server-common.

Check for the freeworld package with rpm -q and skip the swap when it is
already installed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01TFG55mJBiH2tV38tXcNzJ9
2026-09-18 11:02:35 -04:00
Gabriel Brown 5ebebc8aa2 Docs: Treat the pre-MR audit verdict as the gate and converge reruns with context 2026-09-17 11:43:07 -04:00
Gabriel Brown 85160ffef2 Stop writing text into the Working Feature Proof field
Dragging attachments into customfield_10142 replaces whatever text is
sitting there, so the list the skill wrote was destroyed by the upload it
was describing. Naming each artifact is already the job of the Test Cases
proof cells, which reference it by filename, and step 10 tells me which
files to upload.

Only the Story rule changes. On a Bug that field carries the test-case
table rather than prose.
2026-09-14 14:36:54 -04:00
Gabriel Brown 59192143c5 Docs: Add the prose bar and record Josh as the APSCA lead
Anything a person other than me reads now gets an unslop pattern pass
before it ships: MR descriptions, ticket fields, deliverables. The rules
that matter most are the ones a generic pass misses. Write as me in first
person, never name me or use a stand-in, and never flag one of my own
decisions as a deviation, because that reads as my own AI tattling on me
and costs the PM time investigating a non-issue.

Punctuation is stricter than unslop's own pattern: no em dashes, en
dashes, semicolons or arrow glyphs. Generated output is never rewritten,
only regenerated.

Also records Josh as the tech lead on apsca_next, whose dev reviews carry
draft code and schema and are a starting point rather than a finished spec.
2026-09-14 14:36:52 -04:00
Gabriel Brown 2f2e3b88e8 Register MCP servers through the installer
An MCP server is a URL plus a bearer token, and the token is why this is a
stage rather than a manifest line. Panama is public, so the tokens cannot
live in it, and neither runtime keeps its server list in a file worth
symlinking: Codex writes them into config.toml beside dozens of unrelated
settings, and Claude Code into ~/.claude.json. link-mcp registers them
through the runtime's own CLI instead.

user/agents/mcp/servers is tracked and names which variable carries each
token. user/agents/mcp/env holds the tokens and is ignored. A new machine
gets the servers by dropping its own env file beside the tracked one.

Only Claude Code is handled, and only where the interview enabled personal
content. Rewriting a section of somebody's live Codex TOML is a worse
failure mode than leaving two lines to paste once.
2026-09-14 14:36:44 -04:00
Gabriel Brown 9156a7e05c Fix: Scan for DDC displays when quick settings opens
The scan was keyed off this item becoming visible, but on a machine with no
backlight `visible` stays false until a scan has already found a DDC
display, so it never scanned at all. It now keys off the quick settings
panel opening, which still keeps the second-long I2C probe off shell
startup.
2026-09-14 14:36:39 -04:00
Gabriel Brown 39a2e8e9ca Fix: Stop the display link retraining and blanking
This panel's DP link is marginal. 4500x3000@60 at 10bpc is around 24 Gbps,
right at the edge of DP 1.4 HBR3 and reliant on DSC, so every modeset
retrains the link and blanks the screen. 8bpc keeps headroom.

Steam games are the other trigger. Everything Hyprland does only for a real
fullscreen window (direct scanout, VRR, tearing, auto HDR) forces that
retrain, so steam_app windows get fullscreen_state "1 2": maximized
internally while the game believes it is fullscreen, which is what
borderless windowed looks like from the game's side.

Recorded alongside the related Panama settings already at 0,
directScanoutPolicy and vrrPolicy.
2026-09-14 14:36:35 -04:00
Gabriel Brown 6206565d95 Fix: Keep the screenshot picker out of its own capture
The picker paints an opaque frozen frame edge to edge, so the blur and dim
it inherited from the shared qs-overlay rule were compositor work on pixels
nobody sees. It now has its own layer rule.

no_anim is the load-bearing part. Capture.qml unmaps the picker and runs
grim 90ms later, and the default 200ms layersOut fade put the half-faded
picker, dimming and all, into every screenshot.
2026-09-14 14:36:33 -04:00
Gabriel Brown cceb7a707b Fix: Never pair exclusionMode with exclusiveZone
Quickshell's exclusiveZone setter flips exclusionMode back to Normal as a
side effect, so a window declaring both is at the mercy of which property
the QML engine applies last. The 2026-09-14 Qt update changed that order
and every full-screen overlay slid under the bar.

Each overlay now declares ExclusionMode.Ignore alone. The capture picker
was the visible failure: it started under the bar, the full-output freeze
frame was squeezed into a shorter box, and every selection landed one
bar-height off in the real capture.

tests/quickshell/exclusion-idiom-contract fails any file that pairs them.
2026-09-14 14:36:24 -04:00
Gabriel Brown c1bbc69c8a Fix: Pass an explicit value to eza --icons in the ls and lt aliases
eza 0.23.5 gives --icons an optional WHEN value, so a trailing --icons
swallowed the path argument and `ls docs` failed with "invalid value
'docs' for '--icons'". Using --icons=auto makes both aliases independent
of flag order.

Claude-Session: https://claude.ai/code/session_017trjCxkXTdv7Z5ePfGWYtW
2026-09-07 17:16:17 -04:00
Gabriel Brown fa8b14e05e Fix: Adopt a Terra the machine already trusts
The repository audit made any Terra that is not Panama's own pinned form a
trust-root failure, and status 78 then stopped every stage before it ran. A
machine that installed Terra the way Terra documents it -- terra-release's own
repo file, a metalink, the key at its stock path -- was classified hostile and
had no way back, because install_terra_repository refused to touch a machine
terra-release had already reached. A gate with no door.

The trust root is the signing key, and that key is byte-for-byte the
fingerprint this repository reviewed and pinned, with every signature check
already on. So verify the fingerprint and adopt the configuration into the
pinned form instead of refusing it. Adoption needs no network and no DNF, it
runs before any other transaction in the stage, and it is repeatable, which it
has to be: terra-release owns that file and restores it on update.

Adoption stays narrow. The pinned fingerprint must match both the reviewed key
and the key the machine actually verifies against, the gpgkey must be a local
file under the system trust directory, and the endpoint must be one Terra
itself serves -- so the reviewed baseurl or the reviewed metalink host, now
pinned as TERRA_METALINK_BASEURL. An unknown key, a redirected baseurl, a
second enabled Terra, or a disabled signature check is still a hard refusal.

A refusal also stops less than it did. It suppresses the stages that open DNF
and the migrations, which may run a transaction of their own, and the run still
exits 78. It no longer stops link-dotfiles, link-skills or link-user, which
read no repository and install no package. Exiting before them is what left
this laptop with a stale ~/.claude/skills and no shipped skill reachable.

Also stub ensure_flathub_remote in the extras contract, which has been failing
since that call was added to install_extra_category without one.

Claude-Session: https://claude.ai/code/session_01PeTrG9dGY89UWuhGm4Pr1s
2026-08-28 14:53:48 -04:00
Gabriel Brown b5832fc94a Fix installer state and launcher freshness checks 2026-08-27 17:31:46 -04:00
Gabriel Brown 99156442b5 Merge branch 'main' into codex/repo-audit-remediation-package-2
# Conflicts:
#	README.md
#	setup/scripts/install-packages
2026-08-27 16:51:53 -04:00
Gabriel Brown 4c27203214 Route pre-MR review through the work account 2026-08-27 16:39:16 -04:00
Gabriel Brown c71d6c8799 Publish skills through the agent-neutral skill home 2026-08-27 16:39:16 -04:00
Gabriel Brown cb305f6662 Install ChatGPT Desktop from a repository this checkout can verify
OpenAI ships an official Linux RPM now, so the community wrapper goes away:
`panama app chatgpt-desktop` built codex-desktop from the upstream macOS disk
image and ran a local rebuild daemon to keep it current, and the official
package comes from a repository that upgrades with everything else. The app
file, the help example and the dock's pinned id all move over, and a migration
replaces the build on machines that already have it -- official package on
before the community one comes off, so a failure part-way still leaves an app.

The install itself does not follow upstream's instructions. Those are "download
this RPM and install it", and the RPM's own root scriptlet is what writes the
repository file and drops the signing key into /etc/pki/rpm-gpg -- so root runs
an unverified download and then learns from it what to trust. That is the shape
the repository audit forbids: no network response is executed as root without a
verified digest or signature first.

OpenAI publishes no key and no fingerprint anywhere an install could fetch and
check them, so the key is pinned here instead. setup/keys/ carries it and says
where it came from, including the honest part -- this is trust established on
first use and then held, not trust verified against the publisher. setup/lib/
chatgpt-package verifies that copy's fingerprint, installs it, and writes the
repository with gpgcheck and repo_gpgcheck on before anything is installed, so
dnf checks the metadata signature and the package signature itself. It is byte
for byte the repository the scriptlet would have written, so nothing churns
afterwards, and every later upgrade goes through the same key. Both callers use
it; a verification failure skips ChatGPT rather than installing it anyway.

The contract proves the pinned key is the key the library names, that a
missing, unreadable or mismatched key writes nothing at all, that what is
written actually turns the checks on, and that neither caller hands root a
downloaded RPM.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:48:20 -04:00
Gabriel Brown 1ee42f2cb6 Fix: Sort the contract manifest in byte order, not the machine's
The manifest is written in byte order, but both the runner and the manifest
contract discovered contracts with a bare `sort` and compared them with bash's
`<` -- and both of those follow LC_COLLATE. Under en_US.UTF-8 the collation
folds punctuation away, so `calendar_agenda_bridge_test.py` sorts before
`calendar-agenda-helper-contract` instead of after it, and eight pairs that
differ only by `-` against `_` come back out of order.

The effect was that `tests/setup/contract-manifest-contract` failed on this
machine, and `panama test` refused to run at all, with eight identical "paths
are not lexicographically sorted" findings and nothing naming which paths. A
gate whose answer depends on the machine's LANG is not a gate, so the sort and
the comparison are both pinned to byte order. LC_ALL rather than LC_COLLATE,
because an exported LC_ALL outranks it and would have put the bug back.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:47:47 -04:00
Gabriel Brown 1ae3825fda Alias a second Claude Code configuration
CLAUDE_CONFIG_DIR points the CLI at a different config home, so `klaude`
runs Claude Code against ~/.klaude -- its own settings, auth and history --
without disturbing the default one this machine already uses.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:39:43 -04:00
Gabriel Brown 28e387868f Docs: Record signed Terra bootstrap proof 2026-08-27 11:13:59 -04:00
Gabriel Brown 1f65e09865 Test: Prove verified bootstrap boundaries 2026-08-27 10:44:14 -04:00
Gabriel Brown dce00e45d1 Docs: Pin the verified Panama bootstrap 2026-08-27 10:14:55 -04:00
Gabriel Brown 0d1841cf86 Fix: Verify the initial Panama revision 2026-08-27 10:06:13 -04:00
Gabriel Brown ba4e5e6677 Fix: Lock installer input state 2026-08-27 09:48:28 -04:00
Gabriel Brown 1ffd05f0e1 Fix: Re-run verified installer inputs 2026-08-27 09:34:20 -04:00
Gabriel Brown 92c60c3ecd Fix: Close runtime provenance residuals 2026-08-27 09:22:17 -04:00
Gabriel Brown 31fc1d3eaa Fix: Harden pinned runtime activation 2026-08-27 08:32:44 -04:00
Gabriel Brown 8264ca6320 Fix: Pin runtime and agent artifacts 2026-08-27 07:49:12 -04:00
Gabriel Brown eab4280969 Fix: Parse complete Terra repository state 2026-08-27 07:25:25 -04:00
Gabriel Brown 1c3cd7ac72 Fix: Enforce effective repository trust preflight 2026-08-27 07:10:11 -04:00
Gabriel Brown 8652f92aae Fix: Validate existing package repositories 2026-08-27 06:46:36 -04:00
Gabriel Brown 4abf3f8acd Fix: Verify third-party package repositories 2026-08-27 06:27:35 -04:00
Gabriel Brown 3f8539176a Test: Strengthen ambient GPG isolation contract 2026-08-27 06:10:32 -04:00
Gabriel Brown 18ecc2244f Fix: Isolate installer GPG inspection 2026-08-27 06:05:42 -04:00
Gabriel Brown 94a4314730 Fix: Harden installer provenance boundary 2026-08-27 05:56:46 -04:00
Gabriel Brown 27acbe7a95 Test: Add installer provenance boundary 2026-08-27 05:43:26 -04:00
Gabriel Brown fc8f226747 Fix: Complete SSH bootstrap hardening 2026-08-27 05:19:58 -04:00
Gabriel Brown 98e29eb8f3 Test: Strengthen SSH bootstrap documentation contract 2026-08-27 04:35:45 -04:00
Gabriel Brown 00e11e0b8d Docs: Explain transactional SSH hardening 2026-08-27 04:25:05 -04:00
Gabriel Brown ac25fd776c Fix: Clean interrupted SSH candidates 2026-08-27 04:12:45 -04:00
Gabriel Brown 51e2c8418a Fix: Complete SSH hardening recovery 2026-08-27 04:06:54 -04:00
Gabriel Brown d267da58ad Fix: Roll back failed SSH hardening 2026-08-27 03:44:57 -04:00
Gabriel Brown 20b41c3976 Fix: Gate SSH hardening on a verified key 2026-08-27 03:28:42 -04:00
Gabriel Brown bfb37afd69 Plan secure bootstrap and privileged installation 2026-08-27 03:11:00 -04:00
Gabriel Brown b361db8486 Design secure bootstrap and privileged installation 2026-08-27 02:23:55 -04:00
Gabriel Brown 12b858371e Fix: Close verification gate review findings 2026-08-27 00:21:45 -04:00
Gabriel Brown 77f625d7bb Docs: Clarify contract output handling 2026-08-26 23:38:09 -04:00
Gabriel Brown 34e295d184 Docs: Explain contract capability gates 2026-08-26 23:32:38 -04:00
Gabriel Brown e19fb00276 Test: Harden dock fixture cleanup 2026-08-26 23:22:26 -04:00
Gabriel Brown 37688f702a Test: Isolate dock geometry fixtures 2026-08-26 23:12:14 -04:00
Gabriel Brown 71f4f0d24b Test: Guard settings docs contract cleanup 2026-08-26 23:05:14 -04:00
Gabriel Brown 3839183401 Test: Isolate generated docs and settings fixtures 2026-08-26 22:59:28 -04:00
Gabriel Brown 68b6664111 Test: Propagate update fixture failures 2026-08-26 22:53:39 -04:00
Gabriel Brown 803362ebad Test: Exercise updates through the real command 2026-08-26 22:46:46 -04:00
Gabriel Brown 6a7bdcb835 Test: Harden compose secret fixtures 2026-08-26 22:38:00 -04:00
Gabriel Brown 37fd5e890e Test: Scan compose secrets by data shape 2026-08-26 22:31:22 -04:00
Gabriel Brown a19c6dc3ef Test: Make agent usage fixtures date independent 2026-08-26 22:26:19 -04:00
Gabriel Brown 6d71a77798 Test: Cover contract runner consent 2026-08-26 22:22:57 -04:00
Gabriel Brown d8144a66c1 Fix: Make contract execution safe and diagnostic 2026-08-26 22:11:56 -04:00
Gabriel Brown 43d1e15858 Test: Harden manifest validation 2026-08-26 22:00:52 -04:00
Gabriel Brown 1192ad64dc Test: Classify every contract capability 2026-08-26 21:52:16 -04:00
Gabriel Brown 044139ed63 Plan verification gate remediation 2026-08-26 21:11:25 -04:00
Gabriel Brown 0d894afe3e Plan repository audit remediation 2026-08-26 20:42:59 -04:00
Gabriel Brown 54dfa977ee The notification menu learns what every other popover already knew
Popover's container is a plain Item and never measures its children, so
the three-dots menu on a notification card opened as a 240x28 sliver
with its one row clipped out of existence. TrayMenu and AgentUsagePanel
both carry the antidote — implicit sizes on the Popover itself, taken
from the content column — and now the notification settings menu does
too, with rows filling the window width so the hover reaches the edges.

Claude-Session: https://claude.ai/code/session_01W8icivxZcmFTR2g6DkYhjT
2026-08-26 19:50:31 -04:00
Gabriel Brown 5f09c5ee38 The document portal may not claim started until its mount exists
Third recurrence of the stale-mount bug: the ExecStartPre cleanup ran on
clean ground and the portal still came up mountless, sitting active while
every fresh flatpak sandbox failed to build. Add an ExecStartPost that
polls for the fuse mount and fails the start after five seconds without
one, so a mountless portal shows up failed in the journal and the next
bus activation retries from the cleanup instead of lingering broken.

Claude-Session: https://claude.ai/code/session_01CTL7G5u3iDf1NCM6rTyCES
2026-08-26 10:48:47 -04:00
Gabriel Brown f33da41cc6 Panama learns what a server is: from a root login to running containers
A machine's role is now the interview's first question and the one answer
Panama records. Servers get the same shell minus the screen: core packages,
nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless
ports from 80, firewalld, the nginx-bridge network, and a nightly image
updater that replaced watchtower for cause.

server/containers/ carries junior's 23 compose services -- secrets moved to
per-machine .env files that never enter this public repo, every transformed
compose proven to render byte-identical to what is live. 'panama server'
enables, disables and relinks them; nothing here restarts a running service.
'boot --server' walks a fresh VPS from its root login to a normal install.

Five new contracts pin the secrets rule, the catalog's shape, panama-server's
behavior, the role plumbing, and the dotfile classification.

Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
2026-08-25 23:11:49 -04:00
Gabriel Brown 9b338608ef Center the usage pill like every other tenant of the bar
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 13:15:42 -04:00
Gabriel Brown cc7d91d09c No error is a dead end: crash, click, and your agent is already looking
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 12:50:09 -04:00
Gabriel Brown ada0faf1d1 Let the sidebar breathe: fourteen categories deserve legible names
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 11:20:34 -04:00
Gabriel Brown 045a774847 The machine now carries its own manual for AI hands
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 10:09:38 -04:00
Gabriel Brown beed44dd87 panama update: one command, no questions, and no gap it cannot see
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 07:22:12 -04:00
Gabriel Brown 8105849151 The last two stale needles the great respelling left behind
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 02:31:59 -04:00
Gabriel Brown bd2fd12de2 Coherence: one component per idea, one spelling per word, one truth per claim
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 02:21:45 -04:00
Gabriel Brown 06c53d6c21 Shortcuts you invent, rules you write, gestures you own - all still just data
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 01:51:59 -04:00
Gabriel Brown f9e5d3f470 No forgetting, deleting or clearing on a single press, anywhere
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 00:41:39 -04:00
Gabriel Brown 88371d19f0 The safety layer: two presses for anything you cannot take back
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 00:39:07 -04:00
Gabriel Brown 8b1205e4b8 Tier 0: render what the services already decided, honestly
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 00:24:30 -04:00
Gabriel Brown be0e55214b Finish the wonderland: System told truthfully, in eight tabs instead of ten
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 23:31:52 -04:00
Gabriel Brown 9ffaf45a4d Make every settings row reachable, and every accessibility switch honest
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 21:03:36 -04:00
Gabriel Brown e1ff25fc66 Draw idle as one timeline, and let the power button answer to its owner
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 20:33:44 -04:00
Gabriel Brown 6f0ce639d9 Show every answer the portal remembers, and give SSH keys their missing half
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 19:26:56 -04:00
Gabriel Brown 4ec8bd94d9 Give identity its due: native enrollment, honest deletion, and sign-in that stays home
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 18:55:38 -04:00
Gabriel Brown 5a0643357f Make Applications a real app manager, and clean up storage without the racket
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 17:18:14 -04:00
Gabriel Brown b30bf40407 Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 16:31:52 -04:00
Gabriel Brown aba2d16ffa Give Input keycaps, a shortcut search, and the missing pointer basics
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 15:21:01 -04:00
Gabriel Brown b8f88a91f3 Add the four components the Focus commit referenced but forgot to ship
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 15:14:02 -04:00
Gabriel Brown d5b6e62515 Bound the notification app list, and give Focus a real editor
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 14:25:26 -04:00
511 changed files with 69358 additions and 5053 deletions
+134
View File
@@ -0,0 +1,134 @@
---
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.
Before large work, read these in 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
`config/dot/quickshell/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.
@@ -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.
+12
View File
@@ -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:*)"
]
}
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/panama
+14 -3
View File
@@ -1,10 +1,21 @@
# Ignore bash environment variables, and the lock its writer takes. # Ignore bash environment variables, and the lock its writer takes.
/config/bash/env /config/bash/env
/config/bash/.env.lock /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
/config/old /config/old
# MCP bearer tokens. The server list in user/agents/mcp/servers is tracked
# and names which variable carries each token; the tokens themselves are
# machine-local, because this repo is public.
/user/agents/mcp/env
# Ignore Wireguard config of course! # Ignore Wireguard config of course!
/config/wg/** /config/wg/**
# Ignore Neovim lazy-lock file # Ignore Neovim lazy-lock file
@@ -32,10 +43,10 @@ __pycache__/
/config/firefox/chrome/panama-theme.css /config/firefox/chrome/panama-theme.css
/config/firefox/chrome/panama-theme.css.tmp /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
+170 -32
View File
@@ -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 `~/.agents/skills` and `~/.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,18 +157,21 @@ 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 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 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 applied as one complete layout, read back from the compositor, and reverted
after fifteen seconds unless you keep it — resolution, scale, rotation, after fifteen seconds unless you keep it — resolution, scale, rotation,
position and primary display, and now colour profile, bit depth, SDR position and primary display, and now color profile, bit depth, SDR
brightness and saturation, and mirroring with them. Two of those opt out of 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 part of that, for reasons rather than convenience. A per-display variable
refresh rate override is applied but never verified, because `hyprctl` reports refresh rate override is applied but never verified, because `hyprctl` reports
@@ -100,7 +187,7 @@ DDC, and the buttons on the bezel change it behind our back.
| `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 |
@@ -108,7 +195,11 @@ DDC, and the buttons on the bezel change it behind our back.
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.
@@ -132,33 +223,53 @@ 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
~/.agents/skills and ~/.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 keys/ Pinned signing keys, for publishers that ship no fetchable
one; setup/keys/README.md records where each came from
lib/ Shared by more than one stage; the extras catalog reader,
the machine role, the verified ChatGPT repository
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
169 of them, under `tests/`. Run the lot, or a subset by pattern: 190 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
@@ -171,6 +282,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
@@ -183,21 +296,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
@@ -210,7 +346,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
@@ -236,6 +372,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.
+700 -84
View File
@@ -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,13 +119,21 @@ ${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
$PROGRAM app chatgpt-desktop
EOF EOF
} }
@@ -112,25 +150,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 +283,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 +291,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,63 +364,558 @@ 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
# The manifest is kept in byte order, so both the discovery sort and the
# comparison below have to be byte order too. A UTF-8 collation folds the
# punctuation away -- `calendar_agenda_bridge_test.py` sorts before
# `calendar-agenda-helper-contract` under en_US and after it under C -- and
# a gate that passes or fails on the machine's LANG is not a gate.
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__*' | LC_ALL=C 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"
# Byte order, for the same reason contract_paths sorts in it.
local LC_ALL=C
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
} }
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@@ -539,13 +1151,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" ;;
*) *)
+195
View File
@@ -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[@]}"
+85
View File
@@ -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"
+67
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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."
+323
View File
@@ -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 "$@"
+468 -21
View File
@@ -2,33 +2,489 @@
# 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
}
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
# 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
# Panama assumes Fedora's repositories, package names, and GNOME base install. 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 if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
echo "This looks like something other than Fedora; Panama only supports Fedora Workstation." >&2 echo "This looks like something other than Fedora; Panama only supports Fedora." >&2
exit 1 exit 1
fi 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 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"
exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" --server </dev/tty
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
@@ -36,24 +492,15 @@ if ! command -v git >/dev/null 2>&1; then
sudo dnf install -y git sudo dnf install -y 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.
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 exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi fi
exec "$PANAMA_PATH/install" exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
+3 -2
View File
@@ -22,6 +22,7 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
alias nlc='nvim leetcode.nvim' alias nlc='nvim leetcode.nvim'
alias clauded='claude --allow-dangerously-skip-permissions' alias clauded='claude --allow-dangerously-skip-permissions'
alias claudedr='claude --allow-dangerously-skip-permissions --resume' alias claudedr='claude --allow-dangerously-skip-permissions --resume'
alias klaude='CLAUDE_CONFIG_DIR="$HOME/.klaude" claude'
alias commit-repo='git add -A && git commit -m "Update stuff" && git push' alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
# Docker Shortcuts # Docker Shortcuts
@@ -36,9 +37,9 @@ alias fprint-on='sudo authselect enable-feature with-fingerprint'
alias fprint-off='sudo authselect disable-feature with-fingerprint' alias fprint-off='sudo authselect disable-feature with-fingerprint'
# File system # File system
alias ls='eza -lh --group-directories-first --icons' alias ls='eza -lh --group-directories-first --icons=auto'
alias lsa='ls -a' alias lsa='ls -a'
alias lt='eza --tree --level=2 --long --icons --git' alias lt='eza --tree --level=2 --long --icons=auto --git'
alias lta='lt -a' alias lta='lt -a'
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name. # Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
alias ff="fzf --preview 'bat --style=numbers --color=always {}'" alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
@@ -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'
+10 -2
View File
@@ -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
+3 -1
View File
@@ -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:
+226
View File
@@ -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
+77
View File
@@ -6,6 +6,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs") local prefs = require("prefs")
local actions = require("actions")
hl.config({ hl.config({
input = { input = {
@@ -35,6 +36,13 @@ hl.config({
-- Still focus-follows-pointer, just less twitchy. -- Still focus-follows-pointer, just less twitchy.
mouse_refocus = false, mouse_refocus = false,
-- 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 -- Flat pointer response by default, no acceleration. Matters for
-- gaming; Settings offers adaptive for people who want it back. -- gaming; Settings offers adaptive for people who want it back.
sensitivity = prefs.get("pointerSensitivity", 0), sensitivity = prefs.get("pointerSensitivity", 0),
@@ -44,6 +52,21 @@ hl.config({
scroll_factor = prefs.get("scrollFactor", 1.0), scroll_factor = prefs.get("scrollFactor", 1.0),
left_handed = prefs.get("leftHanded", false), 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,
@@ -61,9 +84,33 @@ hl.config({
scroll_factor = prefs.get("touchpadScrollFactor", 1.0), scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
drag_lock = prefs.getInt("touchpadDragLock", 0), drag_lock = prefs.getInt("touchpadDragLock", 0),
middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false), 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. -- Tuning for the three-finger gestures registered below.
gestures = { gestures = {
workspace_swipe_distance = prefs.getInt("swipeDistance", 300), workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
@@ -95,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
+131 -5
View File
@@ -12,6 +12,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs") local prefs = require("prefs")
local actions = require("actions")
local mod = "SUPER" local mod = "SUPER"
@@ -262,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" })
@@ -472,11 +511,52 @@ bind("XF86RFKill", hl.dsp.exec_cmd(osd("airplane toggle")), { locked = true, des
-- displays are actually arranged. -- displays are actually arranged.
bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" }) bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" })
-- The power button. logind is told to ignore it (config/copy ships the -- ── The power button ────────────────────────────────────────────────────────
-- drop-in) so a stray press is a question, not an instant poweroff -- the category("Media & hardware")
-- menu this opens is the question. Until the next boot after that drop-in --
-- lands, logind still acts on the key; this bind costs nothing extra then. -- logind is told to ignore the power key (config/copy ships the drop-in) so a
bind("XF86PowerOff", hl.dsp.exec_cmd("qs ipc call powermenu toggle"), { locked = true, description = "Power menu" }) -- 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 -- 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 -- internal panel off so nothing renders inside a closed shell and no
@@ -495,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
+52
View File
@@ -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),
@@ -145,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) },
+14 -4
View File
@@ -244,10 +244,20 @@ 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 -- 8-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 -- HBR3 and reliant on DSC, and this panel's link is marginal: every modeset
-- a lower mode, drop this to 8 first. -- retrains it and blanks the screen. 8bpc keeps headroom on the link.
local shipped_bitdepth = 10 --
-- Related: directScanoutPolicy is 0 in Panama settings (2026-09-13). With
-- scanout on, a fullscreen game whose buffer depth differs from the desktop
-- (games ship both 8- and 10-bit swapchains) makes Hyprland change the output
-- format, and on amdgpu a format change is a full modeset. Compositing always
-- keeps the format fixed, so the link never retrains mid-game.
--
-- vrrPolicy is also 0 there. VRR on this panel loses sync and blacks out
-- (seen on GNOME in July 2026 and again here); a 60Hz panel gains little
-- from it anyway.
local shipped_bitdepth = 8
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header. -- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
local shipped_cm = "auto" local shipped_cm = "auto"
+117 -2
View File
@@ -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",
@@ -123,6 +125,16 @@ hl.window_rule({
no_dim = true, no_dim = true,
}) })
-- Steam games never get true fullscreen. Everything Hyprland does only for a
-- real fullscreen window (direct scanout, VRR, tearing, auto HDR) makes this
-- panel's marginal DP link retrain and blank (2026-09-13). "1 2" keeps the
-- window maximized internally while the game believes it is fullscreen, which
-- is what borderless windowed looks like from the game's side.
hl.window_rule({
match = { class = "^steam_app_\\d+$" },
fullscreen_state = "1 2",
})
-- Steam itself is a normal window, but its transient popups are a mess. -- Steam itself is a normal window, but its transient popups are a mess.
hl.window_rule({ hl.window_rule({
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" }, match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
@@ -136,6 +148,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".
-- --
@@ -176,16 +279,28 @@ hl.layer_rule({
ignore_alpha = 0.2, ignore_alpha = 0.2,
}) })
-- Overview, capture and local screen-reading UI dim the desktop behind them. -- Overview and local screen-reading UI dim the desktop behind them.
hl.layer_rule({ hl.layer_rule({
name = "qs-overlay", name = "qs-overlay",
match = { namespace = "^qs-(overview|capture|screen-intelligence)$" }, match = { namespace = "^qs-(overview|screen-intelligence)$" },
blur = true, blur = true,
ignore_alpha = 0.4, ignore_alpha = 0.4,
dim_around = true, dim_around = true,
no_screen_share = true, no_screen_share = true,
}) })
-- The screenshot picker paints an opaque frozen frame edge to edge, so blur and
-- dim would be compositor work on pixels nobody sees. no_anim is load-bearing:
-- services/Capture.qml unmaps the picker and runs grim 90ms later, and the
-- 200ms layersOut fade would otherwise put the half-faded picker, dimming and
-- all, into every screenshot.
hl.layer_rule({
name = "qs-capture",
match = { namespace = "^qs-capture$" },
no_anim = true,
no_screen_share = true,
})
-- Notification toasts. Blurred like every other shell surface -- without this -- Notification toasts. Blurred like every other shell surface -- without this
-- the cards are a near-transparent fill sitting directly on the wallpaper and -- the cards are a near-transparent fill sitting directly on the wallpaper and
-- read as washed out rather than as glass. -- read as washed out rather than as glass.
+28
View File
@@ -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);
}
+28
View File
@@ -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);
}
+33
View File
@@ -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);
}
+28
View File
@@ -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);
}
+301 -13
View File
@@ -168,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 ─────────────────────────────────────────────────────────
@@ -294,14 +346,13 @@ Singleton {
} }
] ]
}, },
// Grouped with the workspaces rather than with focus, because a group is // Set by the duration chips on the Focus tab, which is also where
// where a setting is found rather than what it is about: the slider that // focusModes renders and where the focus group routes -- one editor,
// sets this is a schema-bound row on WorkspacesPage. The focus group // one page, so search and the docs point at the only place it exists.
// routes to Notifications, which is where focusModes renders.
{ {
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",
group: "workspaces", group: "focus",
label: "Focus session length", label: "Focus session length",
detail: "How long a focus session runs before it ends itself" detail: "How long a focus session runs before it ends itself"
}, },
@@ -758,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 ────────────────────────────────────────────────────────
// //
@@ -815,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
@@ -919,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" }
}, },
{ {
@@ -951,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
@@ -1150,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
@@ -1490,6 +1698,20 @@ 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 ─────────────────────────────────────────────────────────── // ── Sound ───────────────────────────────────────────────────────────
// The two audio preferences that are Panama's own. Everything else on // The two audio preferences that are Panama's own. Everything else on
@@ -1583,7 +1805,7 @@ Singleton {
"org.mozilla.thunderbird_esr", "com.slack.Slack", "org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk", "app.bluebubbles.BlueBubbles", "rustdesk",
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude", "io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
"codex-desktop", "md.obsidian.Obsidian", "chatgpt", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam" "com.obsproject.Studio", "steam"
] ]
}, },
@@ -1604,6 +1826,63 @@ 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, vrrMode, // { "<output>": { mode, scale, transform, x, y, primary, vrrMode,
// colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } }, // colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } },
@@ -1618,8 +1897,17 @@ Singleton {
}, },
// ── Per-application notification rules ────────────────────────────── // ── Per-application notification rules ──────────────────────────────
// { "<appId>": { enabled } } -- lock-screen fields from older rules are // { "<appId>": { enabled, sound, display, urgency, lastSeenMs, name,
// dropped at normalization; hyprlock cannot render notifications. // 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
@@ -1630,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 ────────────────────────────────────────────────────────
+28
View File
@@ -62,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")
@@ -82,6 +95,21 @@ 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 ─────────────────────────────────────────────────────────────── // ── Sound ───────────────────────────────────────────────────────────────
// Over-amplification is the clamp ceiling for output volume: off means 1.0, // Over-amplification is the clamp ceiling for output volume: off means 1.0,
+11 -4
View File
@@ -204,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]
+4
View File
@@ -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 Keyboard page, under Input, listing every bind, each of which Settings has a [Keyboard page](panama://settings/shortcuts), under Input, listing every bind, each of which
can be 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,9 +28,11 @@ 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
@@ -41,6 +43,29 @@ How windows share the space — the layout, the master area, gaps and window
edges — is Shell › Tiling. The workspace switches themselves, and the projects edges — is Shell › Tiling. The workspace switches themselves, and the projects
below, are Shell › Workspaces. 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
A project is the set of windows you open together: which applications, which A project is the set of windows you open together: which applications, which
@@ -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, then the System Health tab. 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.
@@ -28,11 +28,11 @@ 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. It opens on a picture of what is connected — Settings has a [Displays page](panama://settings/displays). It opens on a picture of what is connected —
one display or several — and everything under that picture belongs to one display or several — and everything under that picture belongs to
whichever one you have selected. Every change there reverts itself after whichever one you have selected. Every change there reverts itself after
fifteen seconds unless you confirm it, so a mode your monitor cannot show fifteen seconds unless you confirm it, so a mode your monitor cannot show
cannot strand you, and that covers colour, bit depth and mirroring as well as cannot strand you, and that covers color, bit depth and mirroring as well as
resolution, scale and rotation. If you are already stranded, `hyprctl resolution, scale and rotation. If you are already stranded, `hyprctl
monitors` from a terminal shows what is applied. monitors` from a terminal shows what is applied.
@@ -45,12 +45,35 @@ 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 tab under System in Settings can roll If an update went badly, [Snapshots](panama://settings/snapshots) under System can roll
the system back. That tab is only there 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. so if you cannot see it, this machine has none to roll back to.
@@ -34,10 +34,10 @@ screen and the system monitor at once — not just the shell's own windows.
### Build your own ### Build your own
The **Theme editor** tab is four colours: primary, secondary, background and The **Theme editor** tab is four colors: primary, secondary, background and
foreground. Each has a swatch, a hex field you can type into, a colour wheel 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 and an eyedropper for sampling anything on screen. Background and foreground
are not single colours — the panels, popovers, dividers and dimmed text are all 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. 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 Under that, a saturation slider for the whole palette at once, a fine-tune for
@@ -67,7 +67,7 @@ titlebar can be turned off entirely, which leaves the Settings window bare:
## Shell ## Shell
Everything Panama draws on the screen has a tab under **Shell**: the bar, the Everything Panama draws on the screen has a tab under **Shell**: the bar, the
dock, Control Center, tiling and workspaces. Appearance decides the colours; dock, Control Center, tiling and workspaces. Appearance decides the colors;
this decides what is there at all. this decides what is there at all.
### The bar ### The bar
@@ -125,14 +125,14 @@ behaviour, how long a focus session runs, and your saved projects.
## Carrying settings between machines ## Carrying settings between machines
System › **Sync & Backup**. Export writes your settings to a file; importing 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 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 — 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 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 covers the whole filesystem. **Restore defaults** is at the bottom: it resets
appearance, dock, clock, focus and display policy and clears your Home appearance, the dock and the applications pinned to it, clock, focus and
accessory arrangement, and leaves your pinned applications, files and paired display policy, removes any themes you saved, and clears your Home accessory
devices alone. arrangement. Your files, paired devices and settings backups are left alone.
## Applications ## Applications
@@ -158,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.
@@ -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,7 +27,7 @@ Pill {
visible: Settings.showAgentUsage && AgentUsage.available visible: Settings.showAgentUsage && AgentUsage.available
onActivated: ShellState.openSettings("bar") onActivated: panel.visible = !panel.visible
onSecondaryActivated: ShellState.openSettings("bar") onSecondaryActivated: ShellState.openSettings("bar")
Row { Row {
@@ -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
}
} }
+3 -1
View File
@@ -112,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
@@ -27,9 +27,13 @@ PanelWindow {
right: true right: true
} }
// Fullscreen overlays must not reserve space, or every window on the // Ignore, and nothing else: reserve no space (or every window on the
// workspace gets resized as the picker opens and closes. // workspace resizes as the picker opens and closes) and respect nobody's
exclusiveZone: 0 // (or the window starts under the bar, the full-output freeze frame is
// squeezed into a shorter box, and every selection lands one bar-height
// off in the real capture). Never pair this with exclusiveZone; see
// tests/quickshell/exclusion-idiom-contract.
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
@@ -57,7 +57,6 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind // The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
@@ -55,7 +55,6 @@ PanelWindow {
color: "transparent" color: "transparent"
// A dock that reserved space would not be intellihiding. // A dock that reserved space would not be intellihiding.
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename. // Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
@@ -1,4 +1,4 @@
// "Add app to dock", reachable without opening Settings. // "Add application to dock", reachable without opening Settings.
// //
// The Settings page is the place to curate the whole dock -- reorder it, unpin // 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 // things, change how it hides. Adding one application is a single decision made
@@ -7,7 +7,7 @@
// //
// It embeds the same DockAppPicker the Dock page uses rather than growing a // It embeds the same DockAppPicker the Dock page uses rather than growing a
// second search: the exclusion of already-pinned applications, the icon lookup // second search: the exclusion of already-pinned applications, the icon lookup
// and the "nothing until you type" behaviour are all already there, and two // and the "nothing until you type" behavior are all already there, and two
// copies of them would drift. // copies of them would drift.
import Quickshell import Quickshell
@@ -40,7 +40,6 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted // Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
@@ -125,7 +124,7 @@ PanelWindow {
Text { Text {
id: title id: title
text: "Add app to dock" text: "Add application to dock"
color: Theme.fg color: Theme.fg
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge font.pixelSize: Theme.fontSizeLarge
@@ -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,167 @@
// 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.
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 }
}
}
@@ -29,7 +29,6 @@ PanelWindow {
screen: root.modelData screen: root.modelData
anchors.bottom: true anchors.bottom: true
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3 margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
implicitWidth: root.desiredWidth implicitWidth: root.desiredWidth
implicitHeight: 64 implicitHeight: 64
@@ -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")
@@ -22,7 +22,6 @@ PanelWindow {
} }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename. // Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
@@ -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
@@ -63,26 +63,34 @@ PanelWindow {
// withdraw itself (hibernate on a machine with no resume swap). // withdraw itself (hibernate on a machine with no resume swap).
readonly property var entries: allEntries.filter(entry => entry.available !== false) 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: [ 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: "󰋊", glyph: "󰋊",
label: "Hibernate", label: "Hibernate",
destructive: false, destructive: false,
@@ -90,12 +98,14 @@ PanelWindow {
cmd: ["systemctl", "hibernate"] 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,
@@ -103,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 {
@@ -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)
}
} }
} }
} }
@@ -34,11 +34,23 @@ Item {
visible: root.rowCount > 0 visible: root.rowCount > 0
implicitHeight: rows.implicitHeight implicitHeight: rows.implicitHeight
// Probing I2C takes on the order of a second, so it waits until the panel // Probing I2C takes on the order of a second, so it waits until the quick
// is actually on screen rather than running at shell startup. Monitors do // settings panel is actually on screen rather than running at shell
// not come and go, so once is enough. // startup. Monitors do not come and go, so once is enough.
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh() //
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh() // The trigger is the panel opening, not this item becoming visible: on a
// machine with no backlight, `visible` stays false until a scan has found
// a DDC display, so keying the scan off it would never scan at all.
Connections {
target: ShellState
function onQuickSettingsOpenChanged(): void { root.scanIfOpen(); }
}
Component.onCompleted: root.scanIfOpen()
function scanIfOpen(): void {
if (ShellState.quickSettingsOpen && !Brightness.scanned)
Brightness.refresh();
}
// `-m` is the machine-readable form: name,class,current,percent,max // `-m` is the machine-readable form: name,class,current,percent,max
Process { Process {
@@ -170,6 +170,22 @@ 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: // Caffeine plus Do Not Disturb as one switch, for the projector:
// the half you forget to arm is the one that fires a message // the half you forget to arm is the one that fires a message
// preview onto the big screen. Restores both exactly as found. // preview onto the big screen. Restores both exactly as found.
@@ -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")
} }
} }
} }
@@ -3,7 +3,7 @@
// This is the fine-tune behind a disclosure, not the fast path: the four wells // 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 // 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 // degrees. Each row is a labelled, keyboard-operable slider with a numeric
// readout, because a hue ring alone tells someone with a colour vision // readout, because a hue ring alone tells someone with a color vision
// deficiency nothing. // deficiency nothing.
// //
// Committed on a debounce rather than per move. Each move used to write BOTH // Committed on a debounce rather than per move. Each move used to write BOTH
@@ -140,27 +140,27 @@ 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"
divider: false divider: false
} }
@@ -176,7 +176,7 @@ Column {
} }
// Hands the sliders back to the stored pair. If the write was refused they // Hands the sliders back to the stored pair. If the write was refused they
// snap back to what is really in effect rather than showing a colour // snap back to what is really in effect rather than showing a color
// nothing accepted. // nothing accepted.
Timer { Timer {
id: releaseTimer id: releaseTimer
@@ -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,75 +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 -- and for the
// right reason. On Wayland there is no protocol for sticky, slow or
// bounce keys: each compositor implements its own (mutter does, which is
// how GNOME has them on Wayland), and Hyprland does not yet. An earlier
// version blamed "X11 feature with no Wayland equivalent", which sent
// anyone who needs sticky keys to the wrong conclusion about the whole
// platform. There is also deliberately no handoff to GNOME's
// universal-access panel: its toggles are applied by GNOME Shell, and
// the few that work through plain gsettings (cursor size, text scale,
// high contrast) are owned by the controls above on this very page.
SettingsCard { SettingsCard {
title: "Keyboard accessibility" title: "Hearing"
subtitle: "Sticky, slow and bounce keys are implemented by each Wayland compositor for itself; Hyprland does not implement them yet, so they are unavailable in this session. Offering switches here would store preferences nothing acts on." subtitle: "What the desktop does instead of making a sound."
ToggleRow { setting: "visualAlerts"; divider: false }
Item { width: 1; height: 10 }
// 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: "Screen reader" label: "Key repeat"
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here" detail: "Delay and speed are on the Keyboard page, with the rest of the keymap"
action: "Start Orca" 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.openApplication("orca")
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 }
}
}
@@ -20,7 +20,7 @@ SettingsPage {
property string expandedPicker: "" property string expandedPicker: ""
// Whether the accent's HSV fine-tune is open. Collapsed by default: it is // Whether the accent's HSV fine-tune is open. Collapsed by default: it is
// six sliders that answer a question the four colour wells above have // six sliders that answer a question the four color wells above have
// usually already answered. // usually already answered.
property bool fineTuneOpen: false property bool fineTuneOpen: false
@@ -270,7 +270,7 @@ SettingsPage {
Text { Text {
width: parent.width width: parent.width
text: "Primary, secondary, background, foreground, effects — every colour yours, saved as a theme that lives in the galleries above" text: "Primary, secondary, background, foreground, effects — every color yours, saved as a theme that lives in the galleries above"
color: Theme.fgDim color: Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -316,15 +316,10 @@ SettingsPage {
: "Based on " + ThemeProfiles.activeProfile.name : "Based on " + ThemeProfiles.activeProfile.name
+ " · every change previews live on the real desktop" + " · every change previews live on the real desktop"
Text { ErrorRow {
width: parent.width label: "The theme needs attention"
visible: wells.lastError !== "" || ColorScheme.lastError !== "" message: wells.lastError !== "" ? wells.lastError : ColorScheme.lastError
text: wells.lastError !== "" ? wells.lastError : ColorScheme.lastError divider: true
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
bottomPadding: 10
} }
ThemeStartChips { ThemeStartChips {
@@ -422,7 +417,7 @@ SettingsPage {
SettingRow { SettingRow {
label: "Application" label: "Application"
detail: "GTK and libadwaita apps that follow the desktop default" detail: "GTK and libadwaita applications that follow the desktop default"
divider: false divider: false
controlWidth: 8 controlWidth: 8
} }
@@ -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,151 +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."
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: "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"
@@ -260,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 {
@@ -281,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
@@ -320,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
} }
@@ -351,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();
}
} }
} }
} }
@@ -16,6 +16,21 @@ Column {
spacing: 0 spacing: 0
// Connected and paired devices always render (they lead the sort, so the
// slice keeps them); unpaired strangers fill up to the cap and the rest
// wait behind the "nearby devices" row. Discovery in a busy room finds
// dozens of phones and TVs that are not yours.
property bool showAll: false
readonly property int visibleCap: 5
readonly property var shown: {
const list = Connectivity.bluetoothDevices;
if (root.showAll || list.length <= root.visibleCap)
return list;
const pinned = list.filter(device => device.connected || device.paired).length;
return list.slice(0, Math.max(root.visibleCap, pinned));
}
readonly property int hiddenCount: Connectivity.bluetoothDevices.length - root.shown.length
function primaryAction(device: var): void { function primaryAction(device: var): void {
if (device.connected) { if (device.connected) {
device.disconnect(); device.disconnect();
@@ -41,7 +56,7 @@ Column {
} }
Repeater { Repeater {
model: Connectivity.bluetoothDevices model: root.shown
SettingRow { SettingRow {
id: entry id: entry
@@ -51,9 +66,16 @@ Column {
width: parent.width width: parent.width
label: entry.modelData.name || entry.modelData.address || "Unknown device" label: entry.modelData.name || entry.modelData.address || "Unknown device"
detail: root.stateLabel(entry.modelData) // Armed, the detail names what Forget costs. "Forget" reads like
divider: entry.index < Connectivity.bluetoothDevices.length - 1 // "hide from this list"; it is the pairing itself that goes, and
controlWidth: 200 // the device has to be put back into pairing mode to return.
detail: forgetConfirm.armed
? "Drops the pairing — the device must be put back into pairing mode to return"
: root.stateLabel(entry.modelData)
divider: entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll
// Room for Keep and the confirming press while the row is armed.
controlWidth: forgetConfirm.armed ? 290 : 200
activatable: !entry.modelData.pairing activatable: !entry.modelData.pairing
onActivated: root.primaryAction(entry.modelData) onActivated: root.primaryAction(entry.modelData)
@@ -71,16 +93,35 @@ Column {
onClicked: root.primaryAction(entry.modelData) onClicked: root.primaryAction(entry.modelData)
} }
SettingsButton { ConfirmAction {
id: forgetConfirm
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.paired visible: entry.modelData.paired
text: "Forget" actionId: "bluetooth-forget:"
onClicked: entry.modelData.forget() + (entry.modelData.address || entry.modelData.name || "")
armText: "Forget…"
confirmText: "Forget it"
enabled: !entry.modelData.pairing
onConfirmed: entry.modelData.forget()
} }
} }
} }
} }
SettingRow {
width: parent.width
visible: root.hiddenCount > 0
|| (root.showAll && Connectivity.bluetoothDevices.length > root.visibleCap)
label: root.showAll
? "Show fewer devices"
: root.hiddenCount + (root.hiddenCount === 1 ? " more nearby device" : " more nearby devices")
detail: root.showAll ? "" : "Unpaired devices in range, folded to keep the list short"
activatable: true
onActivated: root.showAll = !root.showAll
divider: false
}
SettingRow { SettingRow {
width: parent.width width: parent.width
visible: Connectivity.bluetoothDevices.length === 0 visible: Connectivity.bluetoothDevices.length === 0
@@ -24,7 +24,28 @@ SettingRow {
detail: root.spec ? root.spec.detail : "" detail: root.spec ? root.spec.detail : ""
controlWidth: Math.max(120, root.options.length * 92) controlWidth: Math.max(120, root.options.length * 92)
readonly property var currentOption:
root.options.find(option => option.value === root.current) ?? null
// Left and Right move one segment along and commit, which is what the
// segments already do under a click -- the arrows are just the other way to
// reach the same neighbour. It never wraps: running off the end of a
// segmented control silently landing on the far end is how a keyboard user
// sets something they did not mean to.
function stepChoice(delta: int): void {
if (root.options.length === 0)
return;
const at = root.options.findIndex(option => option.value === root.current);
const from = at < 0 ? 0 : at;
const next = Math.max(0, Math.min(root.options.length - 1, from + delta));
if (next === at)
return;
SystemSettings.commitPreference(root.setting, root.options[next].value);
}
Rectangle { Rectangle {
id: group
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
implicitWidth: segments.implicitWidth + 4 implicitWidth: segments.implicitWidth + 4
@@ -33,6 +54,35 @@ SettingRow {
color: Theme.alpha(Theme.fg, 0.07) color: Theme.alpha(Theme.fg, 0.07)
border.width: 0 border.width: 0
activeFocusOnTab: true
Accessible.role: Accessible.ComboBox
Accessible.name: root.label
Accessible.description: {
const chosen = root.currentOption
? String(root.currentOption.label ?? "")
: "nothing selected";
return root.detail === "" ? chosen : `${root.detail} — ${chosen}`;
}
Accessible.focusable: true
Accessible.focused: group.activeFocus
Keys.onLeftPressed: root.stepChoice(-1)
Keys.onUpPressed: root.stepChoice(-1)
Keys.onRightPressed: root.stepChoice(1)
Keys.onDownPressed: root.stepChoice(1)
// Focus ring only -- the strip keeps its borderless plate at rest.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 11
color: "transparent"
visible: group.activeFocus
border.width: 2
border.color: Theme.accentSecondary
}
Row { Row {
id: segments id: segments
anchors.centerIn: parent anchors.centerIn: parent
@@ -54,6 +104,13 @@ SettingRow {
border.width: 0 border.width: 0
color: "transparent" color: "transparent"
// Named individually so the reader says which option it is
// on, not just that a choice exists. Not a Tab stop: the
// strip is one stop and the arrows walk it.
Accessible.role: Accessible.RadioButton
Accessible.name: String(segment.modelData.label ?? "")
Accessible.checked: segment.selected
// The selected segment is the only place the prism appears // The selected segment is the only place the prism appears
// in a row: blue leads into orchid, never orchid alone. // in a row: blue leads into orchid, never orchid alone.
Rectangle { Rectangle {
@@ -175,7 +175,7 @@ Rectangle {
clip: true clip: true
Accessible.role: Accessible.EditableText Accessible.role: Accessible.EditableText
Accessible.name: root.role + " colour, hex" Accessible.name: root.role + " color, hex"
// Seeded once, then written only by the guarded handlers // Seeded once, then written only by the guarded handlers
// below and by onSwatchColorChanged. A binding here would // below and by onSwatchColorChanged. A binding here would
@@ -212,7 +212,7 @@ Rectangle {
IconButton { IconButton {
glyph: "◐" glyph: "◐"
title: "Open the colour wheel for " + root.role title: "Open the color wheel for " + root.role
onTriggered: root.wheelRequested() onTriggered: root.wheelRequested()
} }
@@ -0,0 +1,81 @@
// The two-stage destructive confirm, as one component instead of twenty
// hand-rolled copies. The first press arms ("Verb…", normal tone -- danger
// never initiates, per SettingsButton's own contract); armed, the row shows
// Cancel ("Keep") beside the confirming press ("Verb it", danger tone).
//
// One armed confirm exists app-wide: the token lives on ShellState, so arming
// this one disarms whichever other row was armed, on any page. Consumers give
// a unique actionId, the verb pair, and handle onConfirmed; the component owns
// the state, the copy shape, and the keyboard path (via SettingsButton).
//
// ConfirmAction {
// actionId: "forget-network:" + ssid
// armText: "Forget…" // default: verb + ellipsis
// confirmText: "Forget it"
// cancelText: "Keep" // the house cancel word
// enabled: !Service.busy
// onConfirmed: Service.forget(ssid)
// }
//
// The armed state is readable (`armed`) so a row can swap its detail line for
// the consequence-naming sentence while armed -- naming what is lost is the
// caller's half of the contract; this component only guarantees the two
// presses happen and the wrong button cannot be the easy one.
import QtQuick
import qs.config
import qs.services
Row {
id: root
property string actionId: ""
property string armText: "Remove…"
property string confirmText: "Remove it"
property string cancelText: "Keep"
property bool enabled: true
signal confirmed
signal armedChanged2
readonly property bool armed: root.actionId !== ""
&& ShellState.armedConfirm === root.actionId
spacing: 7
function disarm(): void {
if (root.armed)
ShellState.armedConfirm = "";
}
// Leaving the page, or the row vanishing under the armed state, must not
// leave a stale token claiming some other future row's identity. Rows more
// often hide than unload (an override pill, a conditional card), so the
// visibility guard matters as much as the destruction one.
Component.onDestruction: root.disarm()
onVisibleChanged: if (!root.visible) root.disarm()
SettingsButton {
visible: !root.armed
enabled: root.enabled
text: root.armText
onClicked: ShellState.armedConfirm = root.actionId
}
SettingsButton {
visible: root.armed
enabled: root.enabled
text: root.cancelText
onClicked: root.disarm()
}
SettingsButton {
visible: root.armed
enabled: root.enabled
tone: "danger"
text: root.confirmText
onClicked: {
root.disarm();
root.confirmed();
}
}
}
@@ -0,0 +1,355 @@
// The facts about one connection that otherwise need a terminal, and the two
// settings that change them.
//
// IP address, gateway, DNS and MAC are the four things people leave this app
// for, and they were the reason the Connections page still pointed at GNOME.
// The facts above are what is on the wire; the editors below are what the
// PROFILE asks for, which is a different question -- a static address that has
// not been applied yet is in the second and not the first, and a component that
// showed only the first would look like it had forgotten what was typed.
//
// Nothing here is ever a secret. panama-network's `details` verb returns
// addresses only -- no PSK, no enterprise password -- so this component can be
// shown for any connection without deciding what is safe to draw.
//
// Nothing applies until Apply. A half-typed address is a draft, not a broken
// network: committing per keystroke would take the connection down somewhere
// around the second octet. Same proxy-draft shape ConnectivityPage uses -- each
// field starts as a binding to the profile and stops being one at the first
// edit, so a reply landing mid-edit cannot empty the box being typed into.
//
// The values are set in the interface face with tabular figures rather than a
// monospaced one. Theme bans monospaced text outright (fontMono is the icon
// face, not a text face), and an address only needs its digits to line up.
import QtQuick
import qs.config
import qs.services
Column {
id: root
// { ip4, gateway, dns: [], mac, macRandomized, metered, ip4Method … } as
// the helper reports it, or null while the read has not come back. Null is
// NOT "no address": the component says it is still reading rather than
// claiming an answer.
property var details: null
// The profile these facts belong to. Empty means "facts only" -- there is
// nothing to write to, so the editors do not appear. Every caller that has
// a connection name should pass it.
property string connection: ""
// Editing needs both a name to write to and an answer to edit from.
readonly property bool editable: root.connection !== "" && !!root.details
// [{ key, value, note }] -- only the facts that actually have a value, so a
// connection with no gateway shows three rows rather than a blank one.
readonly property var facts: {
const source = root.details;
if (!source)
return [];
const rows = [];
const ip4 = String(source.ip4 ?? "");
if (ip4 !== "")
rows.push({ key: "IPv4 address", value: ip4, note: "" });
const ip6 = String(source.ip6 ?? "");
if (ip6 !== "")
rows.push({ key: "IPv6 address", value: ip6, note: "" });
const gateway = String(source.gateway ?? "");
if (gateway !== "")
rows.push({ key: "Gateway", value: gateway, note: "" });
const dns = Array.isArray(source.dns)
? source.dns.map(entry => String(entry)).filter(entry => entry !== "")
: [];
if (dns.length > 0)
rows.push({
key: dns.length === 1 ? "DNS" : "DNS servers",
value: dns.join(" · "),
note: ""
});
const mac = String(source.mac ?? "");
if (mac !== "")
rows.push({
key: "MAC address",
value: mac,
note: source.macRandomized === true ? "randomized" : ""
});
return rows;
}
width: parent ? parent.width : 620
spacing: 5
topPadding: 8
bottomPadding: 10
Repeater {
model: root.facts
Item {
id: fact
required property var modelData
width: parent.width
implicitHeight: Math.max(key.implicitHeight, value.implicitHeight)
Text {
id: key
anchors.left: parent.left
anchors.top: parent.top
width: 132
text: String(fact.modelData.key ?? "")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
id: value
anchors.left: key.right
anchors.right: parent.right
anchors.top: parent.top
text: String(fact.modelData.value ?? "")
+ (String(fact.modelData.note ?? "") !== ""
? " · " + String(fact.modelData.note) : "")
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
}
}
}
// Two honest empty states, and they say different things. "Still reading"
// is not "no address", and a page that renders the first as the second is
// how a working connection comes to look broken for half a second.
Text {
width: parent.width
visible: !root.details
text: "Reading this connection's addresses…"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: !!root.details && root.facts.length === 0
text: "NetworkManager reports no addresses for this connection."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// The helper's own aside -- chiefly "this takes effect on the next
// reconnect" after a MAC randomization change. It belongs to the answer,
// so it is shown with the answer rather than guessed at by the page.
Text {
width: parent.width
visible: String(root.details?.note ?? "") !== ""
topPadding: 4
text: String(root.details?.note ?? "")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// ── Metered ─────────────────────────────────────────────────────────────
//
// Three states in NetworkManager, two in this switch, and the difference is
// said rather than hidden: "automatic" is NetworkManager deciding from what
// the network told it, which is a guess, and the detail line says so while
// it is the state in force.
SwitchRow {
width: parent.width
visible: root.editable
label: "Metered connection"
detail: String(root.details?.metered ?? "auto") === "auto"
? "NetworkManager is deciding for itself. Turn this on to hold updates and large downloads back until you are somewhere unmetered."
: "Updates and large downloads wait until you are somewhere unmetered"
checked: String(root.details?.metered ?? "auto") === "yes"
enabled: !NetworkTools.busy
onToggled: value => NetworkTools.setMetered(root.connection, value ? "yes" : "no")
}
// ── Addressing, one stack at a time ─────────────────────────────────────
//
// A Repeater over the two families rather than two hand-written copies: the
// drafts, the validation and the Apply are identical, and the only things
// that differ are the property prefix and what an address looks like.
//
// Each stack applies on its own. The helper writes a whole stack in one
// nmcli call and reactivates the connection afterwards, and NetworkTools
// runs one mutation at a time -- so a single Apply for both would silently
// drop one of them.
Repeater {
model: [
{
family: "4",
label: "IPv4",
addressHint: "192.168.1.50/24",
gatewayHint: "192.168.1.1",
dnsHint: "1.1.1.1, 9.9.9.9"
},
{
family: "6",
label: "IPv6",
addressHint: "fd00::42/64",
gatewayHint: "fd00::1",
dnsHint: "2606:4700:4700::1111"
}
]
Column {
id: stack
required property var modelData
readonly property bool six: String(stack.modelData.family) === "6"
// What the profile says now. Bound, so an Apply that succeeds
// reseeds every field nobody has touched.
readonly property string profileMethod:
String((stack.six ? root.details?.ip6Method : root.details?.ip4Method) ?? "")
=== "manual" ? "manual" : "auto"
readonly property string profileAddress: {
const list = (stack.six ? root.details?.ip6Addresses : root.details?.ip4Addresses) ?? [];
return list.length > 0 ? String(list[0]) : "";
}
readonly property string profileGateway:
String((stack.six ? root.details?.ip6Gateway : root.details?.ip4Gateway) ?? "")
readonly property string profileDns: {
const list = (stack.six ? root.details?.ip6Dns : root.details?.ip4Dns) ?? [];
return list.map(entry => String(entry)).join(", ");
}
// The drafts. Bindings until the first edit, and the user's after
// it -- see the header.
property string draftMethod: stack.profileMethod
property string draftAddress: stack.profileAddress
property string draftGateway: stack.profileGateway
property string draftDns: stack.profileDns
// The helper validates properly and is the authority. This is the
// same shape one step earlier, so Apply is dark rather than a round
// trip that comes back refused.
readonly property bool addressValid: stack.six
? (stack.draftAddress.indexOf(":") >= 0
&& /^[0-9A-Fa-f:]{2,45}\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/.test(stack.draftAddress))
: /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/.test(stack.draftAddress)
readonly property bool dirty: stack.draftMethod !== stack.profileMethod
|| (stack.draftMethod === "manual"
&& (stack.draftAddress !== stack.profileAddress
|| stack.draftGateway !== stack.profileGateway
|| stack.draftDns !== stack.profileDns))
function apply(): void {
if (root.connection === "")
return;
if (stack.draftMethod === "auto") {
NetworkTools.setIpAuto(root.connection, stack.modelData.family);
return;
}
if (!stack.addressValid)
return;
NetworkTools.setIpManual(root.connection, stack.modelData.family,
stack.draftAddress.trim(),
stack.draftGateway.trim(),
stack.draftDns.trim());
}
width: parent.width
visible: root.editable
OptionPickerRow {
width: parent.width
label: String(stack.modelData.label)
detail: stack.profileMethod === "manual"
? "This connection asks for an address you chose"
: "This connection takes whatever the network hands it"
enabled: !NetworkTools.busy
options: [
{
value: "auto",
label: "Automatic",
detail: stack.six
? "Router advertisements and DHCPv6, as the network offers them"
: "DHCP, as the network offers it"
},
{
value: "manual",
label: "Manual",
detail: "An address, gateway and nameservers you enter"
}
]
current: stack.draftMethod
onPicked: value => stack.draftMethod = String(value)
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "Address / prefix"
detail: stack.draftAddress !== "" && !stack.addressValid
? "Not an address yet — it needs a prefix, like " + stack.modelData.addressHint
: "The address this machine takes on the network, with its prefix length"
placeholder: String(stack.modelData.addressHint)
text: stack.draftAddress
enabled: !NetworkTools.busy
onAccepted: value => stack.draftAddress = value.trim()
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "Gateway"
detail: "The router traffic leaves through. Leave it empty on a segment with no way out."
placeholder: String(stack.modelData.gatewayHint)
text: stack.draftGateway
enabled: !NetworkTools.busy
onAccepted: value => stack.draftGateway = value.trim()
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "DNS"
detail: "Nameservers, separated by commas. These replace the ones the network hands out rather than joining them."
placeholder: String(stack.modelData.dnsHint)
text: stack.draftDns
enabled: !NetworkTools.busy
onAccepted: value => stack.draftDns = value.trim()
}
ActionRow {
width: parent.width
visible: stack.dirty
label: "Apply " + String(stack.modelData.label)
detail: {
if (stack.draftMethod === "manual" && !stack.addressValid)
return "Fill in an address with a prefix first — nothing is written until this looks like an address.";
if (stack.draftMethod === "auto")
return "Clears the static address and takes what the network offers. This connection reconnects.";
return "Writes these to this connection only, and reconnects it.";
}
action: NetworkTools.busy ? "Working…" : "Apply"
enabled: !NetworkTools.busy
&& (stack.draftMethod === "auto" || stack.addressValid)
divider: !stack.six
onTriggered: stack.apply()
}
}
}
}
@@ -1,10 +1,18 @@
// Connections — wired, Wi-Fi, and Bluetooth. // Connections — the whole network, not the half that was easy.
// //
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes // This page used to end in a card headed "Owned by Fedora" with two doors back
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and // to GNOME's panels: one for hidden and enterprise networks, one for VPN and
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was // proxies. Everything behind those doors now lives here, so the card is gone.
// the founding requirement for this desktop: never having to drop to a terminal //
// to join a network. // Two mechanisms, deliberately kept apart:
//
// * Wi-Fi and Bluetooth state -- scanning, joining, pairing, the radio
// switches -- go through Quickshell.Networking and Quickshell.Bluetooth,
// which speak to NetworkManager and BlueZ over DBus. Connectivity.qml is
// pinned shell-out-free and stays that way.
// * Everything NetworkManager exposes only through nmcli -- per-connection
// addresses, forgetting a profile, MAC randomization, VPN import, hotspot,
// enterprise join -- goes through NetworkTools.qml and scripts/panama-network.
// //
// Scanning follows this page being on screen. Wi-Fi scanning and especially // Scanning follows this page being on screen. Wi-Fi scanning and especially
// Bluetooth discovery hold the radio, and doing either for a list nobody is // Bluetooth discovery hold the radio, and doing either for a list nobody is
@@ -19,26 +27,132 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
objectName: "connectivity"
title: "Connections" title: "Connections"
lede: Connectivity.activeNetwork lede: {
? "Connected to " + Connectivity.activeNetwork.name const wifi = Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "";
: "Wi-Fi, Bluetooth, and the things Fedora owns." if (wifi !== "" && Connectivity.wiredOn)
return "On " + wifi + " and wired.";
if (wifi !== "")
return "On " + wifi + ".";
if (Connectivity.wiredOn)
return "Wired.";
return "Wi-Fi, Bluetooth, VPN, and what this machine can reach.";
}
// Drive the scanners only while this page is the one being shown. // The wired connection's own drawer, and the two inline forms that are only
// open while someone is filling them in.
property bool wiredOpen: false
property bool hotspotOpen: false
property string hotspotName: ""
property bool importOpen: false
property string importPath: ""
// The saved list, folded at the house cap. Sorted by the helper with the
// active and autoconnecting profiles first, so a slice keeps the ones worth
// seeing and folds the tail.
property bool savedShowAll: false
readonly property int savedCap: 6
readonly property var savedShown: {
const list = NetworkTools.savedConnections;
if (root.savedShowAll || list.length <= root.savedCap)
return list;
return list.slice(0, root.savedCap);
}
readonly property int savedHidden:
NetworkTools.savedConnections.length - root.savedShown.length
// The proxy dropdown and its address, page-local until they add up to a
// whole setting.
//
// A manual proxy is a host AND a port -- GNOME ignores a proxy whose host
// is empty or whose port is 0 -- so engaging "manual" before an address
// exists produces a mode that says it is proxying and is not. The dropdown
// therefore leads the applied mode: picking Manual reveals the fields, and
// the proxy is written on the edit that completes the pair, whichever of
// the two that is. Committing on the first field instead would have to
// guess at the other one; requiring the other to be stored first, which is
// what this page used to do, meant neither could ever be first.
//
// The three start as bindings and stay bound until the first edit, which is
// what carries the helper's opening read into a page that was built before
// it answered. After that they are the user's, and nothing re-seeds them:
// a reply landing mid-edit must not move the dropdown or empty the field
// somebody is typing into.
property string proxyChoice: NetworkTools.proxyMode
property string draftProxyHost: NetworkTools.proxyHost
property string draftProxyPort: NetworkTools.proxyPort
// "host", "port", "both" or "" -- which half of the pair is still missing.
readonly property string proxyMissing: {
const host = root.draftProxyHost.trim() === "";
const port = root.draftProxyPort.trim() === "";
if (host && port)
return "both";
if (host)
return "host";
if (port)
return "port";
return "";
}
// Called after either field is edited. Writes only a whole address, so a
// half-filled form leaves the proxy exactly as it was.
function commitProxyManual(): void {
if (root.proxyMissing !== "")
return;
NetworkTools.setProxyManual(root.draftProxyHost.trim(), root.draftProxyPort.trim());
}
readonly property string wiredConnection:
String(Connectivity.wiredDevice?.network?.name ?? "")
readonly property string activeWifi:
Connectivity.activeNetwork ? Connectivity.activeNetwork.name : ""
// Drive the scanners and the helper only while this page is the one being
// shown. Both cost radio time or nmcli invocations for a list nobody is
// reading.
Component.onCompleted: { Component.onCompleted: {
Connectivity.active = true; Connectivity.active = true;
NetworkTools.active = true;
if (!WifiShare.scanned) if (!WifiShare.scanned)
WifiShare.refresh(); WifiShare.refresh();
if (!Vpn.scanned)
Vpn.refresh();
} }
Component.onDestruction: Connectivity.active = false Component.onDestruction: {
Connectivity.active = false;
NetworkTools.active = false;
}
// Addresses and the MAC in use both change with the connection, so the
// cached details for a network that just came up are stale the moment it
// does.
onActiveWifiChanged: {
if (root.activeWifi !== "")
NetworkTools.refreshDetails(root.activeWifi);
}
onWiredConnectionChanged: {
if (root.wiredConnection !== "")
NetworkTools.refreshDetails(root.wiredConnection);
}
ErrorRow {
label: "The network needs attention"
message: NetworkTools.lastError
}
// ── Wired ────────────────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Wired" title: "Wired"
visible: Connectivity.wiredDevice !== null visible: Connectivity.wiredDevice !== null
SwitchRow { SettingRow {
label: "Ethernet" width: parent.width
label: root.wiredConnection !== "" ? root.wiredConnection : "Ethernet"
// Three states worth telling apart: on, off but plugged in, and // Three states worth telling apart: on, off but plugged in, and
// nothing in the socket. "Not connected" covered all three and // nothing in the socket. "Not connected" covered all three and
// explained none of them. // explained none of them.
@@ -54,12 +168,44 @@ SettingsPage {
// that blamed the hardware for what it had just done itself. // that blamed the hardware for what it had just done itself.
return device.name + " · off"; return device.name + " · off";
} }
controlWidth: 78
divider: false
activatable: root.wiredConnection !== "" && Connectivity.wiredOn
onActivated: root.wiredOpen = !root.wiredOpen
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wiredOn checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable enabled: Connectivity.wiredAvailable
divider: false
onToggled: value => Connectivity.setWired(value) onToggled: value => Connectivity.setWired(value)
} }
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.wiredConnection !== "" && Connectivity.wiredOn
text: root.wiredOpen ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
} }
}
}
ConnectionDetails {
width: parent.width
visible: root.wiredOpen && Connectivity.wiredOn
connection: root.wiredConnection
details: root.wiredConnection !== ""
? NetworkTools.detailsFor(root.wiredConnection) : null
}
}
// ── Wi-Fi ────────────────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Wi-Fi" title: "Wi-Fi"
@@ -79,7 +225,7 @@ SettingsPage {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wifiEnabled checked: Connectivity.wifiEnabled
enabled: Connectivity.wifiAvailable enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value onToggled: value => Connectivity.setWifiEnabled(value)
} }
} }
@@ -87,71 +233,269 @@ SettingsPage {
width: parent.width width: parent.width
visible: Connectivity.wifiEnabled visible: Connectivity.wifiEnabled
} }
}
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The // ── Hotspot ──────────────────────────────────────────────────────────
// alternative is reading a passphrase out loud.
// //
// The image holds the password in machine-readable form, so it is generated // One radio cannot be a client and an access point at the same time, so
// on demand rather than up front, and the helper writes it to tmpfs under // starting this drops whatever network the machine is on. Said before it
// XDG_RUNTIME_DIR instead of anywhere persistent. // happens rather than discovered when the browser stops loading.
SettingsCard {
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
title: "Share a network"
subtitle: WifiShare.sharing !== ""
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
: "Shows a QR code a phone can scan to join, without reading the password out."
Repeater {
model: WifiShare.shareable
ActionRow { ActionRow {
id: shareRow width: parent.width
visible: Connectivity.wifiEnabled && !NetworkTools.hotspotActive
label: "Hotspot"
detail: "Share this machine's connection over Wi-Fi"
action: root.hotspotOpen ? "Cancel" : "Start hotspot…"
enabled: !NetworkTools.busy
divider: root.hotspotOpen
onTriggered: {
root.hotspotOpen = !root.hotspotOpen;
root.hotspotName = "";
}
}
Column {
width: parent.width
visible: root.hotspotOpen && !NetworkTools.hotspotActive
TextFieldRow {
width: parent.width
label: "Network name"
detail: "What the hotspot calls itself to phones and laptops nearby"
placeholder: "panama-hotspot"
text: root.hotspotName
enabled: !NetworkTools.busy
onAccepted: value => root.hotspotName = value
}
ActionRow {
width: parent.width
label: "Start the hotspot"
detail: Connectivity.activeNetwork
? "This machine leaves " + Connectivity.activeNetwork.name
+ " while the hotspot runs — one radio cannot do both."
: "NetworkManager makes up a password and shows it once."
action: NetworkTools.busy ? "Starting…" : "Start"
enabled: root.hotspotName.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.startHotspot(root.hotspotName.trim());
root.hotspotOpen = false;
}
}
}
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Hotspot is running"
detail: "Phones and laptops nearby can see this network and join it"
value: NetworkTools.hotspotSsid
}
// Shown once, on purpose. NetworkManager keeps the passphrase; this page
// never stores it, so leaving this screen means asking NetworkManager
// again rather than reading it back from Panama.
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive && NetworkTools.hotspotPassword !== ""
label: "Password"
detail: "Shown once. Panama does not keep a copy — write it down or let someone type it in now."
value: NetworkTools.hotspotPassword
}
ActionRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Stop the hotspot"
detail: "Anything connected through this machine loses its connection"
action: "Stop"
enabled: !NetworkTools.busy
divider: false
onTriggered: NetworkTools.stopHotspot()
}
}
// ── Saved networks ───────────────────────────────────────────────────────
//
// The list above is what is nearby. This is what this machine REMEMBERS,
// which is a different set and the more useful one to tidy: a profile you
// want rid of is invisible in a scan-driven list until you are standing
// next to it, which is exactly when you are least able to deal with it.
SettingsCard {
title: "Saved networks"
visible: NetworkTools.savedConnections.length > 0
subtitle: NetworkTools.savedConnections.length
+ (NetworkTools.savedConnections.length === 1 ? " profile" : " profiles")
+ " NetworkManager holds, including the ones nowhere near you."
Repeater {
model: root.savedShown
delegate: SettingRow {
id: savedRow
required property var modelData required property var modelData
required property int index required property int index
label: shareRow.modelData.ssid readonly property string name: String(savedRow.modelData.name ?? "")
detail: WifiShare.sharing === shareRow.modelData.name readonly property bool wireless: savedRow.modelData.wifi === true
? "Showing a code below — anyone who can see the screen can join"
: "Saved network"
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
onTriggered: WifiShare.sharing === shareRow.modelData.name
? WifiShare.stopSharing()
: WifiShare.share(shareRow.modelData.name)
}
}
// Drawn at its natural size on a white plate: a QR code inverted or
// tinted to match a dark theme is unreliable to scan, and this one has
// exactly one job.
Item {
width: parent.width width: parent.width
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== "" label: savedRow.name
implicitHeight: visible ? plate.height + 20 : 0 // Armed, the row stops describing the profile and names what
// the confirming press costs. The saved passphrase goes with
// the profile, and nothing else on this page says so.
detail: {
if (forgetSaved.armed)
return "This deletes the saved profile and its password. Rejoining "
+ savedRow.name + " means typing it again.";
const bits = [];
if (savedRow.modelData.active === true)
bits.push("Connected");
else if (savedRow.wireless)
bits.push(savedRow.modelData.inRange === true ? "In range" : "Out of range");
else
bits.push(String(savedRow.modelData.type ?? ""));
bits.push(savedRow.modelData.autoconnect === true
? "autoconnects" : "never autoconnects");
return bits.join(" · ");
}
value: savedRow.modelData.active === true ? "this one" : ""
controlWidth: 200
divider: savedRow.index < root.savedShown.length - 1 || root.savedHidden > 0
|| root.savedShowAll
Rectangle { ConfirmAction {
id: plate id: forgetSaved
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
Image { anchors.right: parent.right
anchors.centerIn: parent anchors.verticalCenter: parent.verticalCenter
width: 184 visible: savedRow.modelData.active !== true
height: 184 actionId: "forget-saved-network:" + savedRow.name
smooth: false armText: "Forget…"
fillMode: Image.PreserveAspectFit confirmText: "Forget it"
cache: false enabled: !NetworkTools.busy
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : "" onConfirmed: NetworkTools.forget(savedRow.name)
}
}
}
SettingRow {
width: parent.width
visible: root.savedHidden > 0
|| (root.savedShowAll && NetworkTools.savedConnections.length > root.savedCap)
label: root.savedShowAll
? "Show fewer"
: root.savedHidden + (root.savedHidden === 1 ? " more" : " more")
detail: root.savedShowAll
? ""
: "Folded to keep the list short — the ones you use least are at the bottom"
activatable: true
divider: false
onActivated: root.savedShowAll = !root.savedShowAll
}
}
// ── VPN ──────────────────────────────────────────────────────────────────
SettingsCard {
title: "VPN"
subtitle: "WireGuard and OpenVPN profiles NetworkManager holds for you."
ErrorRow {
width: parent.width
label: "The last VPN action did not finish"
message: Vpn.lastError
divider: true
}
Repeater {
model: Vpn.connections
delegate: SwitchRow {
id: vpnRow
required property var modelData
required property int index
width: parent.width
label: String(vpnRow.modelData.name ?? "")
detail: String(vpnRow.modelData.kind ?? "VPN")
+ (vpnRow.modelData.active === true ? " · connected" : "")
checked: vpnRow.modelData.active === true
enabled: !Vpn.busy
onToggled: value => Vpn.setActive(String(vpnRow.modelData.uuid ?? ""), value)
}
}
TextRow {
width: parent.width
visible: Vpn.scanned && Vpn.connections.length === 0
label: "No VPNs configured"
detail: "Import a WireGuard or OpenVPN file to add one"
value: ""
}
// Names the profile that appeared rather than saying "done". An import
// that succeeds under a name you did not choose is otherwise invisible
// until you go looking for it in the list.
TextRow {
width: parent.width
visible: NetworkTools.lastImport !== ""
label: "Imported " + NetworkTools.lastImport
detail: "It is switched off until you turn it on above"
value: ""
}
ActionRow {
width: parent.width
label: "Import a VPN"
detail: "A .conf file from WireGuard, or a .ovpn file from OpenVPN"
action: root.importOpen ? "Cancel" : "Import…"
enabled: !NetworkTools.busy
divider: root.importOpen
onTriggered: {
root.importOpen = !root.importOpen;
root.importPath = "";
}
}
// A typed path rather than a file chooser this phase. A chooser is the
// better answer and is worth doing properly; a typed path is worth far
// more than the door back to GNOME it replaces.
Column {
width: parent.width
visible: root.importOpen
TextFieldRow {
width: parent.width
label: "File"
detail: "The full path to the profile you were given"
placeholder: "~/Downloads/work.ovpn"
text: root.importPath
enabled: !NetworkTools.busy
onAccepted: value => root.importPath = value
}
ActionRow {
width: parent.width
label: "Import this profile"
detail: "NetworkManager reads it and adds a profile. Nothing connects until you switch it on."
action: NetworkTools.busy ? "Importing…" : "Import"
enabled: root.importPath.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.importVpn(root.importPath.trim());
root.importOpen = false;
} }
} }
} }
} }
// ── Bluetooth ────────────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Bluetooth" title: "Bluetooth"
visible: Connectivity.adapter !== null visible: Connectivity.adapter !== null
@@ -159,54 +503,140 @@ SettingsPage {
SettingRow { SettingRow {
label: "Bluetooth" label: "Bluetooth"
detail: Connectivity.adapter detail: Connectivity.bluetoothAvailable
? (Connectivity.adapter.enabled ? "On" : "Off") ? (Connectivity.bluetoothEnabled ? "On" : "Off")
: "Unavailable" : "Unavailable"
controlWidth: 48 controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled) divider: Connectivity.bluetoothEnabled
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled) checked: Connectivity.bluetoothEnabled
enabled: Connectivity.adapter !== null enabled: Connectivity.bluetoothAvailable
onToggled: value => { onToggled: value => Connectivity.setBluetoothEnabled(value)
if (Connectivity.adapter)
Connectivity.adapter.enabled = value;
}
} }
} }
BluetoothPanel { BluetoothPanel {
width: parent.width width: parent.width
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled) visible: Connectivity.bluetoothEnabled
} }
} }
// ── Radios and proxy ─────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Owned by Fedora" title: "Radios and proxy"
// These two panels drive NetworkManager over D-Bus, which is why they subtitle: "The two settings that apply to every connection at once."
// work in this session when most GNOME panels do not. They are split
// because GNOME splits them: "network" is wired, VPN and proxies, and
// does not contain Wi-Fi -- the one panel used to point everyone
// there, so the road to a hidden SSID or eduroam ended on a page
// without Wi-Fi on it.
subtitle: "Wired, VPN and Wi-Fi connection editing stay with GNOME's panels, which drive the same NetworkManager this page reads. Printers and online accounts have their own pages here."
ActionRow { // A hardware kill switch cannot be overridden from software, so the
label: "Wi-Fi networks" // switch says so rather than moving and having nothing happen.
detail: "Hidden networks, enterprise (802.1X) logins, and per-network settings" SwitchRow {
action: "Open" width: parent.width
onTriggered: SystemSettings.openGnomePanel("wifi") label: "Airplane mode"
detail: NetworkTools.airplaneHardBlocked
? "A switch on this machine is holding the radios off. Software cannot turn them back on."
: "Turns Wi-Fi and Bluetooth off together — the same switch the keyboard's airplane key throws"
checked: NetworkTools.airplaneOn
enabled: !NetworkTools.busy && !NetworkTools.airplaneHardBlocked
onToggled: value => NetworkTools.setAirplane(value)
} }
ActionRow { OptionPickerRow {
label: "Network connections" width: parent.width
detail: "VPN, proxies, and wired connection settings" label: "Network proxy"
action: "Open" detail: {
if (root.proxyChoice === "manual" && NetworkTools.proxyMode !== "manual") {
if (root.proxyMissing === "both")
return "Not applied yet — fill in the host and the port below.";
if (root.proxyMissing !== "")
return "Not applied yet — the " + root.proxyMissing + " below is still empty.";
return "Not applied yet.";
}
if (NetworkTools.proxyMode === "none")
return "Applications that honour the system proxy use this. Not every application does.";
return "In use: " + NetworkTools.proxySummary;
}
enabled: !NetworkTools.busy
options: [
{
value: "none",
label: "Off",
detail: "Applications reach the network directly"
},
{
value: "manual",
label: "Manual",
detail: "A host and port you enter, used for http, https and socks alike"
},
{
value: "auto",
label: "Automatic (PAC)",
detail: "A configuration URL decides, per address"
}
]
current: root.proxyChoice
// Off and Automatic mean something the moment they are picked;
// Manual does not, so it only opens the fields. See proxyChoice.
onPicked: value => {
root.proxyChoice = String(value);
if (root.proxyChoice === "manual")
root.commitProxyManual();
else
NetworkTools.setProxyMode(root.proxyChoice);
}
}
Column {
width: parent.width
visible: root.proxyChoice === "manual"
// Host and port are written together, because the proxy is only
// usable as a pair. Each field edits the page's draft; the pair is
// written once both halves are there.
TextFieldRow {
width: parent.width
label: "Proxy host"
detail: root.proxyMissing === "host"
? "Still empty — the proxy applies once this and the port are both set"
: "The machine applications should go through"
placeholder: "proxy.example.com"
text: root.draftProxyHost
enabled: !NetworkTools.busy
onAccepted: value => {
root.draftProxyHost = value.trim();
root.commitProxyManual();
}
}
TextFieldRow {
width: parent.width
label: "Port"
detail: root.proxyMissing === "port"
? "Still empty — the proxy applies once this and the host are both set"
: "The port that proxy listens on"
placeholder: "8080"
text: root.draftProxyPort
enabled: !NetworkTools.busy
divider: false divider: false
onTriggered: SystemSettings.openGnomePanel("network") onAccepted: value => {
root.draftProxyPort = value.trim();
root.commitProxyManual();
}
}
} }
TextFieldRow {
width: parent.width
visible: root.proxyChoice === "auto"
label: "Configuration URL"
detail: "The .pac file whoever runs the network published"
placeholder: "http://example.com/proxy.pac"
text: NetworkTools.proxyPac
enabled: !NetworkTools.busy
divider: false
onAccepted: value => NetworkTools.setProxyPac(value.trim())
}
} }
} }
@@ -16,11 +16,18 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
objectName: "containers" objectName: "containers"
title: "Containers"
lede: "Local services you run for development — what they expose, and what they cost."
// The log reader replaces the page while it is open; SettingsPage owns the
// swap so this file is one page rather than two stacked on a bare Item.
drilledIn: Containers.logTarget !== ""
// "images", "volumes", or empty. Removal never happens on a first press. // "images", "volumes", or empty. Removal never happens on a first press.
property string confirmingPrune: "" property string confirmingPrune: ""
@@ -54,29 +61,19 @@ Item {
}).join(", "); }).join(", ");
} }
Component.onCompleted: Containers.refresh() Component.onCompleted: if (!Containers.scanned) Containers.refresh()
// ── the list ──────────────────────────────────────────────────────────── // ── the list ────────────────────────────────────────────────────────────
SettingsPage { ErrorRow {
anchors.fill: parent
visible: Containers.logTarget === ""
title: "Containers"
lede: "Local services you run for development — what they expose, and what they cost."
TextRow {
visible: Containers.lastError !== ""
label: "That did not work" label: "That did not work"
detail: Containers.lastError message: Containers.lastError
value: ""
divider: false
} }
TextRow { TextRow {
visible: Containers.scanned && !Containers.available visible: Containers.scanned && !Containers.available
label: "podman is not available" label: "podman is not available"
detail: "Nothing here can be shown until podman is installed." detail: "Nothing here can be shown until podman is installed"
value: "" value: ""
divider: false divider: false
} }
@@ -449,11 +446,10 @@ Item {
TextRow { TextRow {
visible: Containers.scanned && Containers.available && Containers.total === 0 visible: Containers.scanned && Containers.available && Containers.total === 0
label: "No containers" label: "No containers"
detail: "Nothing has been created on this machine yet." detail: "Nothing has been created on this machine yet"
value: "" value: ""
divider: false divider: false
} }
}
// ── logs ──────────────────────────────────────────────────────────────── // ── logs ────────────────────────────────────────────────────────────────
// //
@@ -461,9 +457,9 @@ Item {
// own scrollback, and nesting one scrolling view inside another makes the // own scrollback, and nesting one scrolling view inside another makes the
// wheel ambiguous over the region where you most want to use it. // wheel ambiguous over the region where you most want to use it.
drillIn: Component {
Item { Item {
anchors.fill: parent anchors.fill: parent
visible: Containers.logTarget !== ""
Column { Column {
id: logHeader id: logHeader
@@ -555,4 +551,5 @@ Item {
} }
} }
} }
}
} }
@@ -20,7 +20,7 @@ Item {
// A Canvas reads Theme inside onPaint, where nothing records a dependency // A Canvas reads Theme inside onPaint, where nothing records a dependency
// on it, so a palette change would leave the ring drawn in the old warn // on it, so a palette change would leave the ring drawn in the old warn
// tone until the next tick -- or for good, on a countdown sitting still. // tone until the next tick -- or for good, on a countdown sitting still.
// Naming the colour as a property gives the repaint something to watch. // Naming the color as a property gives the repaint something to watch.
readonly property color ringColor: Theme.warn readonly property color ringColor: Theme.warn
onSecondsLeftChanged: ring.requestPaint() onSecondsLeftChanged: ring.requestPaint()
@@ -0,0 +1,276 @@
// The two-step flow behind "+ Add shortcut".
//
// Step one records the chord, because a shortcut whose keys are already taken
// is not worth choosing an action for -- the conflict is reported here, before
// anything is stored. Step two picks what it does, from three fixed
// vocabularies: an installed application, one of the shell's own actions, or
// one of the compositor's window verbs.
//
// Nothing typed here becomes a command. The editor reports an enum kind and a
// target that Keybinds.describeAction() already recognizes; hypr/actions.lua
// resolves that to something runnable through whitelist tables of its own. That
// is the whole reason there is no "run this command" option: settings.json has
// to stay a file it is safe to hand somebody.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
// { chord, kind, target, label }
signal committed(string chord, string kind, string target, string label)
signal canceled
property string chord: ""
property string kind: "app"
property string target: ""
property string targetLabel: ""
// The action already holding a chord somebody just pressed, and the chord
// itself, so the refusal can name both.
property string conflict: ""
property string conflictChord: ""
readonly property bool capturing: root.chord === ""
readonly property bool complete: root.chord !== "" && root.target !== "" && root.targetLabel !== ""
function reset(): void {
root.chord = "";
root.kind = "app";
root.target = "";
root.targetLabel = "";
root.conflict = "";
root.conflictChord = "";
appSearch.text = "";
}
width: parent ? parent.width : 620
spacing: 0
SectionLabel {
text: root.capturing ? "New shortcut · press the keys" : "New shortcut · pick what it does"
count: root.chord === "" ? "" : root.chord
}
// ── Step one: the chord ─────────────────────────────────────────────────
Item {
width: parent.width
height: root.capturing ? 56 : 0
visible: root.capturing
clip: true
ShortcutCapture {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 300
height: 32
focus: root.visible && root.capturing
message: root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict
onCaptured: chord => {
const taken = Keybinds.boundTo(chord, "");
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
if (Keybinds.isCustomChord(chord)) {
root.conflict = "one of your own shortcuts";
root.conflictChord = chord;
return;
}
root.conflict = "";
root.chord = chord;
}
onCanceled: root.canceled()
}
Text {
anchors.left: parent.left
anchors.leftMargin: 312
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "A modifier is required. Esc cancels. A chord another action holds is reported, never taken."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
// ── Step two: the action ────────────────────────────────────────────────
Column {
width: parent.width
visible: !root.capturing
spacing: 0
ChoiceGrid {
width: parent.width
label: "What it does"
detail: "Three kinds, and only three: Panama resolves the name you pick when you press the keys, so no shortcut ever stores a command"
current: root.kind
options: [
{ value: "app", label: "Launch an application" },
{ value: "shell", label: "Shell action" },
{ value: "window", label: "Window & workspace" }
]
onPicked: value => {
root.kind = String(value);
root.target = "";
root.targetLabel = "";
}
}
// Applications are searched rather than listed: a normal machine has
// several hundred desktop entries, and a flow of pills for all of them
// is a page nobody can read.
Column {
width: parent.width
visible: root.kind === "app"
spacing: 0
SearchField {
id: appSearch
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.appMatches
SettingRow {
id: candidate
required property var modelData
required property int index
label: String(candidate.modelData.name ?? "")
detail: String(candidate.modelData.id ?? "")
value: candidate.modelData.id === root.target ? "Chosen" : ""
controlWidth: 96
divider: candidate.index < root.appMatches.length - 1
activatable: true
onActivated: {
root.target = String(candidate.modelData.id ?? "");
root.targetLabel = "Launch " + String(candidate.modelData.name ?? "");
}
}
}
Text {
width: parent.width
visible: root.appMatches.length === 0
text: appSearch.text.trim() === ""
? "Type to find an application."
: "Nothing installed matches that."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
topPadding: 10
bottomPadding: 10
}
}
ChoiceGrid {
width: parent.width
visible: root.kind === "shell"
label: "Shell action"
detail: "The shell's own surfaces, each one something Panama already answers over IPC"
current: root.target
options: root.shellOptions
divider: false
onPicked: value => {
root.target = String(value);
root.targetLabel = Keybinds.shellActionLabel(root.target);
}
}
ChoiceGrid {
width: parent.width
visible: root.kind === "window"
label: "Window & workspace"
detail: "Compositor verbs: the first three act on the focused window, the rest go to a workspace"
current: root.target
options: root.windowOptions
divider: false
onPicked: value => {
root.target = String(value);
root.targetLabel = Keybinds.windowActionLabel(root.target);
}
}
Item {
width: parent.width
height: 50
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
tone: "accent"
text: "Add it"
enabled: root.complete && !Keybinds.reloading
onClicked: root.committed(root.chord, root.kind, root.target, root.targetLabel)
}
SettingsButton {
text: "Cancel"
onClicked: root.canceled()
}
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 200
horizontalAlignment: Text.AlignRight
text: root.complete
? root.targetLabel + " · saves, then the compositor reloads — same as rebinding"
: "Pick what the shortcut does."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
// ── The vocabularies ────────────────────────────────────────────────────
// Shell and window targets come from Keybinds, which is the single QML
// authority on what hypr/actions.lua will resolve. Listing them here would
// be a second opinion.
readonly property var shellOptions:
Keybinds.shellActions.map(action => ({ value: action.target, label: action.label }))
readonly property var windowOptions:
Keybinds.windowActions.map(action => ({ value: action.target, label: action.label }))
readonly property var appMatches: {
const needle = appSearch.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
}
@@ -0,0 +1,94 @@
// One shortcut the user invented, in the pinned Custom group.
//
// ShortcutRow renders a bind the compositor reported; this renders a stored
// `customBinds` entry, which is a different thing and deliberately not the same
// component. A custom bind has an action to describe (the compositor reports
// every Lua bind as "__lua" plus a bytecode offset, so the description has to
// come from the entry), it is removable, and it is never "overridden" -- a
// rebind rewrites the entry in place rather than adding to the override map.
//
// The row reports and never decides: conflicts, the write and the reload all
// belong to Keybinds, and the page owns the one capture at a time.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property var entry
property bool capturing: false
// Shown inside the capture field -- the page puts a refused chord here.
property string message: ""
signal rebindRequested
signal removeRequested
signal captured(string chord)
signal canceled
readonly property string chord: String(root.entry?.chord ?? "")
readonly property string action: Keybinds.describeAction(root.entry)
// A stored entry is an intention; the keymap is the fact. hypr/keybinds.lua
// skips an entry whose action does not resolve or whose chord a shipped
// bind already holds, and a shortcut that quietly does nothing is exactly
// what a settings page must not draw as working.
readonly property bool live: root.action !== "" && Keybinds.customBindApplied(root.entry)
label: String(root.entry?.label ?? "")
detail: root.action === ""
? "This shortcut names an action Panama no longer has — remove it"
: (root.live
? root.action
: root.action + " · not answering yet — the compositor reloads on save")
labelColor: root.action === "" ? Theme.danger : Theme.fg
controlWidth: root.capturing ? 250 : (removeConfirm.armed ? 330 : 250)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !root.capturing
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Rebind"
enabled: !Keybinds.reloading
onClicked: root.rebindRequested()
}
ConfirmAction {
id: removeConfirm
anchors.verticalCenter: parent.verticalCenter
actionId: "custom-bind-remove:" + root.chord
armText: "Remove…"
confirmText: "Remove it"
enabled: !Keybinds.reloading
onConfirmed: root.removeRequested()
}
KeycapChord {
anchors.verticalCenter: parent.verticalCenter
chord: root.chord
}
}
Loader {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 240
height: 30
active: root.capturing
// Focus has to travel through the Loader for the capture inside it to
// ever see a key press.
focus: root.capturing
sourceComponent: ShortcutCapture {
focus: true
message: root.message
onCaptured: chord => root.captured(chord)
onCanceled: root.canceled()
}
}
}
@@ -1,16 +1,22 @@
// Date & Time. // Date, Time & Region.
// //
// These belong to the machine rather than to Panama, so nothing here is stored // One tab, because they are one question: what does this machine consider
// in Panama's settings file -- it would be a second answer to a question the // local. It used to be two -- Date & Time here, Region & Language one tab over
// system already answers. Timezone and network time are read from and written // -- and the second was mostly a button that opened GNOME.
// to timedatectl directly. 24-hour time is the one presentation choice that
// belongs here rather than on Shell › Bar: it drives the date menu,
// notification timestamps and the lock screen as well as the bar. The rest of
// the bar clock's presentation -- seconds, weekday -- stays with the bar.
// //
// Changing the timezone or network time needs privilege. timedatectl asks // Nothing on this page is stored in Panama's settings file except 24-hour time.
// polkit, and a canceled dialog surfaces as an error rather than as a value // The timezone and network time belong to timedatectl; the language and the
// that appears to have been accepted. // per-category formats belong to localectl. A preference here would be a second
// answer to a question the system already answers, and the two would drift the
// moment anything else changed one of them.
//
// Changing any of it needs privilege. Both helpers ask polkit, and a canceled
// dialog surfaces as an error rather than as a value that appears to have been
// accepted.
//
// The preview at the bottom is rendered by Qt from the *chosen* locales rather
// than described in prose, because "1,234,567.89" settles in one glance what a
// sentence about separators does not.
import QtQuick import QtQuick
import Quickshell import Quickshell
@@ -21,29 +27,42 @@ import qs.modules.clipboard
SettingsPage { SettingsPage {
id: root id: root
title: "Date & Time" objectName: "datetime"
lede: "Timezone and network time, shared with the whole machine."
title: "Date, Time & Region"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: root.nowLine
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
readonly property string nowLine:
Qt.formatDateTime(clock.date, Settings.use24Hour ? "HH:mm" : "h:mm AP")
+ " · " + Qt.formatDate(clock.date, "dddd, MMMM d")
+ (DateTime.timezone === "" ? "" : " · " + DateTime.timezone) + "."
// ── The clock ────────────────────────────────────────────────────────────
// What the system itself says the time is, in the shape `timedatectl
// set-time` accepts, so the manual field starts from the truth rather than
// from Qt's idea of it. Falls back to the shell's own clock on a machine
// whose timedatectl phrases TimeUSec differently.
function seedTime(): string {
const match = /(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/.exec(String(DateTime.localTime ?? ""));
return match
? match[1] + " " + match[2]
: Qt.formatDateTime(clock.date, "yyyy-MM-dd HH:mm");
}
SettingsCard { SettingsCard {
title: "Clock" title: "Clock"
TextRow {
label: "Current time"
detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone
value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP")
}
// Not just the bar's. The same choice reads out in the date menu,
// every notification's timestamp, and the lock screen, so it belongs
// beside the clock the whole machine shares rather than on a page
// about one surface.
ToggleRow { setting: "use24Hour" }
SettingRow { SettingRow {
label: "Set automatically" label: "Set automatically"
detail: DateTime.ntpEnabled detail: DateTime.ntpEnabled
? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize") ? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize")
: "The clock is set by hand" : "The clock is set by hand"
controlWidth: 48 controlWidth: 48
divider: false
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
@@ -53,6 +72,32 @@ SettingsPage {
onToggled: value => DateTime.setNtp(value) onToggled: value => DateTime.setNtp(value)
} }
} }
// Only offered when there is something to do: with a time server in
// charge, setting the clock by hand is refused by timedatectl anyway,
// and a field that always fails is worse than no field.
FieldActionRow {
id: manualClock
visible: !DateTime.ntpEnabled
label: "Set the clock"
detail: "Date and time as YYYY-MM-DD HH:MM"
placeholder: "2026-01-31 09:00"
action: "Set"
fieldWidth: 160
enabled: !DateTime.busy
onSubmitted: value => DateTime.setTime(value)
// Seeded when the row appears rather than bound, so typing is not
// yanked out from under anyone by the next status read.
onVisibleChanged: if (visible && text === "") text = root.seedTime()
}
// Not just the bar's. The same choice reads out in the date menu,
// every notification's timestamp, and the lock screen, so it belongs
// beside the clock the whole machine shares rather than on a page
// about one surface.
ToggleRow { setting: "use24Hour"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -109,6 +154,264 @@ SettingsPage {
subtitle: DateTime.lastError subtitle: DateTime.lastError
} }
// ── Language and formats ─────────────────────────────────────────────────
// Each category may follow the language or override it. An empty override
// is what "Match language" means, and it is what localectl stores: no
// LC_TIME line at all, rather than a copy of LANG that stops tracking it.
function categoryValue(category: string): string {
return String(SystemLocale.categoryValue(category) ?? "");
}
// The locale actually in force for a category, which is what the preview
// must be rendered from.
function effectiveLocale(category: string): string {
const override = root.categoryValue(category);
return override === "" ? SystemLocale.current : override;
}
function localeLabel(value: string): string {
const match = (SystemLocale.locales ?? []).find(entry => entry.value === value);
return match ? match.label : value;
}
// "Match language" first, then everything installed. The first entry has an
// empty value because that is literally what it sets.
readonly property var categoryChoices: [{
value: "",
label: "Match language",
detail: root.localeLabel(SystemLocale.current)
}].concat(SystemLocale.locales ?? [])
// Qt wants "en_US"; localectl deals in "en_US.UTF-8". QLocale tolerates the
// codeset, but the modifier forms do not all round-trip, so it is trimmed.
function qtLocale(value: string): var {
return Qt.locale(String(value ?? "").split(".")[0].split("@")[0]);
}
readonly property var timeLocale: root.qtLocale(root.effectiveLocale("LC_TIME"))
readonly property var numberLocale: root.qtLocale(root.effectiveLocale("LC_NUMERIC"))
readonly property var currencyLocale: root.qtLocale(root.effectiveLocale("LC_MONETARY"))
readonly property var measurementLocale: root.qtLocale(root.effectiveLocale("LC_MEASUREMENT"))
readonly property var weekdayNames: [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
]
// Measurement and paper are chosen together by one row, because glibc ships
// them from the same country and nobody sets one without the other. When
// they have been set apart by hand the row says so rather than hiding it.
readonly property bool paperDiffers:
root.categoryValue("LC_PAPER") !== root.categoryValue("LC_MEASUREMENT")
SettingsCard {
title: "Language & formats"
subtitle: "Changing any of these needs your password, and takes effect for programs started afterwards."
PickerRow {
id: languagePicker
label: "Language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => {
SystemLocale.set(value);
languagePicker.collapse();
}
}
}
PickerRow {
id: timePicker
label: "Dates and times"
detail: "LC_TIME — the order of day and month, and the shape of the clock"
value: SystemLocale.categoryLabel("LC_TIME")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_TIME")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_TIME", value);
timePicker.collapse();
}
}
}
PickerRow {
id: numberPicker
label: "Numbers"
detail: "LC_NUMERIC — the decimal mark and the thousands separator"
value: SystemLocale.categoryLabel("LC_NUMERIC")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_NUMERIC")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_NUMERIC", value);
numberPicker.collapse();
}
}
}
PickerRow {
id: currencyPicker
label: "Currency"
detail: "LC_MONETARY — which symbol, and which side of the number it sits on"
value: SystemLocale.categoryLabel("LC_MONETARY")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_MONETARY")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_MONETARY", value);
currencyPicker.collapse();
}
}
}
PickerRow {
id: measurementPicker
label: "Measurements and paper"
detail: root.paperDiffers
? "LC_MEASUREMENT and LC_PAPER — set apart from each other on this machine; choosing here sets both"
: "LC_MEASUREMENT and LC_PAPER — metric or imperial, and the default page size"
value: SystemLocale.categoryLabel("LC_MEASUREMENT")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_MEASUREMENT")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_MEASUREMENT", value);
SystemLocale.setCategory("LC_PAPER", value);
measurementPicker.collapse();
}
}
}
// A fact, not a switch: glibc ships the first day of the week with the
// date format, and there is no separate thing to set.
TextRow {
label: "First day of the week"
detail: "Comes with the date format — glibc owns it, so this is a readout rather than a choice"
value: root.weekdayNames[root.timeLocale.firstDayOfWeek] ?? ""
}
// ── The preview ──────────────────────────────────────────────────────
Item {
width: parent.width
implicitHeight: previewFrame.implicitHeight + 20
Rectangle {
id: previewFrame
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 6
implicitHeight: previewGrid.implicitHeight + 22
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
Grid {
id: previewGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
columns: previewFrame.width >= 520 ? 2 : 1
columnSpacing: 18
rowSpacing: 5
Repeater {
model: root.previewCells
Row {
required property var modelData
width: (previewGrid.width - (previewGrid.columns - 1) * previewGrid.columnSpacing)
/ previewGrid.columns
spacing: 10
Text {
width: 78
text: modelData.label
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width - 88
text: modelData.value
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
}
}
}
}
}
}
// Paper size is deliberately absent above. Qt reports a locale's
// measurement system and not its page size, and deriving Letter from a
// country list would be Panama inventing an answer glibc already holds
// -- a preview that guesses is worse than a preview that stops.
NotMeasuredRow {
label: "Paper size is not previewed"
because: "Qt can report a locale's measurement system but not its page size, and guessing it from the country would be this page making something up"
divider: false
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "The system did not accept that"
subtitle: SystemLocale.lastError
}
readonly property var previewCells: {
const sample = clock.date;
return [
{ label: "Today", value: sample.toLocaleDateString(root.timeLocale, Locale.LongFormat) },
{ label: "Time", value: sample.toLocaleTimeString(root.timeLocale, Locale.ShortFormat) },
{ label: "Number", value: (1234567.89).toLocaleString(root.numberLocale) },
{ label: "Currency", value: (1234.56).toLocaleCurrencyString(root.currencyLocale) },
{ label: "Units", value: root.measurementLocale.measurementSystem === Locale.MetricSystem
? "Metric — °C, km, kg"
: "Imperial — °F, miles, lb" },
{ label: "Short date", value: sample.toLocaleDateString(root.timeLocale, Locale.ShortFormat) }
];
}
// Bounded so a blank search does not try to lay out six hundred rows. The // Bounded so a blank search does not try to lay out six hundred rows. The
// current zone is always included, so the card never looks empty when the // current zone is always included, so the card never looks empty when the
// field is untouched. // field is untouched.
@@ -1,77 +1,515 @@
import QtQuick import QtQuick
import Quickshell.Io
import qs.config import qs.config
import qs.services import qs.services
// Dictation is an input method, so it lives under Input beside the keyboard — // Dictation is an input method, so it lives under Input beside the keyboard —
// but it listens through whichever device the Sound page selects, so the // but it listens through whichever device the Sound page selects, so the
// microphone card below hands off there rather than duplicating the picker. // microphone card below hands off there rather than duplicating the picker.
//
// Status first. The two questions this page exists to answer are "is it ready"
// and "which keys" -- both were previously spelled as a pair of rows reading
// "Ready / Missing" beside a hardcoded sentence about Super+D. The hotkeys are
// now looked up from the live keymap, so rebinding the dictation key changes
// what this page says instead of quietly making it wrong.
SettingsPage { SettingsPage {
title: "Dictation" id: root
lede: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "Speech to text, on the GPU, once the one-time setup below has run."
SettingsCard { // Chords come from the compositor by matching the descriptions
title: "Dictation" // hypr/keybinds.lua gives the dictation binds. The literals are a fallback
subtitle: Dictation.ready // for the moment before the keymap has loaded, not a second source of truth.
? "Hold Super+D, speak, and release. The words are typed where the cursor is." function chordFor(needle: string, fallback: string): string {
: "The one-time setup fetches a speech server and a ~490 MB model — neither ships with Panama, because both are large and want the network." for (const bind of Keybinds.binds) {
if (String(bind.description).toLowerCase().indexOf(needle) >= 0)
// Status of the two pieces, read from the machine rather than guessed. return String(bind.chord);
TextRow { }
label: "Speech server" return fallback;
detail: Dictation.serverReady
? "Running"
: (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
value: Dictation.imageBuilt ? "Ready" : "Missing"
} }
TextRow { readonly property string holdChord: root.chordFor("dictate (hold to talk)", "Super + D")
label: "Speech model" readonly property string cancelChord: root.chordFor("cancel dictation", "Super + Shift + D")
readonly property int modelMegabytes: Math.round(Dictation.modelBytes / 1048576)
// What the setup is doing, as steps rather than a percentage with no
// subject. Driven entirely by the service's existing phase and progress
// fields -- nothing new is asked of panama-dictate.
readonly property var setupSteps: [
{
number: 1,
title: "Container image",
detail: Dictation.imageBuilt
? "The speech server is on this machine"
: (Dictation.phase === "pulling" ? "Fetching the speech server…" : "About 1 GB, pulled once"),
done: Dictation.imageBuilt,
active: Dictation.phase === "pulling",
progress: -1
},
{
number: 2,
title: "Speech model",
detail: Dictation.modelInstalled detail: Dictation.modelInstalled
? "Kept across rebuilds of the server" ? root.modelMegabytes + " MB, kept across rebuilds of the server"
: "About 490 MB, downloaded once" : (Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0
value: Dictation.modelInstalled ? Math.round(Dictation.modelBytes / 1048576) + " MB" : "Missing" ? "Downloading — " + Math.round(Dictation.downloadedBytes / 1048576)
+ " of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB"
: "About 490 MB, downloaded once"),
done: Dictation.modelInstalled,
active: Dictation.phase === "downloading",
progress: Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0
? Dictation.downloadFraction
: -1
},
{
number: 3,
title: "First transcription",
detail: Dictation.serverReady
? "The speech server is answering"
: "The server starts itself the first time you hold the key",
done: Dictation.serverReady,
active: false,
progress: -1
}
]
// ── The on-page test ────────────────────────────────────────────────────
// panama-dictate types what it heard into whatever has keyboard focus, so
// a test needs no new plumbing: the field below takes focus, the helper
// runs exactly as the hotkey runs it, and the words arrive here. The helper
// is addressed through the path the Dictation service already publishes
// rather than one spelled again in this file.
// True only once the helper has confirmed a recording actually started.
//
// It used to be set on the press, which made it a claim rather than a fact:
// `panama-dictate start` refuses with {"ok":false,"error":"already-recording"}
// when the hotkey is already holding the microphone, and the field said
// "Listening…" over that refusal. Worse, the button then read "Stop and
// type it" -- so the next press stopped the hotkey's recording and typed
// somebody else's words into this test field.
property bool listening: false
// Why the last press did not do what it said, in this page's words.
property string testError: ""
// The helper answers with a code, not a sentence, so the words live here.
// Anything unrecognised is shown as itself rather than swallowed.
readonly property var dictateReasons: ({
"already-recording": "Something is already listening — the dictation hotkey, most likely. Let that finish first.",
"not-recording": "Nothing was listening, so there was nothing to type.",
"not-downloaded": "The speech model has not been downloaded yet.",
"no-image": "The speech server is not installed yet.",
"server-unavailable": "The speech server did not answer.",
"transcribe-failed": "That could not be transcribed.",
"too-short": "That was too short to transcribe.",
"no-speech": "Nothing was said.",
"deliver-failed": "The words could not be typed into the field.",
"unknown-command": "The dictation helper did not understand that."
})
Process {
id: dictateRun
// Which action this run was, so its reply can be read against it.
property string action: ""
stdout: StdioCollector { onStreamFinished: root.absorbDictate(this.text) }
onExited: (exitCode, exitStatus) => Dictation.refresh()
}
// The helper's reply decides what happened. A start that was refused leaves
// this page exactly as it was, with the refusal on screen.
function absorbDictate(text: string): void {
let reply = null;
try {
reply = JSON.parse(text);
} catch (error) {
reply = null;
}
const ok = reply?.ok === true;
root.listening = dictateRun.action === "start" && ok;
if (ok) {
root.testError = "";
return;
}
const code = String(reply?.error ?? "");
if (code === "")
root.testError = "The dictation helper did not answer.";
else
root.testError = root.dictateReasons[code] !== undefined
? String(root.dictateReasons[code])
: code;
}
function runDictate(action: string): void {
if (dictateRun.running)
return;
root.testError = "";
dictateRun.action = action;
dictateRun.command = [Dictation.helper, action];
dictateRun.running = true;
}
title: "Dictation"
lede: "Local speech to text — the audio, the model and the server never leave this machine."
// ── Ready ───────────────────────────────────────────────────────────────
SettingsCard {
visible: Dictation.ready
Item {
width: parent.width
height: 72
Rectangle {
id: readyTile
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 44
height: 44
radius: 14
color: Theme.alpha(Theme.ok, 0.10)
border.width: 1
border.color: Theme.alpha(Theme.ok, 0.35)
Text {
anchors.centerIn: parent
text: "✓"
color: Theme.ok
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge + 4
}
}
Column {
anchors.left: readyTile.right
anchors.leftMargin: 16
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: "Dictation is ready"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Speech model · " + root.modelMegabytes + " MB · "
+ (Dictation.serverReady
? "the speech server is running"
: "the speech server starts on your first dictation")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
SettingRow {
label: "Hold to dictate"
detail: "Release to type what you said, wherever the cursor is"
controlWidth: 220
KeycapChord {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
chord: root.holdChord
}
}
SettingRow {
label: "Cancel a dictation"
detail: "Throws the recording away without transcribing it"
controlWidth: 220
KeycapChord {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
chord: root.cancelChord
}
}
SettingRow {
label: "Try it"
// The refusal takes the detail's place while there is one, so a
// press that did nothing says so where the press happened.
detail: root.testError !== ""
? root.testError
: "Speak a sentence and it is typed into the field here, rather than into whatever you were working on"
controlWidth: 340
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 200
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: heard.activeFocus
? Theme.alpha(Theme.accent, 0.55)
: Theme.alpha(Theme.fg, 0.12)
TextInput {
id: heard
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
verticalAlignment: TextInput.AlignVCenter
clip: true
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
selectByMouse: true
selectionColor: Theme.alpha(Theme.accent, 0.35)
Text {
anchors.verticalCenter: parent.verticalCenter
visible: heard.text === ""
text: root.listening ? "Listening…" : "Dictated text lands here"
color: Theme.fgMuted
font: heard.font
}
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
// Four states, because stopping is not instant: the helper
// transcribes before it types, and a button still reading
// "Stop and type it" through those seconds invites a second
// press for a stop that has already been asked for.
text: {
if (dictateRun.running)
return root.listening ? "Transcribing…" : "Starting…";
return root.listening ? "Stop and type it" : "Test dictation";
}
tone: root.listening ? "accent" : "normal"
enabled: !dictateRun.running
onClicked: {
// Focus first, and keep it: the helper types with wtype
// into whatever holds keyboard focus when it finishes.
heard.forceActiveFocus();
if (root.listening) {
root.runDictate("stop");
return;
}
heard.text = "";
// `listening` is set by the reply, not by this press.
root.runDictate("start");
}
}
}
}
SettingRow {
visible: !Dictation.typingAvailable
label: "Typing"
detail: "wtype is missing, so dictated text goes to the clipboard instead of being typed"
value: "Missing"
divider: false
}
}
// ── Setting up ──────────────────────────────────────────────────────────
SettingsCard {
visible: !Dictation.ready
Item {
width: parent.width
height: 72
Rectangle {
id: setupTile
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 44
height: 44
radius: 14
color: Dictation.downloading
? Theme.alpha(Theme.accent, 0.10)
: Theme.alpha(Theme.fg, 0.07)
border.width: 1
border.color: Dictation.downloading
? Theme.alpha(Theme.accent, 0.35)
: Theme.alpha(Theme.fg, 0.16)
Text {
anchors.centerIn: parent
text: Dictation.downloading ? "…" : "✗"
color: Dictation.downloading ? Theme.accent : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge + 4
}
}
Column {
anchors.left: setupTile.right
anchors.leftMargin: 16
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: Dictation.downloading ? "Setting up dictation" : "Dictation is not set up yet"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Everything stays local: a speech server in a container, and a Whisper model. Neither ships with Panama — both are large and want the network."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
Repeater {
model: root.setupSteps
Item {
id: step
required property var modelData
width: parent ? parent.width : 620
height: 48
Rectangle {
id: number
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
radius: 12
color: {
if (step.modelData.done)
return Theme.alpha(Theme.ok, 0.15);
if (step.modelData.active)
return Theme.alpha(Theme.accent, 0.20);
return Theme.alpha(Theme.fg, 0.08);
}
Text {
anchors.centerIn: parent
text: step.modelData.done ? "✓" : String(step.modelData.number)
color: {
if (step.modelData.done)
return Theme.ok;
if (step.modelData.active)
return Theme.accent;
return Theme.fgDim;
}
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
Column {
anchors.left: number.right
anchors.leftMargin: 12
anchors.right: progress.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: String(step.modelData.title)
color: step.modelData.done || step.modelData.active ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: String(step.modelData.detail)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
// A real bar rather than a spinner: this is the one part of
// setup whose length is actually known.
Rectangle {
id: progress
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: step.modelData.progress >= 0 ? 140 : 0
height: 5
radius: 3
visible: step.modelData.progress >= 0
color: Theme.alpha(Theme.fg, 0.09)
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, step.modelData.progress))
radius: parent.radius
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.accent }
GradientStop { position: 1.0; color: Theme.accentSecondary }
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.alpha(Theme.fg, 0.05)
}
}
} }
// ONE action that actually works: panama-dictate setup pulls the // ONE action that actually works: panama-dictate setup pulls the
// server image and downloads the model together. This card used to // server image and downloads the model together. This card used to
// offer a Download button wired to a command the helper does not have, // offer a Download button wired to a command the helper does not have,
// and told you to build a "panama app whisper-vulkan" that does not // so nothing here did anything. It does now.
// exist -- so nothing here did anything. It does now.
ActionRow { ActionRow {
visible: !Dictation.ready || Dictation.downloading
label: "Set up dictation" label: "Set up dictation"
detail: { detail: Dictation.downloading
if (!Dictation.downloading) ? "Working — this can take several minutes on a slow connection"
return "Fetches the speech server and the model. Runs once, keeps both."; : "Fetches the speech server and the model. Runs once, keeps both."
if (Dictation.phase === "pulling")
return "Fetching the speech server…";
if (Dictation.downloadTotalBytes > 0)
return "Downloading model — " + Math.round(Dictation.downloadFraction * 100)
+ "% of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB";
return "Setting up…";
}
action: Dictation.downloading ? "Working…" : "Set up" action: Dictation.downloading ? "Working…" : "Set up"
enabled: !Dictation.downloading enabled: !Dictation.downloading
onTriggered: Dictation.setup() onTriggered: Dictation.setup()
divider: !Dictation.typingAvailable || Dictation.lastError !== ""
}
TextRow {
visible: !Dictation.typingAvailable
label: "Typing"
detail: "wtype is missing, so dictated text would go to the clipboard instead of being typed."
value: "Missing"
divider: Dictation.lastError !== "" divider: Dictation.lastError !== ""
} }
TextRow { ErrorRow {
visible: Dictation.lastError !== ""
label: "Problem" label: "Problem"
detail: Dictation.lastError message: Dictation.lastError
value: ""
divider: false
} }
} }
@@ -22,7 +22,6 @@ Variants {
implicitWidth: 260 implicitWidth: 260
implicitHeight: 172 implicitHeight: 172
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
mask: Region {} mask: Region {}
@@ -10,7 +10,7 @@
// picture, and whether this is the one the panel was built for. // picture, and whether this is the one the panel was built for.
// //
// Applying is the page's job. Every per-display edit on the Displays page goes // Applying is the page's job. Every per-display edit on the Displays page goes
// through one funnel so the whole record -- position, colour, mirror state -- // through one funnel so the whole record -- position, color, mirror state --
// rides the same keep-or-revert transaction; this reports which resolution was // rides the same keep-or-revert transaction; this reports which resolution was
// asked for and lets that funnel do the rest. // asked for and lets that funnel do the rest.
@@ -104,15 +104,22 @@ Item {
} }
} }
// Armed, this line stops reporting the current mode and names what
// forgetting costs. The consequence used to live in a 400ms tooltip on
// the pill, which is not where someone about to press Forget is
// looking.
Text { Text {
width: parent.width width: parent.width
visible: root.meta !== "" visible: root.meta !== "" || forget.armed
text: root.meta text: forget.armed
? "Resolution, refresh rate, scale, rotation and color go back to what Panama picks automatically."
: root.meta
color: Theme.fgDim color: Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.features: Theme.tabularFigures font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight wrapMode: forget.armed ? Text.WordWrap : Text.NoWrap
elide: forget.armed ? Text.ElideNone : Text.ElideRight
} }
} }
@@ -136,9 +143,11 @@ Item {
border.width: 1 border.width: 1
border.color: Theme.alpha(Theme.accent, 0.25) border.color: Theme.alpha(Theme.accent, 0.25)
// The pill says what the state IS. What Forget costs is said by
// the header line while Forget is armed, not hidden in here.
ToolTip.visible: customHover.hovered ToolTip.visible: customHover.hovered
ToolTip.delay: 400 ToolTip.delay: 400
ToolTip.text: "This display uses a setting you chose. Forget returns it to the one Panama ships." ToolTip.text: "This display uses a setting you chose, not the one Panama picks automatically."
Text { Text {
id: customLabel id: customLabel
@@ -153,13 +162,17 @@ Item {
HoverHandler { id: customHover } HoverHandler { id: customHover }
} }
SettingsButton { ConfirmAction {
id: forget
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: root.overridden visible: root.overridden
width: visible ? implicitWidth : 0 width: visible ? implicitWidth : 0
text: "Forget" actionId: "forget-display:" + root.connector
armText: "Forget…"
confirmText: "Forget it"
enabled: root.enabled enabled: root.enabled
onClicked: root.forgetRequested() onConfirmed: root.forgetRequested()
} }
} }
} }
@@ -39,10 +39,25 @@ SettingsPage {
// and the stored entry for vrrMode, which it does not report. Reading it // and the stored entry for vrrMode, which it does not report. Reading it
// from the service rather than rebuilding it here is what stops the page // from the service rather than rebuilding it here is what stops the page
// from showing one thing while an apply carries another. // from showing one thing while an apply carries another.
//
// With one exception, and it is the whole reason this is not a one-liner.
// While the Keep-or-revert banner is up, nothing has been stored yet --
// confirm() is what writes -- so currentLayout() still answers vrrMode from
// the PREVIOUS stored record. The variable-refresh picker therefore snapped
// back to its old value the instant the change was applied, while the
// banner beside it asked whether to keep a change the page had just stopped
// showing. For the length of that window the requested layout is what the
// pickers must render: it is what was asked for, and it is what keeping
// will store.
readonly property var record: { readonly property var record: {
const name = root.monitor ? root.monitor.name : ""; const name = root.monitor ? root.monitor.name : "";
if (name === "" || Displays.monitors.length === 0) if (name === "" || Displays.monitors.length === 0)
return null; return null;
if (Displays.awaitingConfirmation && Displays.pendingRequestedLayout) {
const requested = Displays.pendingRequestedLayout.find(entry => entry.name === name);
if (requested)
return requested;
}
return Displays.currentLayout().find(entry => entry.name === name) ?? null; return Displays.currentLayout().find(entry => entry.name === name) ?? null;
} }
@@ -501,17 +516,7 @@ SettingsPage {
} }
// ── Everything that belongs to no display in particular ───────────────── // ── Everything that belongs to no display in particular ─────────────────
Text { SectionLabel { text: "All displays" }
width: parent.width
text: "All displays"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.7
topPadding: 6
}
// Two cards side by side while there is room for two, and one above the // Two cards side by side while there is room for two, and one above the
// other when there is not. This window is tiled: its width is anywhere from // other when there is not. This window is tiled: its width is anywhere from
@@ -1,7 +1,7 @@
// Dock. // Dock.
// //
// The pinned applications come first because they are what the Dock is; the // The pinned applications come first because they are what the Dock is; the
// behaviour card below is how it gets out of the way. The dock timings used to // behavior card below is how it gets out of the way. The dock timings used to
// be shown here as text -- "Instant", "250 ms" -- even though they were already // be shown here as text -- "Instant", "250 ms" -- even though they were already
// stored, mutable integers. They are controls now. // stored, mutable integers. They are controls now.
@@ -80,35 +80,18 @@ SettingsPage {
Repeater { Repeater {
model: Quickshell.screens model: Quickshell.screens
delegate: Rectangle { delegate: SettingsChip {
id: screenPill id: screenPill
required property var modelData required property var modelData
readonly property string screenName: String(screenPill.modelData.name ?? "") readonly property string screenName: String(screenPill.modelData.name ?? "")
// An empty list means all, so every pill reads as on.
readonly property bool on: Settings.dockScreens.length === 0
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
width: pillLabel.implicitWidth + 20
height: 28
radius: 8
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: screenPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
id: pillLabel
anchors.centerIn: parent
text: screenPill.screenName text: screenPill.screenName
color: screenPill.on ? Theme.fg : Theme.fgDim // An empty list means all, so every chip reads as on.
font.family: Theme.fontFamily active: Settings.dockScreens.length === 0
font.pixelSize: Theme.fontSizeSmall || Settings.dockScreens.indexOf(screenPill.screenName) >= 0
} onClicked: root.toggleDockScreen(screenPill.screenName)
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
} }
} }
} }
@@ -0,0 +1,98 @@
// Joining an 802.1X network -- eduroam and every corporate SSID -- inline,
// under the row you clicked.
//
// This is the single road that used to end at GNOME's Wi-Fi panel. A PSK field
// cannot join these networks: they want an authentication method, an identity,
// a password, and sometimes a certificate to check the network against.
//
// The password never becomes a command-line argument. It is handed to
// panama-network over stdin, because argv is world-readable through /proc for
// as long as the process lives -- see the pin in network-tools-contract. It is
// also dropped from this form the moment the form closes.
//
// No file dialog for the certificate this phase: a path typed in is worse than
// a picker and much better than a road back to GNOME.
import QtQuick
Column {
id: root
property string ssid: ""
property bool busy: false
// Live form state. There is no reset(): WifiPanel loads this form and
// destroys it when the row closes, so the typed password goes with it
// rather than sitting in a hidden object waiting to be reopened.
property string eap: "peap-mschapv2"
property string identity: ""
property string password: ""
property string caPath: ""
readonly property bool ready: root.identity.trim() !== "" && root.password !== ""
signal submitted(eap: string, identity: string, password: string, caPath: string)
width: parent ? parent.width : 620
spacing: 0
OptionPickerRow {
width: parent.width
label: "Authentication"
detail: "What the network expects. Whoever runs it publishes this."
options: [
{
value: "peap-mschapv2",
label: "PEAP · MSCHAPv2",
detail: "The usual choice for university and workplace networks"
},
{
value: "ttls-pap",
label: "TTLS · PAP",
detail: "For networks built around a plain-text inner method"
}
]
current: root.eap
onPicked: value => root.eap = String(value)
}
TextFieldRow {
width: parent.width
label: "Identity"
detail: "The username the network knows you by, often an email address"
placeholder: "[email protected]"
text: root.identity
enabled: !root.busy
onAccepted: value => root.identity = value
}
PasswordRow {
width: parent.width
label: "Password"
detail: "Handed to NetworkManager directly. It is never written to a command line."
placeholder: "Password for " + (root.ssid !== "" ? root.ssid : "this network")
onChanged: value => root.password = value
}
TextFieldRow {
width: parent.width
label: "CA certificate"
detail: "Optional. A path to the certificate that proves the network is the one it claims to be."
placeholder: "/etc/ssl/certs/example.pem"
text: root.caPath
enabled: !root.busy
onAccepted: value => root.caPath = value
}
ActionRow {
width: parent.width
label: "Connect to " + (root.ssid !== "" ? root.ssid : "this network")
detail: root.ready
? "NetworkManager saves this network, so it comes back on its own next time"
: "An identity and a password are needed before this network will answer"
action: root.busy ? "Connecting…" : "Connect"
enabled: root.ready && !root.busy
divider: false
onTriggered: root.submitted(root.eap, root.identity.trim(), root.password, root.caPath.trim())
}
}
@@ -0,0 +1,28 @@
// A failure, reported where it happened. Before this component every page
// hand-rolled the same stanza with a different headline -- "Problem",
// "Updates need attention", "The network needs attention" -- and no visual
// mark distinguishing a failure from any other read-only fact. One shape now:
//
// ErrorRow { message: Updates.lastError }
// ErrorRow { message: Vpn.lastError; label: "The VPN needs attention" }
//
// The row hides itself while the message is empty, so consumers bind the
// service's lastError directly and write no visible: line. The message is the
// service's own sentence -- this component never rewords a failure, only
// frames it.
import QtQuick
import qs.config
SettingRow {
id: root
property string message: ""
label: "Needs attention"
visible: root.message !== ""
detail: root.message
labelColor: Theme.danger
controlWidth: 210
divider: false
}
@@ -0,0 +1,120 @@
// A row whose button takes a short piece of typed text as its argument.
//
// FieldActionRow {
// label: "Back up now"
// placeholder: "Name (optional)"
// action: "Back up"
// onSubmitted: value => SettingsBackup.save(value)
// }
//
// TextFieldRow is for a value that IS the setting -- a hostname, an account's
// real name -- and keeps showing whatever the system currently holds. This is
// for a one-shot whose argument happens to be typed: naming a backup, setting
// the clock by hand. The field holds an argument rather than a value, so the
// caller clears it once the action is away instead of leaving text sitting
// there looking like something that took effect.
import QtQuick
import qs.config
SettingRow {
id: root
property string placeholder: ""
property string action: ""
property bool enabled: true
property int fieldWidth: 150
// What is typed right now, so a caller can require it before enabling the
// button, or seed it with a sensible starting point.
property alias text: input.text
signal submitted(value: string)
function clear(): void { input.text = ""; }
function press(): void {
if (root.enabled)
root.submitted(input.text.trim());
}
controlWidth: root.fieldWidth + submitButton.implicitWidth + 10
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: root.fieldWidth
height: 32
radius: 9
color: Theme.alpha(Theme.fg, root.enabled ? 0.06 : 0.03)
border.width: input.activeFocus ? 2 : 1
border.color: input.activeFocus
? Theme.alpha(Theme.accent, 0.55)
: Theme.alpha(Theme.fg, 0.1)
opacity: root.enabled ? 1 : 0.5
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 11
anchors.rightMargin: 11
enabled: root.enabled
activeFocusOnTab: root.enabled
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
verticalAlignment: TextInput.AlignVCenter
clip: true
Accessible.role: Accessible.EditableText
Accessible.name: root.label
Accessible.description: root.placeholder
// Enter in the field is the same press as the button. Typing a
// name and hitting return is what everybody does first.
onAccepted: root.press()
Text {
anchors.fill: parent
visible: input.text === ""
text: root.placeholder
color: Theme.fgMuted
font: input.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
SettingsButton {
id: submitButton
anchors.verticalCenter: parent.verticalCenter
text: root.action
enabled: root.enabled
activeFocusOnTab: root.enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.press()
Accessible.role: Accessible.Button
Accessible.name: root.label
Accessible.description: root.detail === ""
? root.action
: `${root.detail} — ${root.action}`
Accessible.focusable: root.enabled
Accessible.onPressAction: root.press()
Keys.onReturnPressed: root.press()
Keys.onEnterPressed: root.press()
Keys.onSpacePressed: root.press()
}
}
}
@@ -0,0 +1,139 @@
// Overriding one file type, when the role above it covers too much.
//
// The role rows set a whole family together on purpose -- a file manager must
// not open one image in a viewer and its neighbour in an editor. That is right
// almost always and wrong exactly once: the one type somebody wants somewhere
// else. This is that escape hatch, and it is deliberately a search rather than
// a list, because the MIME database has thousands of entries and none of them
// is worth scrolling past.
//
// Nothing is written here. The page owns the service call, so a type set from
// this row goes down the same validated path as a role does.
import QtQuick
import qs.config
import qs.modules.clipboard
Column {
id: root
// [{ mime, label, extensions, handler, handlerName, candidates: [{ id, name }] }]
property var matches: []
property bool busy: false
property bool truncated: false
// The helper answers nothing shorter than this, so the row says so rather
// than looking broken while somebody types the first letter.
readonly property int minimumQuery: 2
// Emitted after the typing settles, so a query is one search rather than
// one per keystroke.
signal queried(string query)
signal chosen(string mime, string desktopId)
readonly property string query: search.text.trim()
function typeLabel(match: var): string {
const label = String(match?.label ?? "").trim();
return label !== "" ? label : String(match?.mime ?? "");
}
function typeDetail(match: var): string {
const parts = [String(match?.mime ?? "")];
const extensions = match?.extensions ?? [];
if (extensions.length > 0)
parts.push(extensions.join(" "));
const handler = String(match?.handlerName ?? match?.handler ?? "").trim();
parts.push(handler === "" ? "nothing opens it yet" : "opens with " + handler);
return parts.join(" · ");
}
function optionsFor(match: var): var {
return (match?.candidates ?? []).map(candidate => ({
value: String(candidate.id ?? ""),
label: String(candidate.name ?? candidate.id ?? ""),
detail: String(candidate.id ?? "")
}));
}
width: parent ? parent.width : 620
spacing: 0
SearchField {
id: search
width: parent.width
placeholder: "png, pdf, video/… — the type or its extension"
onTextChanged: settle.restart()
}
Timer {
id: settle
interval: 250
onTriggered: root.queried(search.text.trim())
}
Item { width: 1; height: 8 }
TextRow {
width: parent.width
visible: root.query.length < root.minimumQuery
label: "Type what you are looking for"
detail: "The type and its file extensions are both searched, so \"svg\" and"
+ " \"image/svg+xml\" land on the same row. Two letters is the shortest"
+ " search the type database answers."
value: ""
divider: false
}
TextRow {
width: parent.width
visible: root.busy && root.query.length >= root.minimumQuery
label: "Searching the type database…"
detail: "Reading which applications advertise each type"
value: ""
divider: false
}
Repeater {
model: root.busy ? [] : root.matches
delegate: OptionPickerRow {
id: typeRow
required property var modelData
required property int index
width: parent.width
label: root.typeLabel(typeRow.modelData)
detail: root.typeDetail(typeRow.modelData)
options: root.optionsFor(typeRow.modelData)
current: String(typeRow.modelData.handler ?? "")
enabled: typeRow.options.length > 0
divider: typeRow.index < root.matches.length - 1
value: typeRow.currentOption
? String(typeRow.currentOption.label)
: (typeRow.options.length === 0 ? "Nothing offers it" : "Not set")
onPicked: value => root.chosen(String(typeRow.modelData.mime ?? ""), String(value))
}
}
TextRow {
width: parent.width
visible: !root.busy && root.truncated
label: "Only the closest matches are shown"
detail: "Narrow the search to reach the rest"
value: ""
divider: false
}
TextRow {
width: parent.width
visible: !root.busy && root.query.length >= root.minimumQuery
&& root.matches.length === 0
label: "No file type matches that"
detail: "Only types this system knows about can be given a handler"
value: ""
divider: false
}
}
@@ -0,0 +1,126 @@
// Guided enrollment, in place, for as long as it takes.
//
// fprintd wants the same finger pressed several times from slightly different
// angles, and it says how many it still needs. That count is the whole content
// of this panel: a person mid-enrollment is looking at the reader, not the
// screen, and the one thing they come back to the screen for is whether it
// worked and how much is left.
//
// Nothing here loops. The bar advances when a touch is accepted and then holds,
// so a panel left open on a desktop that never got touched costs no frames --
// see the note about repainting animations in the Appearance page.
import QtQuick
import qs.config
Rectangle {
id: root
// Human label for the finger being enrolled, already resolved by the
// service so nothing here has to know fprintd's vocabulary.
property string finger: ""
property int stage: 0
property int total: 0
// Whatever the last stream line said, when it said anything worth showing --
// "enroll-retry-scan", a failure, a completion.
property string message: ""
signal cancelled
width: parent ? parent.width : 620
implicitHeight: body.implicitHeight + 28
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.3)
Column {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
spacing: 7
Text {
anchors.horizontalCenter: parent.horizontalCenter
// md-fingerprint
text: "\u{F0306}"
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 38
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.finger === ""
? "Touch the reader"
: "Touch the reader with your " + root.finger.toLowerCase()
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
wrapMode: Text.WordWrap
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.total > 0
? "Lift and press again — " + root.stage + " of " + root.total + " touches"
: "Lift and press again"
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Item { width: 1; height: 3 }
Rectangle {
width: parent.width
height: 5
radius: 3
color: Theme.alpha(Theme.fg, 0.1)
border.width: 0
Rectangle {
width: root.total > 0
? parent.width * Math.max(0, Math.min(1, root.stage / root.total))
: 0
height: parent.height
radius: parent.radius
color: Theme.accent
border.width: 0
// One step per accepted touch, then still.
Behavior on width {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
}
}
}
Text {
width: parent.width
visible: root.message !== ""
horizontalAlignment: Text.AlignHCenter
text: root.message
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
topPadding: 4
}
Item { width: 1; height: 3 }
SettingsButton {
anchors.horizontalCenter: parent.horizontalCenter
text: "Cancel"
onClicked: root.cancelled()
}
}
}
@@ -12,7 +12,6 @@
import Quickshell import Quickshell
import QtQuick import QtQuick
import qs.config
import qs.services import qs.services
SettingsPage { SettingsPage {
@@ -25,14 +24,119 @@ SettingsPage {
property string confirmingRemoval: "" property string confirmingRemoval: ""
property bool confirmingRange: false property bool confirmingRange: false
Component.onCompleted: Firewall.refresh() // The add flow. Additions are not destructive -- they open something rather
// than closing it -- so there is no confirm here, only the caption saying
// the rule is permanent and will prompt.
property bool addOpen: false
property string addKind: "service"
property string addValue: ""
TextRow { // Changing a connection's zone IS consequence-bearing: it silently rewrites
visible: Firewall.lastError !== "" // what every machine on that network can reach. Two-stage, and the confirm
// names the interface it is about to move.
property string pendingInterface: ""
property string pendingZone: ""
// The default is the same change with a wider blast radius: it decides for
// every connection that does not ask for a zone by name. It used to apply
// on the pick, one dropdown below a picker that made you confirm.
property string pendingDefaultZone: ""
// Two armed changes on screen at once is how the wrong one gets pressed.
function armInterfaceMove(iface: string, zoneName: string): void {
root.pendingDefaultZone = "";
root.pendingInterface = iface;
root.pendingZone = zoneName;
}
function armDefaultZone(zoneName: string): void {
root.pendingInterface = "";
root.pendingZone = "";
root.pendingDefaultZone = zoneName;
}
// The zone being read in the browser. Read-only: this looks at a zone
// without applying it to anything.
property string browsingZone: ""
readonly property bool addValid: root.addKind === "service"
? /^[a-z0-9][a-z0-9-]*$/.test(root.addValue.trim())
: /^[0-9]{1,5}(-[0-9]{1,5})?\/(tcp|udp)$/.test(root.addValue.trim())
// [{ iface, zone }] -- one row per network interface the firewall has
// actually placed in a zone, flattened out of activeZones.
readonly property var zonedInterfaces: {
const placed = Firewall.activeZones ?? ({});
const rows = [];
for (const zoneName of Object.keys(placed)) {
for (const iface of (placed[zoneName] ?? []))
rows.push({ iface: String(iface), zone: String(zoneName) });
}
rows.sort((a, b) => a.iface.localeCompare(b.iface));
return rows;
}
readonly property var zoneOptions: (Firewall.allZones ?? []).map(zoneName => ({
value: String(zoneName),
label: String(zoneName),
detail: String(zoneName) === Firewall.defaultZone
? "The default for new connections" : ""
}))
// What an interface is carrying, said the way a person names it: the Wi-Fi
// network or the wired profile, falling back to the kernel's name for it.
function connectionOn(iface: string): string {
if (Connectivity.wifiDevice && Connectivity.wifiDevice.name === iface
&& Connectivity.activeNetwork)
return Connectivity.activeNetwork.name;
if (Connectivity.wiredDevice && Connectivity.wiredDevice.name === iface
&& Connectivity.wiredDevice.network)
return String(Connectivity.wiredDevice.network.name ?? iface);
return iface;
}
// The rules by name, then what the zone does with everything else. The
// helper's `summary` counts them; the names are what someone comparing two
// zones actually needs, so the names are listed and the helper's own
// sentence about the target is kept for the tail.
function describeZone(zoneName: string): string {
const info = Firewall.zoneInfo(zoneName);
if (!info)
return "Reading what " + zoneName + " allows…";
const services = (info.services ?? []).map(entry => String(entry));
const ports = (info.ports ?? []).map(entry => String(entry));
const parts = [];
if (services.length > 0)
parts.push(services.join(", "));
if (ports.length > 0)
parts.push("ports " + ports.join(", "));
let text = parts.length === 0
? zoneName + " allows nothing in."
: zoneName + " allows: " + parts.join(", and ") + ".";
// "3 services, 1 port rule; anything no rule allows is rejected" --
// everything after the semicolon is the helper's phrasing for the
// zone's target, which the list above does not say.
const summary = String(info.summary ?? "");
const cut = summary.indexOf("; ");
if (cut >= 0)
text += " Otherwise, " + summary.slice(cut + 2) + ".";
const rich = (info.richRules ?? []).length;
if (rich > 0)
text += " It also carries " + rich + " rich rule"
+ (rich === 1 ? "" : "s") + ", which this page never edits.";
return text;
}
Component.onCompleted: if (!Firewall.scanned) Firewall.refresh()
ErrorRow {
label: "The firewall needs attention" label: "The firewall needs attention"
detail: Firewall.lastError message: Firewall.lastError
value: ""
divider: false
} }
// ── The finding, when there is one ─────────────────────────────────────── // ── The finding, when there is one ───────────────────────────────────────
@@ -146,10 +250,11 @@ SettingsPage {
text: "Close it" text: "Close it"
tone: "danger" tone: "danger"
enabled: !Firewall.busy enabled: !Firewall.busy
// Every range in one call. The label says "the range",
// and a range is a tcp rule and a udp rule.
onClicked: { onClicked: {
root.confirmingRange = false; root.confirmingRange = false;
for (const spec of Firewall.openRanges) Firewall.removePorts(Firewall.openRanges);
Firewall.removePort(String(spec));
} }
} }
} }
@@ -180,7 +285,9 @@ SettingsPage {
: "Anything relying on this service stops being reachable.") : "Anything relying on this service stops being reachable.")
: "Allowed by name, so it works whatever the port range says" : "Allowed by name, so it works whatever the port range says"
controlWidth: 210 controlWidth: 210
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1 // The add row always follows, so the last service still needs
// its hairline.
divider: true
Row { Row {
anchors.right: parent.right anchors.right: parent.right
@@ -208,6 +315,80 @@ SettingsPage {
} }
} }
// ── The add side ─────────────────────────────────────────────────────
//
// Opening a port is not destructive, so it is not confirmed. It is
// permanent and it prompts, and both of those are said before the
// button rather than discovered by the polkit dialog appearing.
ActionRow {
width: parent.width
label: "Allow something new"
detail: "A service firewalld already knows by name, or a port and protocol"
action: root.addOpen ? "Cancel" : "Add…"
enabled: !Firewall.busy
divider: root.addOpen
onTriggered: {
root.addOpen = !root.addOpen;
root.addValue = "";
}
}
Column {
width: parent.width
visible: root.addOpen
SegmentRow {
width: parent.width
label: "What to allow"
detail: "A named service carries its own ports, so it keeps working if they change"
controlWidth: 230
options: [
{ value: "service", label: "Named service" },
{ value: "port", label: "Port" }
]
value: root.addKind
enabled: !Firewall.busy
onSelected: value => {
root.addKind = String(value);
root.addValue = "";
}
}
TextFieldRow {
width: parent.width
label: root.addKind === "service" ? "Service name" : "Port and protocol"
detail: root.addKind === "service"
? "One of firewalld's own service names, in lower case"
: "A port or range, then tcp or udp"
placeholder: root.addKind === "service" ? "syncthing" : "8080/tcp"
text: root.addValue
enabled: !Firewall.busy
onAccepted: value => root.addValue = value
}
ActionRow {
width: parent.width
label: root.addValue.trim() === "" || root.addValid
? "Allow it"
: (root.addKind === "service"
? "That is not a service name firewalld would accept"
: "That is not a port firewalld would accept")
detail: "This writes a permanent rule — the system will ask for your password"
action: "Allow"
enabled: root.addValid && !Firewall.busy
divider: false
onTriggered: {
if (root.addKind === "service")
Firewall.addService(root.addValue.trim());
else
Firewall.addPort(root.addValue.trim());
root.addOpen = false;
root.addValue = "";
}
}
}
// Shown, never edited. // Shown, never edited.
TextRow { TextRow {
visible: (Firewall.zone?.richRules ?? []).length > 0 visible: (Firewall.zone?.richRules ?? []).length > 0
@@ -222,26 +403,184 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Zones" title: "Zones"
subtitle: "A zone is a set of rules. Each network connection uses one." subtitle: "A zone is a set of rules. Each network connection uses one, and moving a connection between zones changes what the machines on that network can reach."
Repeater { Repeater {
model: Object.keys(Firewall.activeZones ?? ({})) model: root.zonedInterfaces
delegate: Column {
id: placement
delegate: TextRow {
required property var modelData required property var modelData
required property int index required property int index
readonly property string iface: String(placement.modelData.iface ?? "")
readonly property string zone: String(placement.modelData.zone ?? "")
readonly property bool pending: root.pendingInterface === placement.iface
width: parent.width width: parent.width
label: String(modelData)
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ") OptionPickerRow {
value: String(modelData) === Firewall.defaultZone ? "Default" : "" width: parent.width
divider: true label: root.connectionOn(placement.iface)
detail: root.connectionOn(placement.iface) === placement.iface
? "This interface"
: "On " + placement.iface
enabled: !Firewall.busy
options: root.zoneOptions
current: placement.zone
divider: !placement.pending
onPicked: value => root.armInterfaceMove(placement.iface, String(value))
}
SettingRow {
width: parent.width
visible: placement.pending
label: "Move " + placement.iface + " to " + root.pendingZone + "?"
// The interface is named because that is the thing being
// moved, and because "public" on the wrong one is the
// difference between a safe café and an unreachable desk.
detail: placement.iface + " leaves " + placement.zone + " for "
+ root.pendingZone + ". Everything reachable over "
+ placement.iface + " is decided by " + root.pendingZone
+ " from then on."
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Keep " + placement.zone
enabled: !Firewall.busy
onClicked: {
root.pendingInterface = "";
root.pendingZone = "";
}
}
SettingsButton {
text: "Move it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
const target = root.pendingZone;
root.pendingInterface = "";
root.pendingZone = "";
Firewall.setZone(placement.iface, target);
}
}
}
}
} }
} }
TextRow { TextRow {
width: parent.width
visible: Firewall.scanned && root.zonedInterfaces.length === 0
label: "No connection is in a zone"
detail: !Firewall.available
? "The firewall is not running, so nothing has been placed"
: "firewalld reports no active zones, which usually means no interface is up"
value: ""
}
OptionPickerRow {
width: parent.width
label: "Default for new connections" label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone" detail: "Used when a network does not ask for a particular zone"
value: Firewall.defaultZone enabled: !Firewall.busy
options: root.zoneOptions
current: Firewall.defaultZone
divider: root.pendingDefaultZone === ""
onPicked: value => root.armDefaultZone(String(value))
}
SettingRow {
width: parent.width
visible: root.pendingDefaultZone !== ""
label: "Make " + root.pendingDefaultZone + " the default?"
// What moves is named the way the per-interface confirm names its
// interface: not "the default changes", but which machines end up
// deciding differently because of it.
detail: "Every connection firewalld has not placed in a zone of its "
+ "own follows the default — each one leaves " + Firewall.defaultZone
+ " for " + root.pendingDefaultZone
+ ", and so does every network joined from now on."
controlWidth: 240
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Keep " + Firewall.defaultZone
enabled: !Firewall.busy
onClicked: root.pendingDefaultZone = ""
}
SettingsButton {
text: "Change it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
const target = root.pendingDefaultZone;
root.pendingDefaultZone = "";
Firewall.setDefaultZone(target);
}
}
}
}
// ── The zone browser ─────────────────────────────────────────────────
//
// Read-only. Picking a chip here applies nothing: it answers "what would
// this zone do", which is the question you have to answer before the
// dropdowns above are anything but a guess.
TextRow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
label: "Browse zones"
detail: "What each of the " + (Firewall.allZones ?? []).length
+ " zones would allow. Nothing here applies anything."
value: ""
divider: false
}
// Full width rather than in the row's trailing slot: fourteen chips wrap
// to several lines, and a row's control area is one line tall.
Flow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
spacing: 6
bottomPadding: 12
Repeater {
model: Firewall.allZones ?? []
delegate: SettingsChip {
id: chip
required property var modelData
readonly property string zoneName: String(chip.modelData)
text: chip.zoneName
active: root.browsingZone === chip.zoneName
onClicked: root.browsingZone = chip.active ? "" : chip.zoneName
}
}
}
TextRow {
width: parent.width
visible: root.browsingZone !== ""
label: root.browsingZone
detail: root.describeZone(root.browsingZone)
value: root.browsingZone === Firewall.defaultZone ? "Default" : ""
divider: false divider: false
} }
} }
@@ -0,0 +1,235 @@
// The applications a focus mode lets through, as chips rather than a second
// unbounded list of every application that has ever notified.
//
// The old editor unfolded the whole application universe under every mode,
// which meant scrolling a hundred switches to add one exception. What a person
// wants to read is the short list of things that may interrupt; adding to it is
// a search, not a scan.
//
// An id that is allowed but no longer known still gets a chip, spelled as the
// raw id, so an exception can always be removed even after the application that
// earned it is gone.
import QtQuick
import qs.config
import qs.modules.clipboard
Column {
id: root
// The mode's allow list: application ids.
property var allow: []
// The same universe the per-application rules use: [{ id, name }].
property var knownApps: []
property bool divider: true
signal toggled(string appId, bool allowed)
property bool adding: false
function nameOf(appId: string): string {
const known = root.knownApps.find(app => String(app.id) === appId);
return known ? String(known.name) : appId;
}
readonly property var allowed: (root.allow ?? []).map(String)
// Capped at eight: this is a search box, not a browser, and a list long
// enough to scroll defeats the point of having replaced one.
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
const out = [];
for (const app of root.knownApps) {
const id = String(app.id);
if (root.allowed.indexOf(id) >= 0)
continue;
const haystack = (String(app.name) + " " + id).toLowerCase();
if (needle !== "" && haystack.indexOf(needle) < 0)
continue;
out.push(app);
if (out.length >= 8)
break;
}
return out;
}
width: parent ? parent.width : 620
spacing: 0
Item {
id: head
width: parent.width
implicitHeight: Math.max(56, Math.max(copy.implicitHeight, chips.implicitHeight) + 20)
Column {
id: copy
anchors.left: parent.left
anchors.right: chips.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: "May interrupt"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: "Applications that break through while this mode is on"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Flow {
id: chips
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: Math.max(160, parent.width * 0.45)
spacing: 7
Repeater {
model: root.allowed
Rectangle {
id: chip
required property string modelData
height: 26
width: chipLabel.implicitWidth + remove.width + 22
radius: Theme.pillRadius
color: Theme.alpha(Theme.accent, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.28)
Text {
id: chipLabel
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: root.nameOf(chip.modelData)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
Item {
id: remove
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
Text {
anchors.centerIn: parent
text: "×"
color: removeHover.hovered ? Theme.danger : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 2
}
HoverHandler {
id: removeHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.toggled(chip.modelData, false)
}
}
}
}
SettingsChip {
id: addChip
text: root.adding ? "Cancel" : "+ Add application"
onClicked: {
root.adding = !root.adding;
if (!root.adding)
search.text = "";
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider && !root.adding
color: Theme.alpha(Theme.fg, 0.065)
}
}
Column {
width: parent.width
visible: root.adding
spacing: 0
Item {
width: parent.width
height: 40
SearchField {
id: search
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
placeholder: "Search applications"
}
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
width: parent.width
label: String(candidate.modelData.name ?? "")
detail: String(candidate.modelData.id ?? "")
controlWidth: 86
divider: candidate.index < root.matches.length - 1
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Add"
onClicked: {
root.toggled(String(candidate.modelData.id), true);
search.text = "";
}
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: "No applications left to add"
detail: "Applications appear here after their first notification"
divider: false
}
}
}
@@ -0,0 +1,547 @@
// One focus mode, as a line that unfolds into its whole editor.
//
// Order is priority -- the first mode whose condition is true wins -- so the
// row carries a grip that moves it, by pointer or by the arrow keys once it has
// keyboard focus. Everything else is the mode itself: what turns it on, what it
// does, and what it lets through anyway.
//
// The editor edits `triggers[0]`. Shipped modes each carry exactly one trigger;
// a hand-edited mode with more than one keeps the extras through field edits and
// collapses to a single trigger the first time its kind is changed here, which
// is what FocusModes.setTriggerKind is defined to do.
import QtQuick
import qs.config
import qs.services
Column {
id: root
required property var mode
required property var knownApps
property bool expanded: false
property bool running: false
property bool divider: true
signal activated
signal allowToggled(string appId, bool allowed)
readonly property string modeId: String(root.mode.id ?? "")
readonly property string modeName: String(root.mode.name ?? "")
readonly property var trigger: (root.mode.triggers ?? [])[0] ?? ({ kind: "manual" })
readonly property string kind: String(root.trigger.kind ?? "manual")
readonly property var allow: Array.isArray(root.mode.allow) ? root.mode.allow.map(String) : []
// Writes one field of the mode's first trigger, leaving any others alone.
function editTrigger(changes: var): void {
const triggers = (root.mode.triggers ?? []).slice();
triggers[0] = Object.assign({}, triggers[0] ?? { kind: root.kind }, changes);
FocusModes.update(root.modeId, { triggers: triggers });
}
function toggleDay(day: int): void {
const days = Array.isArray(root.trigger.days) ? root.trigger.days.map(Number) : [];
const at = days.indexOf(day);
if (at >= 0)
days.splice(at, 1);
else
days.push(day);
days.sort((a, b) => a - b);
root.editTrigger({ days: days });
}
// A time is written only if it is a real one. FocusModes treats a malformed
// schedule as off, and silencing a machine because a string was wrong is the
// worst way for this to fail -- so a bad entry is simply not stored.
function normalizedTime(text: string): string {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(text ?? "").trim());
if (!match)
return "";
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59)
return "";
return String(hours).padStart(2, "0") + ":" + match[2];
}
function daysText(days: var): string {
const list = Array.isArray(days) ? days.map(Number).slice().sort((a, b) => a - b) : [];
if (list.length === 0)
return "no days";
if (list.length === 7)
return "every day";
if (list.join(",") === "1,2,3,4,5")
return "weekdays";
if (list.join(",") === "0,6")
return "weekends";
const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return list.map(day => names[day]).join(", ");
}
readonly property string whenPhrase: {
switch (root.kind) {
case "game":
return "when a game runs";
case "schedule":
return "on a schedule — " + String(root.trigger.start ?? "")
+ " to " + String(root.trigger.end ?? "")
+ ", " + root.daysText(root.trigger.days);
case "workspace":
return "on workspace " + String(root.trigger.id ?? 1);
case "fullscreen":
return "when an application is fullscreen";
default:
return "manually";
}
}
// What this mode does, said in a tense the shell can keep. A manual mode has
// no activation path at all -- FocusModes.triggerActive is false for
// "manual" and the automatic list filters it out -- so its switch is saved
// intent and nothing more, and the summary must not claim otherwise.
readonly property string summaryText: {
if (root.running)
return "On now — " + FocusModes.activeReason;
const hypothetical = root.kind === "manual" || root.mode.enabled !== true;
const effects = [];
if (root.mode.silence === true) {
effects.push((hypothetical ? "would silence everything" : "silences everything")
+ (root.allow.length === 0
? ""
: " except " + root.allow.length
+ (root.allow.length === 1 ? " application" : " applications")));
}
if (root.mode.keepAwake === true)
effects.push(hypothetical ? "would keep the screen awake" : "keeps the screen awake");
const opening = root.kind === "manual"
? "Nothing turns this on automatically"
: (root.mode.enabled === true
? "Turns on " + root.whenPhrase
: "Off · would turn on " + root.whenPhrase);
return effects.length > 0 ? opening + " · " + effects.join(", ") : opening;
}
readonly property color tileColor: {
const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan,
Theme.green, Theme.orange, Theme.pink];
let hash = 0;
for (let index = 0; index < root.modeId.length; index++)
hash = (hash * 31 + root.modeId.charCodeAt(index)) % 9973;
return palette[hash % palette.length];
}
width: parent ? parent.width : 620
spacing: 0
Item {
id: head
width: parent.width
implicitHeight: Math.max(60, copy.implicitHeight + 22)
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 1
radius: 10
z: -1
visible: headHover.hovered
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Item {
id: grip
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 32
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: "Reorder " + root.modeName
Accessible.description: "Up and down move this mode's priority"
Accessible.focusable: true
Keys.onUpPressed: FocusModes.moveMode(root.modeId, -1)
Keys.onDownPressed: FocusModes.moveMode(root.modeId, 1)
Rectangle {
anchors.fill: parent
radius: 6
visible: grip.activeFocus
color: "transparent"
border.width: 2
border.color: Theme.alpha(Theme.accent, 0.55)
}
Text {
anchors.centerIn: parent
text: "⠿"
color: gripHover.hovered || grip.activeFocus ? Theme.fgDim : Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize + 2
}
HoverHandler {
id: gripHover
cursorShape: Qt.OpenHandCursor
}
// A row swap on drop rather than a live-reordering list: the whole
// gesture is "put this one further up", and one write at the end
// says exactly that.
DragHandler {
id: gripDrag
property real startY: 0
target: null
onActiveChanged: {
if (gripDrag.active) {
gripDrag.startY = gripDrag.centroid.scenePosition.y;
return;
}
const travelled = gripDrag.centroid.scenePosition.y - gripDrag.startY;
const steps = Math.round(travelled / Math.max(1, head.height));
if (steps !== 0)
FocusModes.moveMode(root.modeId, steps);
}
}
}
Rectangle {
id: tile
anchors.left: grip.right
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 32
radius: 9
color: Theme.alpha(root.tileColor, root.mode.enabled === true ? 0.9 : 0.35)
border.width: 0
Text {
anchors.centerIn: parent
text: root.modeName.length > 0 ? root.modeName.charAt(0).toUpperCase() : "?"
color: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
font.weight: Font.Bold
}
}
Column {
id: copy
anchors.left: tile.right
anchors.leftMargin: 12
anchors.right: controls.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.modeName
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.summaryText
color: root.running ? Theme.ok : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Row {
id: controls
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 11
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: root.mode.enabled === true
onToggled: value => FocusModes.setEnabled(root.modeId, value)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
anchors.left: tile.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider && !root.expanded
color: Theme.alpha(Theme.fg, 0.065)
}
HoverHandler {
id: headHover
cursorShape: Qt.PointingHandCursor
}
// The grip moves the mode and the trailing controls switch it on; the
// band between them is the expander.
TapHandler {
onTapped: eventPoint => {
if (eventPoint.position.x <= grip.width
|| eventPoint.position.x >= controls.x)
return;
root.activated();
}
}
}
Column {
id: body
x: 44
width: Math.max(0, parent.width - 44)
visible: root.expanded
OptionPickerRow {
width: parent.width
label: "Turns on"
detail: root.kind === "manual"
? "Nothing turns this on automatically — pick a trigger, or start a focus session below for quiet by hand"
: "The condition is asked continuously, so this is right after a reboot or a suspend"
options: [
{
value: "manual",
label: "Manually",
detail: "No condition: the mode is saved, and nothing switches it on"
},
{
value: "game",
label: "When a game runs",
detail: "The gamemode hook reports the game; this mode does the silencing"
},
{
value: "schedule",
label: "On a schedule",
detail: "A window of the clock, on the days you choose"
},
{
value: "workspace",
label: "On a workspace",
detail: "On while that workspace is the focused one"
},
{
value: "fullscreen",
label: "When an application is fullscreen",
detail: "On while a window is fullscreen on the focused display"
}
]
current: root.kind
onPicked: value => FocusModes.setTriggerKind(root.modeId, value, {})
}
TextFieldRow {
width: parent.width
visible: root.kind === "schedule"
label: "From"
detail: "24-hour, such as 23:30"
text: String(root.trigger.start ?? "")
placeholder: "23:30"
onAccepted: value => {
const time = root.normalizedTime(value);
if (time !== "")
root.editTrigger({ start: time });
}
}
TextFieldRow {
width: parent.width
visible: root.kind === "schedule"
label: "Until"
detail: "A time earlier than the start means the window crosses midnight"
text: String(root.trigger.end ?? "")
placeholder: "07:00"
onAccepted: value => {
const time = root.normalizedTime(value);
if (time !== "")
root.editTrigger({ end: time });
}
}
SettingRow {
width: parent.width
visible: root.kind === "schedule"
label: "On these days"
detail: "A window that crosses midnight belongs to the day it starts on"
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Repeater {
// `name` is what a screen reader says: four of the seven
// initials repeat, so the letter alone is not a day.
model: [
{ value: 1, label: "M", name: "Monday" },
{ value: 2, label: "T", name: "Tuesday" },
{ value: 3, label: "W", name: "Wednesday" },
{ value: 4, label: "T", name: "Thursday" },
{ value: 5, label: "F", name: "Friday" },
{ value: 6, label: "S", name: "Saturday" },
{ value: 0, label: "S", name: "Sunday" }
]
// Seven of them across, so they share one width rather than
// each taking the width of its own letter.
delegate: SettingsChip {
id: dayPill
required property var modelData
width: 30
text: String(dayPill.modelData.label)
active: (root.trigger.days ?? []).indexOf(dayPill.modelData.value) >= 0
Accessible.name: String(dayPill.modelData.name)
onClicked: root.toggleDay(dayPill.modelData.value)
}
}
}
}
SettingRow {
id: workspaceRow
readonly property int current: Math.max(1, Number(root.trigger.id ?? 1))
width: parent.width
visible: root.kind === "workspace"
label: "Workspace"
detail: "On while this workspace is the focused one"
controlWidth: 130
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "−"
enabled: workspaceRow.current > 1
onClicked: root.editTrigger({ id: workspaceRow.current - 1 })
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: 24
horizontalAlignment: Text.AlignHCenter
text: String(workspaceRow.current)
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "+"
enabled: workspaceRow.current < 10
onClicked: root.editTrigger({ id: workspaceRow.current + 1 })
}
}
}
SettingRow {
width: parent.width
label: "Silence notifications"
detail: "Banners are held until the mode ends"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.mode.silence === true
onToggled: value => FocusModes.update(root.modeId, { silence: value })
}
}
FocusAllowChips {
width: parent.width
visible: root.mode.silence === true
allow: root.allow
knownApps: root.knownApps
onToggled: (appId, allowed) => root.allowToggled(appId, allowed)
}
SettingRow {
width: parent.width
label: "Keep the screen awake"
detail: "Caffeine, for as long as the mode is on"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.mode.keepAwake === true
onToggled: value => FocusModes.update(root.modeId, { keepAwake: value })
}
}
TextFieldRow {
width: parent.width
label: "Name"
detail: "What this mode is called everywhere it appears"
text: root.modeName
placeholder: "Focus mode"
onAccepted: value => FocusModes.renameMode(root.modeId, value)
}
SettingRow {
width: parent.width
label: "Delete this mode"
// The permanence is said on the armed press, where it is the
// question being answered, rather than under a button that had
// already done it by the time the sentence was read.
detail: deleteConfirm.armed
? "Removes " + (root.modeName === "" ? "this mode" : root.modeName)
+ (root.allow.length === 0
? ""
: " and its " + root.allow.length
+ (root.allow.length === 1 ? " exception" : " exceptions"))
+ " for good"
: "Takes it out of the list"
controlWidth: deleteConfirm.armed ? 180 : 90
divider: false
ConfirmAction {
id: deleteConfirm
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
actionId: "focus-mode-delete:" + root.modeId
armText: "Delete…"
confirmText: "Delete it"
onConfirmed: FocusModes.removeMode(root.modeId)
}
}
}
}
@@ -0,0 +1,160 @@
// The focus tab: the modes that quiet this machine, and the timed session that
// is deliberately not one of them.
//
// Modes are conditions rather than alarms -- a mode is on because something is
// true right now -- so the editor never asks anyone to schedule an event. It
// asks what has to be true. Order is priority, which is why the list can be
// reordered and says so.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
// One mode is open for editing at a time; the point is a card you can read,
// not every mode unfolded at once.
property string expandedMode: ""
// The exception list belongs to the page rather than to the row: the
// applications it is chosen from are the same universe the per-application
// notification rules use, and the page is what reads it.
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
const at = current.indexOf(appId);
if (allowed && at < 0)
current.push(appId);
else if (!allowed && at >= 0)
current.splice(at, 1);
FocusModes.update(String(mode.id), { allow: current });
}
title: "Focus"
lede: "Modes quiet this machine on their own terms — first matching mode wins, and the order below is the priority."
SettingsCard {
title: "Focus modes"
subtitle: FocusModes.active
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
: "Drag a mode by its grip, or press up and down on it, to change which one wins."
TextRow {
visible: FocusModes.modes.length === 0
label: "No focus modes"
detail: "Add one below and give it something to react to"
divider: false
}
Repeater {
model: FocusModes.modes
delegate: FocusModeRow {
id: modeRow
required property var modelData
width: parent.width
mode: modeRow.modelData
knownApps: Notifs.applications
expanded: root.expandedMode === String(modeRow.modelData.id ?? "")
running: FocusModes.activeMode?.id === String(modeRow.modelData.id ?? "")
onActivated: root.expandedMode = modeRow.expanded
? ""
: String(modeRow.modelData.id ?? "")
onAllowToggled: (appId, allowed) =>
root.toggleAllowed(modeRow.modelData, appId, allowed)
}
}
Item {
width: parent.width
height: 58
Rectangle {
id: newMode
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 44
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, newModeHover.hovered ? 0.06 : 0.02)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.16)
Text {
anchors.centerIn: parent
text: "+ New focus mode"
color: newModeHover.hovered ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
HoverHandler {
id: newModeHover
cursorShape: Qt.PointingHandCursor
}
// Created open, so the first thing anyone sees is the editor
// rather than a row named "New mode" with nothing to react to.
TapHandler {
onTapped: root.expandedMode = String(FocusModes.createMode("New mode"))
}
}
}
}
SettingsCard {
title: "Focus sessions"
subtitle: "A timed, workspace-bound sprint — separate from modes, and it owns Do Not Disturb while it runs."
SegmentRow {
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
options: [
{ value: 25, label: "25 min" },
{ value: 45, label: "45 min" },
{ value: 60, label: "60 min" },
{ value: 90, label: "90 min" }
]
value: DesktopPreferences.get("focusDurationMinutes")
onSelected: value => SystemSettings.commitPreference("focusDurationMinutes", value)
}
SettingRow {
label: "Keep the machine awake"
detail: Caffeine.enabled
? "The display will not blank or lock while this is on"
: "Caffeine — outside of sessions and modes too"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Caffeine.enabled
onToggled: value => Caffeine.enabled = value
}
}
SettingRow {
label: FocusSession.active
? `Active on ${FocusSession.workspaceLabel}`
: "No session running"
detail: FocusSession.active
? `${FocusSession.remainingText} remaining`
: "Start one without leaving Settings"
divider: false
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "Show controls" : "Start focus"
tone: FocusSession.active ? "normal" : "accent"
onClicked: FocusSession.reveal()
}
}
}
}
@@ -25,17 +25,15 @@ SettingsPage {
// Polling only while this page is on screen. // Polling only while this page is on screen.
Component.onCompleted: { Component.onCompleted: {
if (!Gaming.scanned)
Gaming.refresh(); Gaming.refresh();
Gaming.watching = true; Gaming.watching = true;
} }
Component.onDestruction: Gaming.watching = false Component.onDestruction: Gaming.watching = false
TextRow { ErrorRow {
visible: Gaming.lastError !== ""
label: "Gaming needs attention" label: "Gaming needs attention"
detail: Gaming.lastError message: Gaming.lastError
value: ""
divider: false
} }
// ── Right now ──────────────────────────────────────────────────────────── // ── Right now ────────────────────────────────────────────────────────────
@@ -123,13 +121,13 @@ SettingsPage {
detail: { detail: {
const mode = FocusModes.modes.find(entry => entry.id === "gaming"); const mode = FocusModes.modes.find(entry => entry.id === "gaming");
if (!mode) if (!mode)
return "Handled by a focus mode on Notifications & Focus."; return "Handled by a focus mode on the Focus tab.";
return mode.enabled === true return mode.enabled === true
? "Handled by the Gaming focus mode, which is on." ? "Handled by the Gaming focus mode, which is on."
: "The Gaming focus mode is off, so notifications are not silenced."; : "The Gaming focus mode is off, so notifications are not silenced.";
} }
action: "Open Focus" action: "Open Focus"
onTriggered: ShellState.settingsPage = "notifications" onTriggered: ShellState.openSettings("focus")
} }
ToggleRow { ToggleRow {
@@ -46,8 +46,8 @@ Item {
// nothing in common but the digits. // nothing in common but the digits.
property string readout: "" property string readout: ""
// Two or three colours, left to right. Painted across the whole track when // Two or three colors, left to right. Painted across the whole track when
// `fullTrack` is set -- for a value whose range is the point, like colour // `fullTrack` is set -- for a value whose range is the point, like color
// temperature -- and as the fill alone otherwise. // temperature -- and as the fill alone otherwise.
property var trackColors: [Theme.accent, Theme.accentSecondary] property var trackColors: [Theme.accent, Theme.accentSecondary]
property bool fullTrack: false property bool fullTrack: false
@@ -15,6 +15,13 @@ Item {
readonly property bool repairFailed: root.check.status !== "ok" readonly property bool repairFailed: root.check.status !== "ok"
&& Health.lastRepair.checkId === root.check.id && Health.lastRepair.checkId === root.check.id
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0) && (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0)
readonly property bool rechecking: Health.refreshingId === root.check.id
&& Health.refreshingCheck
// What the row says under its title. Defaults to the check's own detail;
// the page overrides it where it has more to say -- a repair row is given
// the command it would run.
property string detailText: String(root.check.detail ?? "")
objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}` objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
implicitHeight: 62 implicitHeight: 62
@@ -86,7 +93,7 @@ Item {
Text { Text {
width: parent.width width: parent.width
text: root.check.detail text: root.detailText
color: Theme.fgDim color: Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -104,7 +111,7 @@ Item {
Text { Text {
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}` objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: root.displayedStatus() text: root.rechecking ? "Checking…" : root.displayedStatus()
color: root.statusColor(root.check.status) color: root.statusColor(root.check.status)
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -124,6 +131,26 @@ Item {
Keys.onReturnPressed: if (enabled) root.actionRequested(root.check) Keys.onReturnPressed: if (enabled) root.actionRequested(root.check)
Keys.onSpacePressed: if (enabled) root.actionRequested(root.check) Keys.onSpacePressed: if (enabled) root.actionRequested(root.check)
} }
// One probe rather than thirty. After a repair -- or after fixing
// something by hand in a terminal -- the question is whether THIS row
// is happy now, and a full rescan to answer it costs nine seconds and
// re-renders the whole page. Offered on rows that are unhappy, since a
// healthy row has nothing to re-ask.
SettingsButton {
id: recheckButton
objectName: `health-row-recheck:${root.check.id}`
visible: root.issue
text: root.rechecking ? "Checking…" : "Re-check"
enabled: visible && !Health.busy && !Health.refreshingCheck
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: Health.refreshCheck(root.check.id)
Keys.onReturnPressed: if (enabled) Health.refreshCheck(root.check.id)
Keys.onSpacePressed: if (enabled) Health.refreshCheck(root.check.id)
}
} }
Rectangle { Rectangle {
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell
import qs.config import qs.config
import qs.services import qs.services
@@ -8,7 +9,17 @@ SettingsPage {
objectName: "system-health-page" objectName: "system-health-page"
title: "System Health" title: "System Health"
lede: "Checks the parts of the desktop this app owns, and explains what needs attention." lede: root.ledeText
readonly property string ledeText: {
if (Health.checks.length === 0)
return "Checks the parts of the desktop this application owns, and explains what needs attention.";
const count = Health.checks.length + (Health.checks.length === 1 ? " check" : " checks");
if (root.issueChecks.length === 0)
return count + " · everything healthy.";
return count + " · " + root.issueChecks.length
+ (root.issueChecks.length === 1 ? " needs" : " need") + " a look.";
}
property var pendingConfirmation: null property var pendingConfirmation: null
property string instructionTarget: "" property string instructionTarget: ""
@@ -33,7 +44,7 @@ SettingsPage {
{ {
group: "panama-tools", group: "panama-tools",
title: "Desktop tools", title: "Desktop tools",
subtitle: "Tracked links, launcher commands, apps, and inhibitors." subtitle: "Tracked links, launcher commands, applications, and inhibitors."
} }
] ]
readonly property var applicationTargets: ({ readonly property var applicationTargets: ({
@@ -43,11 +54,95 @@ SettingsPage {
"integration.bluebubbles": "bluebubbles" "integration.bluebubbles": "bluebubbles"
}) })
function statusLabel(status: string): string { // What a row says under its title. For a repair, that includes the command
if (status === "ok") return "Healthy"; // the button would run -- said before it runs rather than in a log
if (status === "warning") return "Needs attention"; // afterwards. "Restart Vicinae" is what the button says; what it does is
if (status === "error") return "Action required"; // run something as the person pressing it, and by the time a log could
return "Not set up"; // tell them, the decision is already made.
function repairDetail(check: var): string {
const detail = String(check.detail ?? "");
const command = String(check.repairCommand ?? "");
if (command === "" || check.action?.kind !== "repair")
return detail;
return detail + " · Repair runs: " + command;
}
// ── The Health rung of the escalation ladder ────────────────────────────
//
// A red check with nothing to press is where this page used to end. It can
// tell you the portals are down; it cannot tell you why, and the honest
// next step -- read the journal, correlate against recent updates -- is
// precisely the work an agent is good at. So the row grows one more button
// carrying what the check knows.
//
// Offered only where it is the LAST resort. A check that offers a repair
// has a better answer than a conversation, right up until that repair has
// actually been run and failed.
readonly property bool agentHandoffAvailable: {
if (DesktopPreferences.get("healthAgentHandoff") !== true)
return false;
// No agent chosen is the shipped default, and it means what it says:
// no button, no offer, the page exactly as it was.
const agent = String(DesktopPreferences.get("preferredAgent") ?? "none");
return agent !== "" && agent !== "none";
}
// `repairFailed` is the row's own answer rather than a second computation
// of it here: the status text beside the button already says "Repair
// failed", and two independent readings of one fact is how a button starts
// disagreeing with the words next to it.
function canAskAgent(check: var, repairFailed: bool): bool {
if (!root.agentHandoffAvailable || !check || check.status !== "error")
return false;
return check.action?.kind !== "repair" || repairFailed === true;
}
// Built from the snapshot this page already holds rather than by shelling
// the doctor a second time: these are the same fields `panama doctor check
// <id>` returns, and asking twice would only create a way for the two to
// disagree. The agent is handed that command anyway, so its first move is
// a fresh reading rather than trust in ours.
function agentPrompt(check: var, repairFailed: bool): string {
const lines = [
"A System Health check on this Panama machine is red and I want to know why.",
"",
"What panama doctor reported:",
" check: " + String(check.id) + " (" + String(check.group) + ")",
" title: " + String(check.title),
" status: " + String(check.status),
" detail: " + String(check.detail ?? "")
];
if (check.repairCommand)
lines.push(" repair: " + String(check.repairCommand)
+ (repairFailed ? " — run, and it failed" : " — offered, not yet run"));
else
lines.push(" repair: none offered");
lines.push("");
lines.push("Start with `panama doctor check " + String(check.id) + "` for the current");
lines.push("snapshot, then find the cause: the journal first, then whether a recent");
lines.push("package update or configuration change explains it.");
lines.push("");
lines.push("Diagnosis reads; it does not fix. Anything needing root goes through");
lines.push("`panama-sudo --reason \"why\" -- <command>`, so the password prompt says why.");
return lines.join("\n");
}
function askAgent(check: var, repairFailed: bool): void {
if (!root.canAskAgent(check, repairFailed))
return;
// Reached by path rather than by name: the shell is started by systemd,
// whose environment does not carry the repository's bin directory on
// PATH. Same expansion the panama-crash-watch unit uses.
Quickshell.execDetached(["sh", "-c",
'"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-agent" --prompt '
+ root.shellQuote(root.agentPrompt(check, repairFailed))]);
}
// POSIX single-quoting: everything between the quotes is literal, and the
// only character needing care is the quote itself. A check's detail is
// helper output, not a command, and this keeps it that way.
function shellQuote(text: string): string {
return "'" + String(text).replace(/'/g, "'\\''") + "'";
} }
function checksForGroup(group: string): var { function checksForGroup(group: string): var {
@@ -144,7 +239,12 @@ SettingsPage {
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible); const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible); const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible); const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
const agentHandoffs = root.descendants(root, "health-ask-agent:").filter(button => button.visible);
return { return {
// Empty whenever no agent is chosen, which is the shipped default
// and the state this page has to keep behaving exactly as it did.
agentHandoffs: agentHandoffs.map(button =>
String(button.objectName).slice("health-ask-agent:".length)),
renderedRows: rows.map(row => { renderedRows: rows.map(row => {
const objectName = String(row.objectName); const objectName = String(row.objectName);
const parts = objectName.split(":"); const parts = objectName.split(":");
@@ -279,16 +379,53 @@ SettingsPage {
id: issueRepeater id: issueRepeater
model: root.issueChecks model: root.issueChecks
HealthCheckRow { // The row plus, where the check has run out of answers,
// the handoff button beside it. The row keeps its own
// layout and yields the width the button takes, so the
// trailing controls never stack on top of each other.
Item {
id: issueEntry
required property var modelData required property var modelData
required property int index required property int index
readonly property bool offersAgent:
root.canAskAgent(issueEntry.modelData, issueRow.repairFailed)
width: issueRows.width width: issueRows.width
check: modelData implicitHeight: issueRow.implicitHeight
HealthCheckRow {
id: issueRow
width: issueEntry.width
- (issueEntry.offersAgent ? askAgentButton.width + 12 : 0)
check: issueEntry.modelData
detailText: root.repairDetail(issueEntry.modelData)
issue: true issue: true
divider: index < issueRepeater.count - 1 divider: issueEntry.index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check) onActionRequested: check => root.handleAction(check)
} }
SettingsButton {
id: askAgentButton
objectName: `health-ask-agent:${issueEntry.modelData.id}`
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: issueEntry.offersAgent
text: "Ask the agent"
enabled: visible && !Health.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.askAgent(issueEntry.modelData, issueRow.repairFailed)
Keys.onReturnPressed: if (enabled)
root.askAgent(issueEntry.modelData, issueRow.repairFailed)
Keys.onSpacePressed: if (enabled)
root.askAgent(issueEntry.modelData, issueRow.repairFailed)
}
}
} }
} }
} }
@@ -379,6 +516,7 @@ SettingsPage {
width: groupRows.width width: groupRows.width
check: modelData check: modelData
detailText: root.repairDetail(modelData)
divider: index < groupRepeater.count - 1 divider: index < groupRepeater.count - 1
onActionRequested: check => root.handleAction(check) onActionRequested: check => root.handleAction(check)
} }
@@ -388,47 +526,47 @@ SettingsPage {
} }
} }
// Copying the report lives on the hero, where it has always been. Saving
// one is the other half: a clipboard survives until the next copy, and a
// report somebody is going to attach to a message has to be a file.
SettingsCard { SettingsCard {
title: "Fedora system settings" title: "Report"
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels." subtitle: "Everything the last check saw, with the repair commands, redacted the same way the clipboard copy is."
Item {
width: parent.width
implicitHeight: 42
Text {
anchors.left: parent.left
anchors.right: gnomeSettingsButton.left
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Use GNOME Settings for the parts of the system this app does not manage."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
SettingsButton {
id: gnomeSettingsButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Open GNOME Settings"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openGnomePanel("network")
Keys.onReturnPressed: SystemSettings.openGnomePanel("network")
Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
}
}
ActionRow { ActionRow {
objectName: "health-fedora-handoff:color" objectName: "health-save-report-row"
label: "Color profiles" label: "Save the report to a file"
detail: "Assigning an ICC profile here has no visible effect in this session: the colord daemon that loads it onto a display isn't running under Hyprland" detail: Health.lastSaveResult !== ""
action: "Open color" ? Health.lastSaveResult
onTriggered: SystemSettings.openGnomePanel("color") : Health.defaultReportPath
action: "Save"
enabled: Health.checks.length > 0
divider: false
onTriggered: Health.saveReport(Health.defaultReportPath)
} }
}
// The one thing left that GNOME genuinely owns.
//
// This card used to be headed "Fedora system settings" and led with an
// umbrella button reading "Open GNOME Settings", which landed on the System
// panel. That button was the last door of its kind, and by the end it was
// pointing at a house Panama had bought: Users, Sharing, Printers, Online
// Accounts, Privacy, Region, Color and the whole of Connections are pages
// here now. A generic front door to a settings app you no longer need is
// not a boundary, it is a habit -- so it is gone, and gnome-handoff-contract
// holds the door shut by naming `system` in its OWNED map.
//
// Screen time is the exception, and it is a real one: GNOME's wellbeing
// panel does something Panama does not, and that button genuinely works.
// Color profiles used to sit here too, with a detail line explaining that
// pressing the button changed nothing, because the colord daemon that
// applies an ICC profile is not running under Hyprland. A handoff that
// documents its own uselessness is a dead button with an apology attached,
// so that one went first.
SettingsCard {
title: "Digital wellbeing"
subtitle: "Screen time and break reminders are GNOME's, and this is the one panel of theirs that still does something Panama does not."
ActionRow { ActionRow {
objectName: "health-fedora-handoff:wellbeing" objectName: "health-fedora-handoff:wellbeing"
@@ -8,41 +8,36 @@ SettingsCard {
objectName: "health-summary" objectName: "health-summary"
implicitHeight: 126 implicitHeight: 126
readonly property int observationCount: Health.summary.warnings + Health.summary.errors // The verdict itself is Health's, not this card's — see Health.headlineState
readonly property string heroTitle: { // for why it can only be decided in one place. This card renders it and
if (Health.diagnosticUnavailable) // adds the long-form detail underneath.
return "Health check unavailable"; readonly property int observationCount: Health.observationCount
if (Health.checks.length === 0) readonly property string heroTitle: Health.headline
return "Checking the desktop";
if (Health.status === "error")
return "Action required";
if (Health.status === "warning")
return "Needs attention";
return "Healthy";
}
readonly property string heroDetail: { readonly property string heroDetail: {
if (Health.diagnosticUnavailable) switch (Health.headlineState) {
case "unavailable":
return Health.lastError || "The latest health check could not be completed."; return Health.lastError || "The latest health check could not be completed.";
if (Health.checks.length === 0) case "checking":
return "Checking the desktop services, tools, and integrations this app owns."; return "Checking the desktop services, tools, and integrations this application owns.";
if (Health.status === "error") case "error":
return root.observationCount === 1 return root.observationCount === 1
? "One part of the desktop needs action." ? "One part of the desktop needs action."
: `${root.observationCount} parts of the desktop need action.`; : `${root.observationCount} parts of the desktop need action.`;
if (Health.status === "warning") case "warning":
return root.observationCount === 1 return root.observationCount === 1
? "Your desktop is working. One feature needs a decision." ? "Your desktop is working. One feature needs a decision."
: `Your desktop is working. ${root.observationCount} features need a decision.`; : `Your desktop is working. ${root.observationCount} features need a decision.`;
default:
return "Desktop services and tools are working normally."; return "Desktop services and tools are working normally.";
} }
}
readonly property color statusColor: { readonly property color statusColor: {
if (Health.diagnosticUnavailable || Health.status === "error") switch (Health.tone) {
return Theme.danger; case "danger": return Theme.danger;
if (Health.status === "warning") case "warn": return Theme.warn;
return Theme.warn; case "muted": return Theme.fgMuted;
if (Health.checks.length === 0) default: return Theme.ok;
return Theme.fgMuted; }
return Theme.ok;
} }
function scanTime(): string { function scanTime(): string {
@@ -65,7 +65,7 @@ Rectangle {
id: aliasFrame id: aliasFrame
anchors.left: reorderControls.right anchors.left: reorderControls.right
anchors.leftMargin: 10 anchors.leftMargin: 10
anchors.right: removeButton.left anchors.right: removeConfirm.left
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 12 anchors.topMargin: 12
@@ -107,53 +107,42 @@ Rectangle {
} }
} }
// The line under the field says which light this is; armed, it says what
// pressing again costs instead, because the alias only lives in this
// favourite and there is nowhere else to read it back from.
Text { Text {
anchors.left: aliasFrame.left anchors.left: aliasFrame.left
anchors.right: removeButton.left anchors.right: removeConfirm.left
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.top: aliasFrame.bottom anchors.top: aliasFrame.bottom
anchors.topMargin: 7 anchors.topMargin: 7
text: root.sourceName text: removeConfirm.armed
color: Theme.fgDim ? "Drops it from My Home — the name you gave it goes too"
: root.sourceName
color: removeConfirm.armed ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight wrapMode: removeConfirm.armed ? Text.WordWrap : Text.NoWrap
elide: removeConfirm.armed ? Text.ElideNone : Text.ElideRight
} }
Rectangle { StatusBadge {
anchors.left: aliasFrame.left anchors.left: aliasFrame.left
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.bottomMargin: 10 anchors.bottomMargin: 10
width: badgeCopy.implicitWidth + 14
height: 21
radius: Theme.pillRadius
visible: root.featured visible: root.featured
color: Theme.alpha(Theme.accent, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.2)
Text {
id: badgeCopy
anchors.centerIn: parent
text: "Control Center" text: "Control Center"
color: Theme.accent tone: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
}
} }
SettingsButton { ConfirmAction {
id: removeButton id: removeConfirm
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 11 anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Remove" actionId: "home-favorite-remove:" + root.favorite.id
activeFocusOnTab: true armText: "Remove…"
border.width: activeFocus ? 2 : 1 confirmText: "Remove it"
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) onConfirmed: root.removeRequested(root.favorite.id)
onClicked: root.removeRequested(root.favorite.id)
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
} }
} }
@@ -93,25 +93,11 @@ Rectangle {
elide: Text.ElideRight elide: Text.ElideRight
} }
Rectangle { StatusBadge {
visible: root.featured visible: root.featured
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: featuredCaption.implicitWidth + 16
height: 19
radius: Theme.pillRadius
color: Theme.alpha(Theme.accent, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.2)
Text {
id: featuredCaption
anchors.centerIn: parent
text: "Control Center" text: "Control Center"
color: Theme.accent tone: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
}
} }
} }
@@ -247,7 +247,7 @@ SettingsPage {
TextRow { TextRow {
visible: root.findings.length === 0 visible: root.findings.length === 0
label: "Nothing needs your attention" label: "Nothing needs your attention"
detail: "Anything worth acting on would appear here." detail: "Anything worth acting on would appear here"
value: "" value: ""
divider: false divider: false
} }
@@ -0,0 +1,485 @@
// One picture of what the machine does while it sits untouched.
//
// The Power page used to show three sliders stacked in a column and leave you
// to hold the relationship between them in your head: blank at 5, lock at 10,
// suspend never. The numbers were never the problem -- what was missing is
// that they are one sequence, and that some orderings of that sequence are
// nonsense. The sliders stay, because they are still the precise way to set a
// number; this draws the same three values as the sequence they actually are,
// and puts the ordering warnings on the picture instead of in a card further
// down that nobody reads.
//
// ── The scale ────────────────────────────────────────────────────────────────
//
// The axis is a FIXED piecewise-linear time scale, not one derived from the
// current values. That distinction matters more than it sounds. A scale
// computed from the stops means dragging a stop moves the scale, which moves
// the stop, under a pointer that has not moved -- the drag chases itself and
// snapping becomes unpredictable. Fixing the breakpoints costs a little
// proportionality at the long end and buys a drag that behaves.
//
// Within each band the mapping is exactly proportional. The first quarter of
// an hour gets nearly half the track because that is where every timing anyone
// actually sets lives; the eight-hour tail gets what is left.
//
// ── "Never" ──────────────────────────────────────────────────────────────────
//
// Never is not zero minutes on that scale -- it is the absence of the event, so
// putting it at the origin would draw "the screen never blanks" as "the screen
// blanks immediately". Never stops park on a reserved shelf past the end of the
// scale, drawn muted, which is also what makes dragging a stop off the right
// end mean "stop doing this" and dragging it back mean "start again".
//
// ── Delegate lifetime ────────────────────────────────────────────────────────
//
// There is deliberately no Repeater here. The Displays arrangement canvas
// learned the hard way that a model rebuilt by the drag itself destroys the
// delegate under the pointer on its first millimetre of travel. There are
// exactly three stops and they are three declared instances, so the item being
// dragged cannot be replaced mid-gesture by anything.
import QtQuick
import qs.config
import qs.services
Item {
id: root
// Which schema keys this timeline reads and writes. The page swaps them for
// the battery set when the charger comes out, so the picture always draws
// the timings that are actually in force.
property string blankKey: "screenBlankMinutes"
property string lockKey: "lockMinutes"
property string suspendKey: "suspendMinutes"
// Minutes to fraction of the track. Monotone by construction, and inverted
// below so a pointer position can be turned back into minutes.
readonly property var bands: [
{ minutes: 15, frac: 0.46 },
{ minutes: 60, frac: 0.74 },
{ minutes: 480, frac: 0.88 }
]
// Everything to the right of this is the Never shelf.
readonly property real neverStart: 0.88
// Room for a knob to sit on either end without being clipped. The track's
// geometry lives on the root rather than being read off the Rectangle's id,
// because the stop below is an inline component and reaching sideways into
// a sibling id from one is not something to rely on.
readonly property real trackLeft: 10
readonly property real trackSpan: Math.max(1, root.width - 2 * root.trackLeft)
readonly property real trackTop: 20
readonly property real trackThickness: 8
// Roughly how much room a stop's label wants. Used only to decide whether
// two labels would collide and one should drop to the next line.
readonly property real labelSpan: 80
// ── Drag state ───────────────────────────────────────────────────────────
//
// One stop at a time. `dragMinutes` is the snapped value the drag is
// proposing; `dragFrac` is the raw pointer position, used only to keep a
// knob that has landed on the Never shelf under the finger instead of
// jumping to the shelf slot it will occupy once the drag ends.
property string dragKey: ""
property real dragMinutes: 0
property real dragFrac: 0
readonly property bool dragging: root.dragKey !== ""
width: parent ? parent.width : 620
implicitHeight: 36 + root.labelRows * 32 + (warnings.visible ? warnings.implicitHeight + 6 : 0)
// ── Scale ────────────────────────────────────────────────────────────────
function fracFor(minutes: real): real {
const value = Math.max(0, Math.min(480, minutes));
let prevMinutes = 0;
let prevFrac = 0;
for (const band of root.bands) {
if (value <= band.minutes) {
const span = band.minutes - prevMinutes;
const ratio = span > 0 ? (value - prevMinutes) / span : 0;
return prevFrac + ratio * (band.frac - prevFrac);
}
prevMinutes = band.minutes;
prevFrac = band.frac;
}
return root.neverStart;
}
function minutesFor(frac: real): real {
const value = Math.max(0, Math.min(root.neverStart, frac));
let prevMinutes = 0;
let prevFrac = 0;
for (const band of root.bands) {
if (value <= band.frac) {
const span = band.frac - prevFrac;
const ratio = span > 0 ? (value - prevFrac) / span : 0;
return prevMinutes + ratio * (band.minutes - prevMinutes);
}
prevMinutes = band.minutes;
prevFrac = band.frac;
}
return 480;
}
// The schema owns the range and the step, so a drag can never propose a
// value the store would refuse.
function snap(key: string, minutes: real): real {
const spec = PreferenceSchema.spec(key);
const step = spec && spec.step ? spec.step : 1;
const lower = spec && spec.min !== undefined ? spec.min : 0;
const upper = spec && spec.max !== undefined ? spec.max : 480;
return Math.max(lower, Math.min(upper, Math.round(minutes / step) * step));
}
function stepOf(key: string): real {
const spec = PreferenceSchema.spec(key);
return spec && spec.step ? spec.step : 1;
}
// What the timeline should draw for a key: the drag's proposal while one is
// in flight, the stored value otherwise.
function minutesOf(key: string): real {
if (key === root.dragKey)
return root.dragMinutes;
const stored = DesktopPreferences.get(key);
return typeof stored === "number" ? stored : 0;
}
function caption(minutes: real): string {
return minutes <= 0 ? "Never" : minutes + " min";
}
// ── Layout ───────────────────────────────────────────────────────────────
//
// Three entries in event order, each carrying where its stop sits and which
// label line it belongs on. Recomputed whenever a value or the width
// changes; nothing here owns any state.
readonly property var stops: {
const span = root.trackSpan;
const entries = [
{ key: root.blankKey, title: "Screen off" },
{ key: root.lockKey, title: "Lock" },
{ key: root.suspendKey, title: "Suspend" }
].map(entry => {
const minutes = root.minutesOf(entry.key);
return { key: entry.key, title: entry.title, minutes: minutes, never: minutes <= 0 };
});
// Never stops share the shelf, spread across it in event order so two
// of them do not land on the same pixel.
const shelf = entries.filter(entry => entry.never).length;
let taken = 0;
for (const entry of entries) {
if (entry.never) {
entry.frac = root.neverStart
+ (taken + 0.5) / Math.max(1, shelf) * (1 - root.neverStart);
taken += 1;
} else {
entry.frac = root.fracFor(entry.minutes);
}
}
// A label drops to the next line only when it would otherwise overlap
// the last one placed on this one, so the ordinary case stays a single
// row and the degenerate one -- three stops crowded onto the shelf --
// stays readable instead of printing over itself.
const ordered = entries.slice().sort((a, b) => a.frac - b.frac);
let lastByRow = [-root.labelSpan, -root.labelSpan, -root.labelSpan];
for (const entry of ordered) {
const x = entry.frac * span;
let row = 0;
while (row < 2 && (x - lastByRow[row]) < root.labelSpan)
row += 1;
entry.row = row;
lastByRow[row] = x;
}
return entries;
}
readonly property int labelRows:
1 + root.stops.reduce((deepest, entry) => Math.max(deepest, entry.row), 0)
// ── Orderings that do not mean what they look like ───────────────────────
readonly property real blankMinutes: root.minutesOf(root.blankKey)
readonly property real lockMinutes: root.minutesOf(root.lockKey)
readonly property real suspendMinutes: root.minutesOf(root.suspendKey)
readonly property bool lockBeforeBlank: root.lockMinutes > 0
&& root.blankMinutes > 0
&& root.lockMinutes < root.blankMinutes
readonly property bool suspendBeforeLock: root.suspendMinutes > 0
&& root.lockMinutes > 0
&& root.suspendMinutes < root.lockMinutes
// ── Writing ──────────────────────────────────────────────────────────────
//
// Same shape as SliderRow: the picture follows the pointer immediately, the
// store is written after a short quiet period, and the drag value is handed
// back a moment after that so a refused write snaps the stop to what is
// really set rather than leaving it where the pointer left it.
function propose(key: string, minutes: real, frac: real): void {
root.dragKey = key;
root.dragMinutes = root.snap(key, minutes);
root.dragFrac = Math.max(0, Math.min(1, frac));
release.stop();
commit.restart();
}
function settle(): void {
if (root.dragKey === "")
return;
commit.stop();
SystemSettings.commitPreference(root.dragKey, root.dragMinutes);
release.restart();
}
Timer {
id: commit
interval: 140
onTriggered: {
if (root.dragKey !== "")
SystemSettings.commitPreference(root.dragKey, root.dragMinutes);
}
}
Timer {
id: release
interval: 160
onTriggered: root.dragKey = ""
}
// ── The picture ──────────────────────────────────────────────────────────
Text {
x: root.trackLeft
y: 0
text: "Active"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.7
}
Rectangle {
id: track
x: root.trackLeft
y: root.trackTop
width: root.trackSpan
height: root.trackThickness
radius: root.trackThickness / 2
border.width: 0
// Blue leads into orchid and fades out toward the far end, so the
// track reads as "awake, then less so" rather than as a progress bar.
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.42) }
GradientStop { position: 0.6; color: Theme.alpha(Theme.accentSecondary, 0.34) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.fgMuted, 0.26) }
}
// Where the scale stops and the Never shelf begins.
Rectangle {
x: root.neverStart * track.width - width / 2
anchors.verticalCenter: parent.verticalCenter
width: 2
height: 16
radius: 1
border.width: 0
color: Theme.alpha(Theme.fg, 0.18)
}
}
// A stop and its label. Declared three times rather than repeated, so a
// value change cannot destroy the one under the pointer.
component Stop: Item {
id: stop
required property int slot
readonly property var entry: root.stops[stop.slot]
readonly property bool never: stop.entry ? stop.entry.never : true
readonly property bool held: root.dragging && !!stop.entry && root.dragKey === stop.entry.key
// While a knob is being dragged onto the shelf its position comes from
// the pointer, because the shelf slot it will end up in is decided by
// how many other stops are already there.
readonly property real frac: {
if (!stop.entry)
return 0;
if (stop.held && stop.never)
return Math.max(root.neverStart, Math.min(1, root.dragFrac));
return stop.entry.frac;
}
readonly property real centre: root.trackLeft + stop.frac * root.trackSpan
anchors.fill: parent
function nudge(direction: int): void {
if (!stop.entry)
return;
const step = root.stepOf(stop.entry.key);
const next = stop.entry.minutes + direction * step;
root.propose(stop.entry.key, Math.max(0, next), root.fracFor(Math.max(0, next)));
root.settle();
}
Rectangle {
id: knob
x: stop.centre - width / 2
y: root.trackTop + root.trackThickness / 2 - height / 2
width: 18
height: 18
radius: 9
color: stop.never ? Theme.fgMuted : Theme.fg
border.width: 3
border.color: Theme.bg
scale: stop.held || knobHover.hovered ? 1.12 : 1
activeFocusOnTab: true
Accessible.role: Accessible.Slider
Accessible.name: (stop.entry ? stop.entry.title : "")
+ ", " + root.caption(stop.entry ? stop.entry.minutes : 0)
Behavior on scale {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutQuad }
}
Rectangle {
anchors.centerIn: parent
width: 26
height: 26
radius: 13
border.width: 2
border.color: Theme.accentSecondary
color: "transparent"
visible: knob.activeFocus
}
Keys.onLeftPressed: stop.nudge(-1)
Keys.onRightPressed: stop.nudge(1)
HoverHandler {
id: knobHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: knob.forceActiveFocus()
}
// target: null and explicit translation maths, the same shape the
// display arrangement uses: letting the handler move the item would
// fight the binding that puts the knob where the value says.
DragHandler {
id: drag
target: null
yAxis.enabled: false
property real startFrac: 0
onActiveChanged: {
if (active) {
drag.startFrac = stop.frac;
knob.forceActiveFocus();
} else {
root.settle();
}
}
onTranslationChanged: {
if (!drag.active || !stop.entry || root.trackSpan <= 0)
return;
const frac = Math.max(0, Math.min(1,
drag.startFrac + drag.translation.x / root.trackSpan));
// Past the end of the scale is not "eight hours and a bit"
// -- it is the event being switched off.
const minutes = frac >= root.neverStart ? 0 : root.minutesFor(frac);
root.propose(stop.entry.key, minutes, frac);
}
}
}
Column {
id: label
readonly property int row: stop.entry ? stop.entry.row : 0
x: Math.max(0, Math.min(root.width - width, stop.centre - width / 2))
y: 36 + label.row * 32
width: root.labelSpan
spacing: 1
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: stop.entry ? stop.entry.title : ""
color: stop.never ? Theme.fgMuted : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.caption(stop.entry ? stop.entry.minutes : 0)
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
elide: Text.ElideRight
}
}
}
Stop { slot: 0 }
Stop { slot: 1 }
Stop { slot: 2 }
// ── Warnings, on the picture rather than beside it ───────────────────────
Column {
id: warnings
x: root.trackLeft
y: 36 + root.labelRows * 32
width: Math.max(1, root.width - 2 * root.trackLeft)
spacing: 3
visible: root.lockBeforeBlank || root.suspendBeforeLock
Text {
width: parent.width
visible: root.lockBeforeBlank
text: "Locks at " + root.caption(root.lockMinutes) + ", before the screen turns off at "
+ root.caption(root.blankMinutes) + " — it works, but the lock screen stays lit for the difference."
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: root.suspendBeforeLock
text: "Suspends at " + root.caption(root.suspendMinutes) + ", before the lock at "
+ root.caption(root.lockMinutes) + " — the idle lock never fires, so locking depends on “Lock before sleeping” below."
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
@@ -0,0 +1,180 @@
// Somewhere to try a pointer change before deciding to keep it.
//
// Pointer speed, acceleration, scroll direction and scroll speed are all
// settings you cannot read: the number means nothing, and the only honest test
// is moving the pointer. Every one of them applies live, so this is simply a
// place to move it that is not somebody's document.
//
// Entirely local. Nothing here writes a preference or touches the compositor,
// and the scribble is not saved anywhere -- it exists for the length of a
// question ("is that too fast?") and is thrown away.
//
// The canvas repaints only when the pointer has actually moved across it,
// driven from the motion handler rather than a timer. A settings page that
// repainted continuously would be a persistent GPU load on a high-refresh
// display, in return for a picture that had not changed.
import QtQuick
import qs.config
Column {
id: root
width: parent ? parent.width : 620
spacing: 10
Rectangle {
id: pad
width: parent.width
height: 150
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
clip: true
// Segments drawn since the last paint. The canvas keeps what it has
// already been given, so each paint adds the new piece of the stroke
// instead of redrawing the whole scribble.
property var pending: []
property bool wiping: false
property bool drawn: false
Canvas {
id: scribble
anchors.fill: parent
renderStrategy: Canvas.Immediate
onPaint: {
const context = scribble.getContext("2d");
if (pad.wiping) {
context.reset();
pad.wiping = false;
pad.pending = [];
return;
}
context.strokeStyle = Theme.accent;
context.lineWidth = 2.4;
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
for (const segment of pad.pending) {
context.moveTo(segment.fromX, segment.fromY);
context.lineTo(segment.toX, segment.toY);
}
context.stroke();
pad.pending = [];
}
}
Text {
anchors.centerIn: parent
visible: !pad.drawn
text: "Draw here to feel pointer speed"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
MouseArea {
id: pointer
property real lastX: 0
property real lastY: 0
anchors.fill: parent
cursorShape: Qt.CrossCursor
// The page is a Flickable, which would otherwise take the drag off
// this and scroll instead of drawing.
preventStealing: true
onPressed: mouse => {
pointer.lastX = mouse.x;
pointer.lastY = mouse.y;
pad.drawn = true;
}
onPositionChanged: mouse => {
const segments = pad.pending;
segments.push({
fromX: pointer.lastX,
fromY: pointer.lastY,
toX: mouse.x,
toY: mouse.y
});
pad.pending = segments;
pointer.lastX = mouse.x;
pointer.lastY = mouse.y;
scribble.requestPaint();
}
}
SettingsButton {
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
visible: pad.drawn
text: "Clear"
onClicked: {
pad.wiping = true;
pad.drawn = false;
scribble.requestPaint();
}
}
}
Rectangle {
width: parent.width
height: 110
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
clip: true
Flickable {
id: strip
anchors.fill: parent
anchors.margins: 12
contentWidth: width
contentHeight: lines.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: lines
width: parent.width
spacing: 6
Repeater {
model: [
"Scroll here to feel scroll speed and direction.",
"Natural scrolling moves the content with your fingers.",
"The quick brown fox jumps over the lazy dog.",
"Line four.",
"Line five.",
"Line six.",
"Line seven — still scrolling.",
"Line eight.",
"Line nine.",
"Line ten, the bottom."
]
Text {
required property var modelData
width: lines.width
text: String(modelData)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
}
}
@@ -0,0 +1,383 @@
// One installed application: a line you can read, unfolding in place into
// everything this desktop can honestly say about it.
//
// Built like NotificationAppRow rather than out of SettingRow, for the same
// reason -- the head carries the application's own icon and a badge saying
// where it came from, and neither fits a row whose trailing slot is one fixed
// width. What is different here is that the body's contents depend on where the
// application came from: a Flatpak can be inspected and removed, a system
// package can only be described.
//
// Nothing in here writes. Every control leaves through a signal so the page
// owns the service calls, which is what keeps the uninstall confirmation a
// single piece of page state rather than something each row remembers for
// itself.
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.config
Column {
id: root
// { entryId, name, icon, kind: "flatpak"|"system", flatpakId, size }
required property var app
property bool expanded: false
property bool divider: true
property bool busy: false
// Whether ~/.config/autostart currently starts this application.
property bool autostart: false
// The helper's curated permission summary, as a list of human strings.
// Empty while it is still being read, which the row says rather than
// showing a blank line.
property var permissionSummary: []
property bool permissionsKnown: false
property bool flatsealAvailable: false
// Jump chips appear only where a rule for this application actually
// exists somewhere else. A chip that lands on a page with nothing about
// this application on it is worse than no chip.
property bool hasNotificationRule: false
property bool hasSoundRule: false
property bool hasPrivacyRule: false
// Set by the page while this row's uninstall is one press from happening.
property bool confirmingUninstall: false
signal activated
signal autostartToggled(bool value)
signal jumped(string page)
signal flatsealRequested
signal uninstallArmed
signal uninstallCancelled
signal uninstallConfirmed
readonly property string appName: String(root.app?.name ?? "")
readonly property string entryId: String(root.app?.entryId ?? "")
readonly property string flatpakId: String(root.app?.flatpakId ?? "")
readonly property bool flatpak: String(root.app?.kind ?? "") === "flatpak"
// The dnf package name, for the refusal row's exact command. The desktop
// entry id is the closest thing to it this desktop can know without asking
// rpm about every application on the machine, so the row says "the package
// that provides" rather than claiming the name is the package.
readonly property string systemName: {
const id = root.entryId.replace(/\.desktop$/, "");
const short = id.split(".").pop();
return short !== "" ? short : id;
}
// `sizeBytes` when the helper could turn flatpak's answer into a number and
// `size` -- flatpak's own "412.5 MB" -- when it could not. Whichever
// arrived is shown as it arrived, rather than one being coerced into the
// other and rounded to nothing.
readonly property string subtitle: {
if (!root.flatpak)
return "Installed by the system package manager";
const bytes = Number(root.app?.sizeBytes ?? 0);
if (Number.isFinite(bytes) && bytes > 0)
return root.flatpakId + " · " + root.formatBytes(bytes);
const text = String(root.app?.size ?? "").trim();
return text === "" || text === "0" ? root.flatpakId : root.flatpakId + " · " + text;
}
function formatBytes(bytes: real): string {
if (!(bytes > 0))
return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let index = 0;
while (value >= 1000 && index < units.length - 1) {
value /= 1000;
index += 1;
}
return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
}
readonly property string iconSource: {
const name = String(root.app?.icon ?? "");
return name === "" ? "" : Quickshell.iconPath(name, true);
}
// A stable color per application, so the tile keeps its identity as the
// list re-sorts or the search narrows it.
readonly property color tileColor: {
const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan,
Theme.green, Theme.orange, Theme.pink];
let hash = 0;
for (let index = 0; index < root.entryId.length; index++)
hash = (hash * 31 + root.entryId.charCodeAt(index)) % 9973;
return palette[hash % palette.length];
}
width: parent ? parent.width : 620
spacing: 0
Item {
id: head
width: parent.width
implicitHeight: Math.max(56, copy.implicitHeight + 20)
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 1
radius: 9
z: -1
visible: headHover.hovered
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Rectangle {
id: tile
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 30
radius: 8
color: root.iconSource === "" ? root.tileColor : "transparent"
border.width: 0
Text {
anchors.centerIn: parent
visible: root.iconSource === ""
text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : "?"
color: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Bold
}
IconImage {
anchors.centerIn: parent
implicitSize: 24
asynchronous: true
visible: root.iconSource !== ""
source: root.iconSource
}
}
Column {
id: copy
anchors.left: tile.right
anchors.leftMargin: 12
anchors.right: chevron.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
width: parent.width
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, Math.max(0, parent.width - badge.width - 8))
text: root.appName
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
// Where it came from, said once and in the same place on every
// row -- the difference decides what the body can offer.
StatusBadge {
id: badge
anchors.verticalCenter: parent.verticalCenter
text: root.flatpak ? "Flatpak" : "System"
tone: root.flatpak ? Theme.cyan : Theme.fgMuted
}
}
Text {
width: parent.width
text: root.subtitle
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
anchors.left: copy.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider && !root.expanded
color: Theme.alpha(Theme.fg, 0.065)
}
HoverHandler {
id: headHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.activated()
}
}
// Indented under the head, so what is inside reads as belonging to the
// application above it rather than to the card.
Column {
id: body
x: 42
width: Math.max(0, parent.width - 42)
visible: root.expanded
// ── What it can reach ────────────────────────────────────────────────
SettingRow {
width: parent.width
visible: root.flatpak
label: "Permissions"
detail: !root.permissionsKnown
? "Reading what this application is allowed to reach…"
: (root.permissionSummary.length > 0
? root.permissionSummary.join(" · ")
: "Nothing beyond its own sandbox")
controlWidth: root.flatsealAvailable ? 130 : 0
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: root.flatsealAvailable
text: "Open Flatseal"
onClicked: root.flatsealRequested()
}
}
// The honest refusal, with the command that does the thing this page
// will not. Removing a system package can take the desktop with it, so
// it happens where the consequences are visible.
TextRow {
width: parent.width
visible: !root.flatpak
label: "Managed by dnf"
detail: "Settings does not remove system packages — removing one can take the desktop"
+ " with it. To remove the package that provides this: sudo dnf remove "
+ root.systemName
value: ""
}
// ── What it does at sign-in ──────────────────────────────────────────
SettingRow {
width: parent.width
label: "Start with the session"
detail: root.autostart
? "An entry in ~/.config/autostart starts this when you sign in"
: "Nothing starts this automatically"
controlWidth: 48
divider: root.flatpak || root.hasNotificationRule
|| root.hasSoundRule || root.hasPrivacyRule
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.autostart
onToggled: value => root.autostartToggled(value)
}
}
// ── Where its other settings live ────────────────────────────────────
SettingRow {
width: parent.width
visible: root.hasNotificationRule || root.hasSoundRule || root.hasPrivacyRule
label: "Elsewhere in Settings"
detail: "This application already has a rule on these pages"
controlWidth: 270
divider: root.flatpak
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasNotificationRule
text: "Notifications"
onClicked: root.jumped("notifications")
}
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasSoundRule
text: "Sound"
onClicked: root.jumped("sound")
}
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasPrivacyRule
text: "Privacy"
onClicked: root.jumped("privacy")
}
}
}
// ── Removing it ──────────────────────────────────────────────────────
SettingRow {
width: parent.width
visible: root.flatpak
label: "Uninstall"
detail: root.confirmingUninstall
? "This removes the application. Data it kept under ~/.var/app stays behind."
: "Removes the application, and its runtime if nothing else needs it"
controlWidth: 210
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.confirmingUninstall
text: "Uninstall it"
tone: "danger"
enabled: !root.busy
onClicked: root.uninstallConfirmed()
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: root.confirmingUninstall ? "Keep" : "Uninstall…"
enabled: !root.busy
onClicked: root.confirmingUninstall
? root.uninstallCancelled()
: root.uninstallArmed()
}
}
}
Item { width: 1; height: 6 }
}
}

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