Author SHA1 Message Date
Gabriel Brown 63f4bdb547 Make display confirmation test deterministic 2026-08-18 15:32:59 -04:00
Gabriel Brown 1a93a788fa Verify settings search routing 2026-08-18 15:09:50 -04:00
Gabriel Brown 1debd37d01 Model rendered lock fallback in diagnostics 2026-08-18 15:05:12 -04:00
Gabriel Brown 4341628b2f Route pointer settings to Mouse 2026-08-18 15:02:41 -04:00
Gabriel Brown 7777f5c1aa Verify display position restoration 2026-08-18 15:01:49 -04:00
Gabriel Brown bc7bf3185c Test managed lock fallback integration 2026-08-18 15:00:54 -04:00
Gabriel Brown f3d91c14a1 Align managed lock screen palette 2026-08-18 15:00:19 -04:00
Gabriel Brown 21beb066e1 Integrate Phase 2 desktop settings 2026-08-18 15:00:19 -04:00
Gabriel Brown d2898d8806 Integrate complete display layouts 2026-08-18 15:00:18 -04:00
Gabriel Brown 0c3935f818 Add display identification overlays 2026-08-18 15:00:18 -04:00
Gabriel Brown 70a9d27c98 Add monitor arrangement canvas 2026-08-18 15:00:18 -04:00
Gabriel Brown 5cf2943ccf Apply monitor layouts transactionally 2026-08-18 15:00:18 -04:00
Gabriel Brown d0d9e196b0 Persist complete monitor layouts 2026-08-18 15:00:18 -04:00
Gabriel Brown 2613768efd Model multi-monitor layout geometry 2026-08-18 15:00:18 -04:00
Gabriel Brown 101797f8f0 Expose wallpaper output status 2026-08-18 15:00:18 -04:00
Gabriel Brown b9336d5930 Restore complete wallpaper policies 2026-08-18 15:00:18 -04:00
Gabriel Brown c36e423890 Add wallpaper mode controls 2026-08-18 15:00:18 -04:00
Gabriel Brown 1c12892252 Add event-driven wallpaper rotation 2026-08-18 15:00:18 -04:00
Gabriel Brown 52818b7290 Verify wallpaper policy application 2026-08-18 15:00:18 -04:00
Gabriel Brown c8004e400a Model wallpaper display policies 2026-08-18 15:00:18 -04:00
Gabriel Brown 3e98cc2216 Integrate managed lock screen recovery 2026-08-18 15:00:18 -04:00
Gabriel Brown a80a6f4dda Add lock screen appearance settings 2026-08-18 15:00:18 -04:00
Gabriel Brown 3520700983 Route session locking through Panama 2026-08-18 15:00:18 -04:00
Gabriel Brown a845534110 Generate managed lock screen configuration 2026-08-18 15:00:18 -04:00
Gabriel Brown db34b1e6ed Add application volume mixer 2026-08-18 15:00:18 -04:00
Gabriel Brown 0f3bddc452 Expose live application audio groups 2026-08-18 15:00:18 -04:00
Gabriel Brown 9aea519e54 Model live application audio streams 2026-08-18 15:00:18 -04:00
Gabriel Brown e5a2a430e0 Plan Phase 2 expectation gaps 2026-08-18 15:00:18 -04:00
Gabriel Brown bfa9c58b09 Design Phase 2 expectation gaps 2026-08-18 15:00:18 -04:00
Gabriel Brown 1360a80f07 Add a visible window switcher
Super+Tab already cycled windows, but nothing was drawn, so you chose
blind and could only confirm the choice by arriving. A visible switcher
is muscle memory for anyone arriving from macOS or GNOME, and it was the
last item of roadmap phase 03 that did not need coordination.

Ordered most-recently-used, not by creation, because that is what makes
the gesture useful: one Tab returns to the window you just came from.
Hyprland does not report an MRU order, so it is tracked from focus
changes and keyed by address, which is the only property stable for a
window's lifetime.

The gesture needs three binds rather than two. Tab steps the selection,
and the switch is committed on Super RELEASE -- the only way the
compositor can say the gesture is over. That bind is on the bare
modifier, so it fires on every Super release in the session; commit()
returns immediately when nothing is open, which is what makes it
affordable.

A list of names rather than thumbnails: at a glance you are looking for
"the other terminal", and a row of live previews is slower to read and
far more expensive to draw than this gesture deserves.

The interesting part is the bug. The overlay was built, mapped nothing,
and logged absolutely nothing -- because it declared `required property
var screen` while Variants supplies `modelData`. shell.qml has carried a
comment warning about exactly this since the Bar hit it, and I read that
comment earlier in the same session and still walked into it. A comment
that does not stop the person who read it is an argument for a test, so
per-screen-surface-contract now checks every per-screen delegate takes
its screen from modelData. Verified it catches the exact mistake.

Also fixes a regression from 8be3fc2: settings-pages-contract still
required vitalsIntervalMs on Home, where it no longer is. That contract
was pinning the split-across-two-pages arrangement the same commit
fixed, and I pushed without running it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 14:56:15 -04:00
Gabriel Brown 8be3fc2fdd Right-click a bar widget to open its settings
Four places in the entire shell could reach Settings. The bar, where a
person looks first, was not one of them -- and Pill has routed
right-click to a secondaryActivated signal all along, which nothing
connected, so the gesture did nothing on every widget in the bar.

Each widget now opens the page that owns its settings: the clock and the
calendar reminder open Date & Time, weather opens Home, the vitals
readout opens Appearance, the status glyphs open Network & Devices, the
media readout opens Sound, and the privacy indicator opens Privacy &
Security. Left-click behaviour is untouched.

Two routing bugs found while picking those destinations, both of the
same kind and both invisible from the code, since each page reads
perfectly well on its own:

  weather routed to Appearance while every weather control lives on
  Home, so searching "temperature unit" opened a page without it.

  vitals routed to Appearance, but the refresh interval sat on Home
  while the toggles it governs sat on Appearance -- one concept split
  across two pages, which is exactly what the ownership rule forbids.
  The interval now sits beside the toggles and Home's stub card is gone.

The jump contract guards the failure mode these share. openSettings()
falls back to Home for an unknown page, sensibly and completely
silently, so a typo or a later rename turns a right-click into "opens
the wrong page" with nothing logged. It also fails a Pill-based bar
widget that leaves right-click unconnected, since that is how the
gesture came to be inert everywhere in the first place.

A third instance of the routing bug is still open: followMouse and
pointerSensitivity sit in the input group, which routes to Keyboard,
while both render on Mouse. Fixing it is a two-line group change in
PreferenceSchema.qml, which codex currently owns, so the contract that
catches all three lands with that fix rather than red.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 14:46:04 -04:00
Gabriel Brown 2d69ce7648 Make the lock screen follow the colour scheme
hyprlock.conf shipped with Tokyo Night Moon hardcoded in six places, so
choosing light mode left the lock screen dark. Every other surface had
been taught to follow the scheme this week -- kitty, GTK, the launcher,
btop, tmux, neovim -- and this was the one left, which is unfortunate,
because it is the screen a user sees most often and the worst possible
place to find a theming bug: you discover it while locked out of the
machine and cannot fix it from there.

It is now generated from a template on every scheme change, the same
shape kitty, GTK, tmux and btop already use, and seeded by link-dotfiles
so the first lock of a fresh install is themed rather than falling back
to hyprlock's bare grey default. hyprlock is launched fresh on each lock
(`pidof hyprlock || hyprlock`), so it picks the file up with no restart.

The dark output is byte-identical to the file it replaces, ignoring
comments -- verified by diff -- so nothing changes for anyone already in
dark mode.

One detail worth recording: hyprlock takes rgba(r, g, b, a) in DECIMAL,
not hex, so the template carries "R, G, B" triples where every other
theme file in this repository uses hex. Two values are the exception,
sitting inside Pango markup where hyprlock wants ##rrggbb. Getting
either wrong is not a parse error -- hyprlock ignores the value and uses
its own default, silently.

Which is why this has a contract. It generates both schemes into a
fixture, never the live config, and checks that no placeholder survives
substitution, that every colour is a well-formed decimal triple, that
the Pango values are well-formed hex, that a light lock screen is
actually light, and that the two schemes differ at all. Verified it
catches a hardcoded colour left in the template and a light mode built
from the dark palette, which is the original bug exactly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 14:36:35 -04:00
Gabriel Brown d87f4b6d6a Define Settings ownership boundaries 2026-08-18 13:27:52 -04:00
Gabriel Brown 9fc1fdbbbb Expose wallpaper health status 2026-08-18 13:07:23 -04:00
Gabriel Brown dd8d93c387 Ignore transient Quickshell clients in Health 2026-08-18 13:05:25 -04:00
Gabriel Brown b8832a0174 Preserve the GNOME Caps Lock behavior 2026-08-18 13:01:28 -04:00
Gabriel Brown 3f07d25858 Merge current Panama main 2026-08-18 12:58:40 -04:00
Gabriel Brown df1dcdfad5 Add effective desktop style controls 2026-08-18 12:57:54 -04:00
Gabriel Brown 91f7273f41 Keep pointer focus controls on Mouse 2026-08-18 12:56:24 -04:00
Gabriel Brown f9eba1e8c5 Add curated XKB option presets 2026-08-18 12:55:54 -04:00
Gabriel Brown b1edfb6fe4 Merge Panama health hardening 2026-08-18 12:51:14 -04:00
Gabriel Brown 1afa41526a Add startup application picker 2026-08-18 12:50:42 -04:00
Gabriel Brown 08b16fa03f Fix live network and phone discovery 2026-08-18 12:34:02 -04:00
Gabriel Brown ccf46c40ef Expose 19 more compositor options that only looks.lua could reach
Measured the gap first: of the 38 real Hyprland options Panama's own Lua
sets, only 16 were editable in Settings. Everything else required a text
editor, which is the thing this app exists to stop. This closes most of
that: 66 mapped options now, from 47.

Window shape and shadows on Appearance: corner shape (rounding_power),
focused and fullscreen opacity, shadow falloff and hard-edged shadows.
Window edges, master layout and Hyprland's own notices on Desktop & Dock.

Three of these are corrections rather than additions.

Master layout options existed nowhere, while Settings has offered "Master
and stack" as a choice since this morning -- a layout you can select and
cannot configure is barely a choice. Its card is hidden unless that
layout is actually selected, since settings that do nothing under the
layout you are running are worse than not offering the layout at all.

The four Hyprland notices -- logo, splash, update news, donation nag --
are all turned off by looks.lua on the user's behalf. Defensible as a
default, but not a decision anyone could reverse. They are stored
positively ("show this") and written as Hyprland's `disable_*` through a
new `invert` flag, because a switch labelled "Disable splash text" that
must be ON to hide something is a small cruelty. The Lua does the same
inversion so both sides agree.

Everything new also reads from prefs in looks.lua. Without that these
would apply live and silently revert on the next compositor reload,
which is the failure this codebase keeps designing against.

Two shapes the write path had never seen. Border colours are gradients
and shadow offsets are vec2, and the verifier understood neither -- it
returned false for anything outside int/bool/float/str/css, so both
would have reported every write as rejected. Gradients also need real
care: the stubs declare them as `string|{colors,angle}`, and the string
form carries only ONE stop, so writing "rgba(a) rgba(b) 45deg" as a
string is accepted and keeps the previous value. Verified that directly.
They are also written in one notation and read back in another
(`{colors={"rgba(3b426199)"},angle=45}` becomes `993b4261 45deg`), so
comparison normalises both sides.

Border COLOUR is deliberately not exposed yet. col.inactive_border is
written by ColorScheme on every scheme change, so a user's choice would
be silently overwritten, and col.active_border is the Prism gradient,
which needs a colour control this app does not have. Shadow offset is
left out for the same reason -- the vec2 support is in place for
whenever the widget exists.

Verified each new option applies and reverts against the live
compositor, and that the schema, enum-map, nav, write and commit/reset
contracts all pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 12:27:34 -04:00
Gabriel Brown 60a321e6f4 Harden Health verification isolation 2026-08-18 12:15:45 -04:00
Gabriel Brown 7d5c65be03 Merge remote-tracking branch 'origin/main' into feat/panama-health
# Conflicts:
#	config/dot/quickshell/modules/settings/HealthPage.qml
#	tests/quickshell/health-ui-contract.sh
2026-08-18 11:41:33 -04:00
Gabriel Brown f8e7512e01 Document Panama health and recovery 2026-08-18 11:36:06 -04:00
Gabriel Brown b7ce2c6e43 Assert the installer's process isolation, not its formatting
panama-command-install-contract matched the literal string
`do "$script"; done`, so it failed the moment that loop gained error
reporting and spanned more than one line -- while the property it exists
to protect, each setup stage running in its own process, was unchanged.

It now checks that property directly: the installer must not source
anything under setup/scripts, and must execute them. Verified it still
catches an installer rewritten to source its stages, which the
first attempt at the replacement did not -- the pattern anchored to the
start of a line, and the sourcing appeared mid-line behind an `if`.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:35:52 -04:00
Gabriel Brown 1f62256024 Make a fresh install actually produce a working desktop
Two things stood between this repository and a machine that could
install it.

The installer aborted on its own first question. The hostname prompt
defaults to N, and the N branch ran `exit` -- so pressing Enter, the
obvious answer when you do not want to rename your machine, skipped the
entire installation and said nothing about it. Declining now just
declines. The installer is also safe to re-run, which is the upgrade
path too: it reports which stages failed instead of scrolling the
failure past twenty minutes ago, and restores the idle settings on every
exit path rather than only on success.

The package lists had drifted badly from what the configs and helpers
actually use. jq alone has thirty-one call sites across the helpers and
the contracts; kitty has a full shipped config and a dock pin; tmux and
btop have shipped themes the colour scheme switches; ddcutil, qrencode
and orca back features added today. None were declared. Neither were
fontconfig, pciutils, libselinux-utils, libnotify, wireplumber, fwupd or
python3-dnf, all of which shipped scripts invoke by name. A fresh
machine following this repository's own instructions would have got a
desktop whose features quietly were not there -- the helpers report "not
installed" rather than crashing, which is good behaviour and completely
silent.

So the lists are corrected and a contract now checks that every external
command Panama's scripts invoke is installed by Panama's packages.

Writing it was instructive about its own blind spots. The first version
reported `then`, `esac` and `done` as missing packages, burying the real
findings. The second passed while jq was undeclared, because the pattern
required three characters and jq is two -- a dependency checker with a
blind spot for short names is worse than none, since it reports PASS.
The third missed ddcutil, which is only ever invoked as `timeout 10
ddcutil` and so never appears statement-initial. It now also reads
`command -v X`, which is how these helpers probe for a tool and
therefore the clearest statement of a dependency there is. Verified it
catches jq, ddcutil and qrencode individually.

Also replaced a fixed 0.3s sleep in the write contract with a bounded
wait. It was failing about one run in three with "a rejected value did
not surface an error" when the error had simply not arrived yet, which
reads as a missing guard rather than a slow one.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:29:52 -04:00
Gabriel Brown 4ef2f01baa Merge remote-tracking branch 'origin/main' into feat/panama-health
# Conflicts:
#	config/dot/quickshell/modules/settings/ServicesPage.qml
#	config/dot/quickshell/modules/settings/SettingsShell.qml
#	config/dot/quickshell/modules/settings/SettingsSidebar.qml
2026-08-18 11:23:07 -04:00
Gabriel Brown 6042c0b1c0 Merge codex's System Health and recovery work
Brings in panama-doctor (a 25-check diagnostic with fixture-backed
contracts), a Health service, a System Health page replacing Startup &
Services, and a bar indicator that stays absent until something is
actually degraded. All seven of its contracts pass on the merge.

Three things needed resolving rather than accepting:

The branch predates the debranding, so its user-visible strings still
named the product -- "Panama desktop is healthy", "Restart Panama",
"Panama tools". Rewritten to say the same thing without the name, which
is what the rest of the app now does.

Its Fedora hand-off card was a single button calling openGnomePanel
("network") under a subtitle naming five subjects. Main had already
replaced that with a row per subject, each opening the panel that owns
it, so those rows are ported into HealthPage instead. Printers and
online accounts stay on Network & Devices with the rest of the network
hardware.

That broke its own assertion, which matched the literal
openGnomePanel("network") string. Rewritten rather than reverted: it now
checks the boundary card exists and that every panel named in HealthPage
is one openGnomePanel actually allows, since a name outside the
allow-list opens nothing at all. Verified it catches a plausible-looking
wrong name.

SettingsShell and SettingsSidebar conflicted because both sides added
pages; resolved as the union, keeping its System Health page and live
footer alongside main's Mouse & Touchpad, Privacy & Security, Region &
Language and Online Accounts.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:20:06 -04:00
Gabriel Brown 0c4d132ee1 Fix focus-mode labels that said the opposite of what they did
Found by codex's GNOME Tweaks audit and verified against the compositor:
`hyprctl descriptions` publishes input:follow_mouse as
map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}].

Panama labelled 0 "Never", 1 "Click to focus", 2 "Sloppy focus". So this
desktop, sitting on the shipped value of 1, has been running
focus-follows-pointer the whole time while Settings called it "Click to
focus" -- and the way to actually GET click-to-focus was to choose
"Never". Value 3 was not offered at all. hypr/input.lua carried the same
wrong claim in a comment.

The shipped VALUE is left alone. Which focus mode this desktop should
use is a behaviour decision rather than a correction, and all four are
now reachable from Settings.

Nothing could have caught this. The compositor accepts 1, reads back 1,
and the write contract passes: the value is valid, it just means
something other than the label. The only authority on what each number
MEANS is the compositor, and it publishes that. So enum-hypr-map-contract
now checks every compositor-backed enum against the published map --
that offered values exist, and that published values are offered, since
a missing one is a capability nobody can reach.

Writing it immediately found two more of the same: variable refresh rate
offered Off and fullscreen-games while the compositor publishes four
(always-on and fullscreen-only were unreachable, and fullscreen-only is
what someone wanting VRR for video rather than games wants), and direct
scanout was missing its always-on value. Both now offer everything, with
a detail line per option rather than a bare word.

Verified the contract catches the original followMouse gap and a value
outside the map.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:13:42 -04:00
Gabriel Brown faa9a00716 Close Panama recovery race windows 2026-08-18 11:11:53 -04:00
Gabriel Brown 27af4fd443 Carry the colour scheme into btop and tmux
Two more applications that keep their own palette and so never followed
the desktop. btop was on "Default" and had never been themed at all;
tmux had Tokyo Night Moon hardcoded across seventeen lines, which meant
a dark status bar sitting under a light terminal in light mode.

Both themes are authored rather than borrowed. btop ships a
"tokyo-night" theme, but it is the Night variant (#1a1b26) where the
rest of this desktop is Moon (#222436), and two Tokyo Nights side by
side read as a mistake; it ships no Tokyo Night light theme at all.
Colours come from kitty's theme files so a terminal and what runs inside
it cannot disagree.

tmux follows kitty's shape: the colours move to themes/, tmux.conf
sources a generated current-theme.conf, and running servers are
re-sourced so an open session changes immediately rather than at next
launch. btop is different -- it OWNS btop.conf and rewrites it on exit,
so only the color_theme line is edited in place and the file is not
symlinked into the repository. btop reads its theme once at startup, so
a running instance keeps the old colours; forcing a restart would kill a
process the user is watching.

The tmux light theme took two passes. Mapping the palette role-for-role
put the standard Day accents on a mid-grey panel at 2.74:1 and 2.94:1 --
under the 3:1 floor, on a bar you read at a glance. It now uses Day's
darker accent variants on a lighter panel: 4.42:1 and 4.17:1, and
5.01:1 / 4.73:1 for the light text inside the inverted blocks.

The helper also now reports every target rather than only kitty. Saying
"kitty: applied" while silently skipping three other applications is
how a half-applied theme goes unnoticed.

Not changed: bat is configured with --theme ansi, which follows the
terminal's own palette, so it already tracks kitty with nothing to do.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:04:00 -04:00
Gabriel Brown b16834fea6 Version the settings file so it can be upgraded
The schema is the single source of truth for what a setting IS. It
cannot express what a setting USED to be -- and renaming a key, changing
its units, or splitting one setting into two all leave a stored value
the new schema does not recognise. Unrecognised keys are deliberately
carried through untouched so that rolling back to an older Panama does
not discard a newer version's settings, which means the user's choice
silently stops taking effect with nothing to explain it.

settings.json now carries a schemaVersion, and load() runs every pending
migration before anything reads a value. The list is empty: the point is
that the first breaking schema change becomes a routine edit rather than
an emergency, and Omarchy carries eighty of these.

The behaviours that make it safe to run against a real user's file:

  A file with no schemaVersion predates this and is STAMPED, not
  migrated -- running the list against it would apply upgrades designed
  for schemas it never had.

  A file from a NEWER Panama is left completely alone. Downgrading keys
  is not something this can do correctly, and unknown keys already
  survive, so an older build simply ignores what it does not understand.

  A step that throws stops at the last good version. Skipping past it
  would lose that conversion forever; failing the whole load would cost
  the user every setting.

The list being empty is exactly why this is tested now: the first time
it runs for real will be against somebody's actual settings during an
upgrade, which is a poor moment to find out how it behaves. The harness
supplies fixture steps including one that throws, and the contract pins
all four behaviours above plus the promise that unknown keys survive.

One thing the contract earned its place on: stamping a pre-versioning
file changes it without running any step, so writing only on "migrated"
left the stamp in memory to be redone on every launch. It now writes
whenever the version moves, and explicitly does not write a file from
the future.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:59:34 -04:00
Gabriel Brown 8bfae70284 Harden bounded Panama recovery actions 2026-08-18 10:58:48 -04:00
Gabriel Brown 4279da999a Share a Wi-Fi network by QR code
GNOME's Wi-Fi panel has this and it is the most-used thing in it: the
alternative is reading a passphrase out loud. Network & Devices now shows
a scannable code for any saved network whose passphrase this user can
read.

The image contains the network password in machine-readable form, so
most of the care here is about that rather than about QR codes. It is
written under XDG_RUNTIME_DIR -- 0700, on tmpfs, gone at logout --
rather than /tmp, which is shared; the file is 0600; the passphrase is
piped to qrencode on stdin rather than passed as an argument, because
argv is world-readable through /proc for as long as the process runs;
and it is never printed or included in an error message. Generated on
demand, because producing a code for every saved network up front means
writing images of passwords nobody asked to see.

Enterprise networks are listed as not shareable rather than offered and
broken: there is no passphrase to encode, so the code could not work.

Two bugs the contract caught while being written. Semicolons in an SSID
were not escaped -- the sed replacement had one backslash where its four
neighbours have two, so sed dropped it, and an SSID containing a
semicolon would have produced a QR code describing a different network.
And nmcli's trailing newline landed inside the payload; it decoded here,
but a newline in the middle of a WIFI: URI is not something every phone
tolerates, and that failure would present as "the QR code just doesn't
work on my phone".

The contract stubs nmcli and qrencode, because the real ones would write
this machine's actual Wi-Fi password into a fixture directory. It
asserts the escaping, the absence of a newline, the file and directory
modes, that no temporary payload survives, and that the passphrase never
reaches argv.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:56:01 -04:00
Gabriel Brown 54b8a82803 Give Accessibility a real magnifier, and stop implying the rest is available
The page had two settings and a hand-off card claiming GNOME's stack
provided "screen reader, zoom, and on-screen keyboard". Zoom did not
need handing off at all, and the claim about the rest was optimistic.

Zoom is native now. Hyprland has a real magnifier -- cursor:zoom_factor
follows the pointer -- so it is a slider here rather than a button that
opens another application. Inactive windows can also be dimmed as well
as faded, which is the other thing that makes a focused window
unmistakable. 47 mapped compositor options, from 43.

What is NOT here is the more useful half of the change. Sticky keys,
slow keys, bounce keys and mouse keys are AccessX, an X11 SERVER
feature. XKB under Wayland has no accessx option group at all -- checked
against evdev.lst, which lists altwin, caps, compose, ctrl, grp and the
rest, and nothing resembling accessx -- and Hyprland implements none of
it. The compositor will accept "accessx:enable" as a keyboard option and
store it happily; I verified that, and verified nothing acts on it.

So the page says plainly that they are unavailable in this session
rather than offering switches, and does not point at GNOME's panel for
them either: the daemon that would apply those keys is not running here,
so that hand-off would be just as empty. Orca is offered instead, since
the accessibility bus genuinely does work.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:49:04 -04:00
Gabriel Brown 2e8292599a Add power profiles to Power & Lock
The same Power Saver / Balanced / Performance choice GNOME's Power panel
offers, and the daemon behind it was already running here -- it simply
had no control anywhere in Panama. This machine has been sitting on
"performance" with nothing to say so.

Talks to the net.hadess.PowerProfiles interface rather than to a binary.
Fedora 44 implements it with tuned-ppd instead of power-profiles-daemon,
and powerprofilesctl is not installed at all, so anything shelling out
to that command would have found nothing while the service was right
there. Setting a profile needs no privileges: the daemon accepts a
property write from the active session user.

Not a stored preference. The daemon owns the profile, it survives Panama
restarts, and anything else on the system can change it, so a copy in
settings.json would drift -- the same reasoning as monitor brightness.

PerformanceDegraded is surfaced because it is what makes the setting a
lie: a thermally throttled machine reports "performance" while behaving
otherwise, and that is worth saying out loud.

The contract stubs busctl, because the real daemon is a system service
shared with everything else on the machine and a test that flipped the
daily driver to power-saver and then died would leave it there. It pins
the parsing in particular: busctl renders the Profiles property flat, so
profile names and driver names arrive in one stream, and a pattern loose
enough to match both reports the driver as an extra profile. On this
machine the driver is called "tuned", which reads exactly like a
plausible fourth profile -- verified the contract catches that, and
catches an unvalidated name reaching the system service.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:46:03 -04:00
Gabriel Brown 5562323eb8 Stop the live shell clobbering the write contract
This contract failed intermittently with "a typed batch did not reach
the compositor", reporting Panama's shipped defaults, and passed on
retry. The earlier guard against a lingering harness was not the cause --
no instance was alive.

Adding the writer's own error to the failure message settled it: the
writer reports NO error while the compositor holds defaults. A rejected
write leaves an error behind; a write that succeeded and was then
overwritten does not. Both this contract and the LIVE shell write to the
same compositor, and the running Panama re-applies its own preferences
on any store change -- landing exactly the values that were being
mistaken for "the write never happened".

So the apply is re-issued periodically while waiting, which makes the
test survive being overwritten without weakening what it asserts, and
the failure message now distinguishes the two cases instead of
describing both as a write that never arrived.

The wait is also 10 seconds rather than 4. The write path verifies every
option by reading it back and retries a refused batch, so a loaded
machine legitimately takes longer -- and this runs in a suite alongside
other tests driving the same compositor.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:39:42 -04:00
Gabriel Brown 2cc109f5e1 Add bounded Panama recovery actions 2026-08-18 10:35:18 -04:00
Gabriel Brown 52d896b054 Add an Online Accounts page
GNOME Online Accounts is a daemon plus a D-Bus API, and the daemon
already runs in this session -- gvfs activates it, and all four accounts
on this machine work without gnome-shell involved anywhere. Only the
PANEL was GNOME's. The accounts themselves are ordinary D-Bus objects
that anything may read and modify.

So everything except the initial sign-in is now native: the account
list, per-service toggles for mail, calendar, contacts, files, photos,
music and chat, and removal. That is the whole Online Accounts panel
apart from one OAuth handshake.

Signing in is the exception, and only for OAuth providers. The daemon's
AddAccount takes credentials as an argument -- it stores them, it does
not obtain them -- and the code that runs Google's OAuth exchange lives
in libgoa-backend, which Fedora ships without a GIR binding, so it is
reachable from C only. Reimplementing it would mean our own Google
client credentials. That step is handed to GNOME's panel and the page
says so, because a hand-off the user does not expect reads as a bug.
Password-based providers (Nextcloud, IMAP, WebDAV) could be added
natively later; their credential keys are known now.

Accounts needing re-authentication are surfaced first, which turned up
something immediately: both Google accounts on this machine report
attention_needed, meaning their tokens have expired and they have
stopped syncing. GOA has known that all along and nothing outside its
own panel ever said so.

Every write re-reads the account list rather than assuming it landed.
GOA can refuse, and a toggle that springs back is the honest outcome.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:34:41 -04:00
Gabriel Brown e4409ed6aa Surface the login keyring, and offer to unlock it
The keyring is already unlocked at sign-in exactly as GNOME does it --
pam_gnome_keyring is in GDM's stack and the journal confirms it works
("gnome-keyring-daemon started properly and unlocked keyring"). So there
was no configuration bug to fix. What a bare Hyprland session lacks is
anywhere to see when that has stopped being true.

It stops being true rarely and expensively. gnome-keyring-daemon crashed
once on this machine -- an upstream abort in service_method_open_session,
with a core dump -- and D-Bus then activated a replacement. That
replacement never received the login password, so the keyring was locked
in the middle of a session that had unlocked it correctly at login.
Nothing announces this. What you see instead is a mail account that will
not authenticate, a git push that cannot find its key, or an integration
reporting "not configured", none of which mention keyrings. That is the
same root cause as the Home Assistant token failure earlier.

Privacy & Security now shows the state, offers an Unlock action that
raises the standard password dialog, and reports when the daemon holding
your secrets is a D-Bus replacement rather than PAM's -- because a
replacement that is currently unlocked was unlocked by hand and will not
survive a restart. The password never passes through Panama.

The contract stubs the secret service rather than touching the real one:
locking the login keyring breaks every saved password on the machine and
can only be undone by typing the password into a dialog, so it is not
something a test suite may do to a daily driver. Verified it catches a
helper that misreports locked as unlocked, and one that crashes instead
of reporting a missing service.

Worth recording: a locked keyring makes a NON-INTERACTIVE caller appear
to hang. It is not hung -- it is waiting on a dialog nobody is looking
at, which is exactly how the earlier secret-tool investigation lost an
hour.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:34:41 -04:00
Gabriel Brown f0321432b4 Fix dark mode: the GTK theme it asked for does not exist
Reported symptom: Electron applications and a Chromium-based browser,
all set to follow the system, went light when the desktop went light and
never came back. Nothing reported an error, and the portal was serving
the correct value the whole time.

Cause: ColorScheme set gtk-theme to "Adwaita-dark" for dark and
"Adwaita" for light. Neither is installed on Fedora 44 -- only adw-gtk3
and adw-gtk3-dark are. GTK responds to an unknown theme name by falling
back to its built-in default, which is LIGHT. So asking for light worked
by accident, asking for dark silently produced light, and anything that
takes its cue from the GTK theme rather than the portal stayed light no
matter what org.freedesktop.appearance said. Verified: the portal emits
correctly in both directions, so this was never the portal's fault.

Second cause, the mirror of the first: gtk-3.0/settings.ini and
gtk-4.0/settings.ini were pinned to adw-gtk3-dark and prefer-dark=1 and
never regenerated. Under GNOME that file is ignored because
gnome-settings-daemon publishes over XSETTINGS; under Hyprland nothing
does, so for GTK3 it is authoritative -- and it contradicted the scheme
in light mode. They are now generated from a template on every switch,
gitignored as machine state, and seeded by link-dotfiles, the same shape
kitty's current-theme.conf already uses. These directories are symlinked
into the repository, so writing the live file directly would dirty the
working tree on every theme switch.

The failure was silent by construction, so it gets a test rather than a
comment: gtk-theme-contract asserts every theme name Panama sets is
actually installed, and that the generated GTK config agrees with the
scheme in both directions. Verified it catches both original bugs.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:34:19 -04:00
Gabriel Brown d855a26bd9 Add quiet System Health entry points 2026-08-18 10:19:03 -04:00
Gabriel Brown 607acb0a2d Make About answer what fastfetch answers
About reported four component versions and, since this morning, a short
hardware summary. It now covers what someone actually wants from an
About page or a fastfetch run: operating system, model, hostname,
kernel, uptime, package counts, shell, locale, windowing system,
resolution, processor, graphics, memory, swap, and disk.

Rows are ordered the way fastfetch presents them -- what the system is,
then what is installed on it, then the hardware underneath -- and the
GPU rows are spliced in directly after Processor rather than appended,
because a graphics card listed after "Disk" reads as an afterthought.
Graphics is still joined from GraphicsDevices rather than read a second
time, so the two readouts cannot disagree.

Memory is total, not used. About is not a monitor: a "12.4 GiB used"
figure is stale before it finishes drawing, and the Home page's vitals
readout is where live numbers belong. Disk is the exception because free
space does not move while you look at it.

Package counting is why this whole helper runs on demand -- rpm -qa on a
full workstation is a few thousand lines and takes a moment. Swap and
package counts are omitted entirely when they are zero or the tools are
absent, rather than reported as "0" or "Unknown".

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:09:06 -04:00
Gabriel Brown 9b2fa80ab7 Drop the product branding: this is just Settings
It is the system settings app for this desktop, so it is named the way
one is. The window is "Settings", the titlebar is "Settings", the
wordmark above the search field is gone, the sidebar's last row is
"About", and the status line reads "Desktop is healthy".

Prose followed the same rule. Forty-odd strings explained what "Panama"
does -- "Panama never animates while idle", "Restore Panama defaults",
"Panama looks in ~/Pictures/Wallpapers" -- which is how a product
describes itself, not how a settings panel describes a setting. They now
say what happens. No user-visible "Panama" remains anywhere in the app.

Two consequences worth naming:

The SUPER+I shortcut's description is user-visible, because the
Shortcuts page is generated from it, so that is renamed too. Rebindings
are keyed by shipped chord rather than description, so no existing
override is orphaned by this.

Three contracts matched the window by title and one matched that
shortcut by description; all four are updated. There are no Hyprland
window rules keyed on the title, so nothing about the window's placement
changes.

The desktop entry is now Name=Settings, but the FILE keeps its
panama-settings name, as does the icon: the dock pins applications by
desktop id, and renaming the file would silently unpin it.
dock-pins-contract covers exactly that.

The dated design docs under docs/superpowers keep the old name. They are
a record of what was decided when, and editing them to agree with the
present would make them lie about the past.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:08:55 -04:00
Gabriel Brown 8a0a09dbb4 Fix System Health ledger rendering 2026-08-18 10:08:12 -04:00
Gabriel Brown 816ea68f9a Read DDC/CI from the AUX bus, not the EDID bus
With I2C access granted, the helper still found nothing: every connected
monitor was probed on the wrong bus.

A connector has two. The `ddc` symlink points at the classic I2C line
that carries EDID on HDMI and DVI. DisplayPort carries DDC/CI over the
AUX channel instead, which appears as a child directory of the
connector. Both exist on a DP connector and both resolve, so the wrong
choice looks entirely reasonable and simply finds no monitor: this
machine's DP-2 has ddc -> i2c-5, where ddcutil reports "No monitor
detected", while its AUX child i2c-9 answers VCP 0x10 immediately.

This is the guess the previous commit said was unverified, and it was
wrong in the way that mattered. Enumerating from sysfs is still right --
it gives the connector name Hyprland uses and skips empty connectors --
but it has to prefer the AUX child and fall back to the symlink.

The fixture now mirrors sysfs properly: /sys/class/drm/<connector> is a
SYMLINK to the real device directory, and `find` does not follow the
path it is given. A fixture built from plain directories passes whether
or not the code resolves the symlink first, which is a test that agrees
with itself rather than with the kernel.

Verified on hardware: the Kuycon P20 reports 100%, accepts 70 and
returns to 100, and the media keys move it through codex's OSD path.
Both bus-selection mistakes are now caught by the contract.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 09:51:55 -04:00
Gabriel Brown 3238623934 Build the System Health settings page 2026-08-18 09:50:15 -04:00
Gabriel Brown 21aa9223df Harden Panama health snapshots 2026-08-18 09:30:18 -04:00
Gabriel Brown e2e03252e4 Add Panama health state service 2026-08-18 09:20:16 -04:00
Gabriel Brown 0ca83f74c7 Harden Panama doctor probes 2026-08-18 09:00:35 -04:00
Gabriel Brown d58c431199 Add Panama system health diagnostics 2026-08-18 08:52:49 -04:00
Gabriel Brown 3ff414fb1b Stop two contracts from driving each other's shell
settings-hyprland-write-contract failed in full suite runs and passed on
its own, reporting "a typed batch did not reach the compositor" with
every value at its default.

Both it and settings-commit-reset-contract drive the same harness file,
and Quickshell identifies an instance by its config path -- not by the
environment it was launched with. So when one run's instance has not
fully exited, the other's wait for `ipc show` is satisfied by that
instance's target, and the whole contract then talks to a shell it did
not start.

The two directions fail differently, and the second is the alarming one:

  The write contract lands on the isolated instance, whose compositor
  write seam is deliberately stubbed. Its writes go nowhere, which is
  exactly the symptom above.

  The commit/reset contract lands on the non-isolated instance and
  drives the DAILY DESKTOP's real compositor while believing it is
  isolated.

Both now refuse to start while another instance of that harness is
alive, and say which hazard they are avoiding rather than failing on an
assertion much later.

One trap worth naming, since it bit me writing this: `rg -c` prints
nothing at all when there are no matches, so an unguarded command
substitution yields "" and not "0" -- the first version of the guard
fired on a perfectly clean machine.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:43:59 -04:00
Gabriel Brown 77f8102148 Approve diagnostic ledger health design 2026-08-18 08:39:47 -04:00
Gabriel Brown 0fd59c59e3 Answer GNOME's Search panel, and pin the settings navigation wiring
GNOME's Search panel configures which applications provide results in
gnome-shell's overview and which folders are indexed. gnome-shell does
not run here, so reimplementing those switches would store preferences
that change nothing. Under Panama searching is the launcher's job and
Vicinae carries its own preferences, so the Applications page says where
search lives and how to rebind the keys that open it, rather than
offering settings that would be inert or duplicated.

The nav contract is the more useful half. Adding a settings page means
editing four files, and missing any one of them fails quietly in a
different way: no sidebar row, a row that silently shows Home, a deep
link that redirects to Home, or -- worst -- a missing qmldir entry,
which makes the page "not a type" and takes the entire settings window
down with it. Nothing at runtime cross-checks the four. Having just
added four pages by hand, this checks them statically, and also fails on
a page file that exists but is unreachable.

Verified it catches a missing qmldir registration and a missing
allow-list entry.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:38:54 -04:00
Gabriel Brown b1f9891849 Add multitasking controls, and a light theme for the launcher
Two things, both closing gaps in work that was already reported done.

GNOME's Multitasking panel, in Hyprland's terms: tiling layout, split
behaviour, floating-window snapping, workspace wrap-around and
back-and-forth, whether applications may take focus, and whether the
pointer changes the active display. Hyprland creates and destroys
workspaces as you use them, so there is no fixed count to expose, and
the page says so rather than leaving a conspicuous absence.

The Desktop page described the first two of these as read-only facts --
"Layout: Tiling", "Workspace movement: Dynamic" -- which was never true.
Both are ordinary Hyprland options that happened to have no controls,
and TextRow's own documentation says a setting the user could reasonably
change does not belong in it. Schema defaults are Panama's shipped
values from looks.lua rather than Hyprland's own, so restoring defaults
returns the desktop to how it ships. 43 mapped options now, from 35.

The launcher had no light theme. vicinae.json already selected a theme
per system appearance, but both entries pointed at Moon, so choosing
light mode left the most frequently opened window on the desktop dark --
a hole in the light/dark work, not a missing feature. Day is authored
from the same palette as the kitty Day theme so the two cannot drift,
and link-dotfiles now installs every authored theme rather than only the
dark one, which is why the gap survived being noticed.

Its placeholder colour is not Tokyo Night Day's own: that measures
2.54:1 against the background, below the 3:1 floor for secondary text.
This is 3.25:1, the same value used for neovim's light comments.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:34:05 -04:00
Gabriel Brown 6026bf308b Add Region & Language, and answer "what am I running on" in About
More GNOME Settings parity.

Region & Language is new. The locale is localectl's, and Panama stores no
copy of it -- there is exactly one system locale, so a preference here
would be a second source of truth that drifts the moment anything else
changes it. Codes are resolved against iso-codes into "Portuguese
(Brazil)" the way GNOME does, with the code kept visible because it is
what actually gets written and someone choosing between two Spanish
variants needs to see it. Changing it is privileged and only applies to
programs started afterwards, so the page says a sign-out is needed
rather than claiming the new language is in use.

The service is called SystemLocale, not Locale: QML has a built-in
Locale value type that silently shadows a singleton of that name, and
every binding then reads properties off the wrong thing. The page
rendered empty with nothing but "cannot read property of undefined" to
explain it.

About now answers what GNOME's About answers -- model, processor,
memory, disk, OS, kernel, windowing system -- where before it listed
only Panama's own component versions. Graphics is joined from
GraphicsDevices rather than read again, because two readouts of the same
hardware are two things that can disagree. Placeholder DMI strings
("To Be Filled By O.E.M.") are filtered out, and unreadable facts are
omitted rather than shown as "Unknown".

The Fedora hand-off card was one row listing five subjects that opened
the network panel regardless. Naming a panel and then not opening it
reads as a broken button rather than a deliberate hand-off. Each subject
now opens the panel that owns it, and openGnomePanel takes an optional
subpage so "Users" reaches System's users page the way GNOME's own
desktop entry does, instead of dropping the user on System's front page.
Printers and online accounts are not repeated here; they stay with the
network hardware on Network & Devices.

One bug this surfaced, caught by the hyprland write contract: the Lua
config key and the hyprctl option name genuinely differ for tap to
click. hl.config wants input.touchpad.tap_to_click; getoption answers to
input:touchpad:tap-to-click. Either spelling used for both fails -- a
hyphen is not a Lua identifier, and the underscored name is not a known
option -- which is what the schema's two separate fields are for.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:30:21 -04:00
Gabriel Brown 24d8bfd641 Plan Panama health and recovery 2026-08-18 08:28:20 -04:00
Gabriel Brown 14529c011f Add a Privacy & Security page
Continuing towards GNOME Settings parity. GNOME's Privacy panel covers
screen lock, camera and microphone access, file history, trash, and
device security; Panama had no equivalent page at all, despite already
tracking camera and microphone use for the bar indicator.

Device security is a new read-only readout: Secure Boot, TPM, disk
encryption, SELinux mode, and the firewall. None of these is a
preference -- they are set in firmware, at install time, or by system
policy, and a switch offering to change them would either fail or do
something far-reaching from a control that looks like every other
control. What it answers is "is this machine set up the way I think it
is", which otherwise takes five commands and root. Facts that cannot be
determined report Unknown rather than guessing, because a security
readout that quietly says "fine" when it failed to look is worse than
no readout.

File history and trash retention are deliberately NOT offered as
switches. They are GNOME preferences enforced by gsd-housekeeping, which
does not run in a Hyprland session -- verified, it is not running here.
Toggling them would store a preference, change nothing, and give no sign
of it. They are delegated to GNOME Settings by name instead.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:20:04 -04:00
Gabriel Brown 7378c18ab9 Merge remote-tracking branch 'origin/fix/osd-ddc-brightness' 2026-08-18 08:16:49 -04:00
Gabriel Brown 9ce7040b91 Add a Mouse & Touchpad page and make keyboard layout editable
Working towards parity with GNOME Settings, which splits pointing
devices into their own panel. Panama had pointer speed and focus-follows
buried under a page called "Input & Shortcuts", and had nothing at all
for scroll direction, acceleration profile, scroll speed, left-handed
buttons, or any touchpad setting -- all of which could only be changed
by editing hypr/input.lua by hand, which is the thing this app exists to
stop.

Every new mapping was read back off the running compositor rather than
assumed, and two were not what they look like: touchpad drag lock is an
int with three states, not a switch, and scroll factors are floats even
at their default of exactly 1. Getting either wrong makes every write to
that setting look rejected. The shape contract now covers 35 mapped
options, up from 23.

The touchpad card renders only when a touchpad is attached, which is
what InputDevices is for. On a desktop it would be worse than useless:
every switch on it would appear to work, because the preference is
stored and Hyprland accepts an option for a device class it has no
member of, so the settings would silently affect nothing.

Keyboard layout was read-only text, justified by a note saying changes
needed a compositor reload. That is not true in 0.56.2 -- setting
input:kb_variant through hl.config re-keymaps attached keyboards
immediately, verified by watching active_keymap on a real keyboard
change to "English (US, intl., with dead keys)" and back. So layout,
variant, and options are now real controls, joined by a TextEntryRow
that commits on Enter or focus loss rather than per keystroke, since
half a layout name is a valid string meaning something else.

Rejected input is shown as rejected rather than sanitised: these strings
are serialised into an hl.config payload, where stripping an unexpected
character would turn a typo into a different working setting.

Verified each new pointer option applies and reverts against the live
compositor. Schema, search, commit/reset, and system contracts pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:16:10 -04:00
Gabriel Brown af0ba13573 Design Panama Health and Recovery 2026-08-18 08:15:51 -04:00
Gabriel Brown 09e5f1ad6b Fix external monitor brightness OSD 2026-08-18 08:15:01 -04:00
Gabriel Brown 7541a45e77 Add external monitor brightness over DDC/CI
brightnessctl drives the kernel backlight class, which laptop panels
have and this desktop does not -- it reports only keyboard and NIC LEDs.
So BrightnessControl removed itself and there was no way to dim the
screen from Panama at all. DDC/CI is the channel the buttons on a
monitor's bezel drive, and it is the only brightness an external display
has. Both sources now render a row each, so a machine gets whichever it
actually has, or none.

Displays are enumerated from sysfs rather than `ddcutil detect`. The
kernel publishes the connector-to-bus mapping as
/sys/class/drm/<card>-<connector>/ddc along with whether anything is
plugged in, which beats parsing detect's undocumented brief output,
yields the connector name spelled exactly as Hyprland spells it, and
probes only connectors with a monitor attached -- one bus on this
machine rather than fourteen, where each empty bus costs a timeout.
No model name is read: Hyprland already knows what every output is
called, so the UI joins on the connector instead of keeping a second
source of truth that could disagree with the Displays page.

Writes are debounced, serial, and read back. Serial because DDC/CI has
no arbitration and two ddcutil processes on one bus interleave their
exchanges and both return garbage. Read back because a write is not a
promise: panels clamp to their own range, ignore values while waking
from standby, and drop writes that arrive too fast. Without the read the
slider would show what Panama asked for rather than what the monitor
did, which is the same class of lie as trusting `hyprctl keyword`.

Brightness is deliberately not a stored preference. The monitor
remembers it and the bezel buttons change it behind Panama's back, so
persisting it would mean restoring a value the panel had moved past.

The contract runs against fixtures with ddcutil stubbed and both sysfs
roots redirected, so it never touches a real monitor. Its fixture
reports a maximum of 200 rather than 100 on purpose -- at 100 the
scaling arithmetic is the identity and a helper that ignored the
reported maximum would pass everything. Verified it catches that, plus a
dropped connection-status filter and an unstripped connector prefix.

Not yet confirmed against hardware: this machine cannot open any I2C bus
yet. ddcutil's udev rule grants that through uaccess but only to devices
created after it was installed, so it needs one udevadm trigger. The
helper detects exactly that case and returns the command as its error
rather than reporting "no displays".

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:01:49 -04:00
Gabriel Brown 1a91d00f2c Stop two contracts from latching a broken desktop state
Both tests run against the live session and capture "what it was before"
so they can put it back. Neither checked that what they found was sane,
so one interrupted run poisoned every run after it -- and because each
subsequent run faithfully restored the bad value, the desktop stayed
broken while the failure looked like an ordinary flake.

displays-contract left the monitor at scale 1.25 after a failed revert.
The next run recorded 1.25 as the original and restored the desktop to
it. It now reads the shipped scale out of monitors.lua and refuses to
run when the live display disagrees. A failure to parse that value is
fatal rather than skipped, because silently skipping the check is how
the laundering happened in the first place.

focus-session-expiry kills and restarts the shell mid-session, so an
interrupted run leaves caffeine on with nothing left to turn it off. The
next `focus start` recorded "previously on", handed it back on expiry,
and failed the assertion that caffeine ends off -- identically, forever,
with the desktop unable to idle or lock the entire time. It now refuses
to start unless caffeine is already off, which is the only state in
which the test can tell "restored correctly" from "never released".

Both guards name the exact command to recover with. Verified each fires
on a dirty state and passes on a clean one; caffeine was found latched
on this machine and has been released.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:48:02 -04:00
Gabriel Brown 19063a3e02 Carry the colour scheme into terminals and the editor
The scheme switch already reached everything that reads
org.freedesktop.appearance -- GTK4, Qt6, Chromium, Electron -- because
ColorScheme.qml writes the gsettings key those all watch. Applications
carrying their own palettes did not follow, so choosing light mode left
the two windows actually used all day, kitty and neovim, still dark.

kitty: the 32 colours move out of kitty.conf into themes/, and kitty.conf
ends with `include current-theme.conf`. The generated file is machine
state rather than configuration, so it is gitignored and link-dotfiles
seeds it on install -- otherwise a fresh checkout starts by complaining
about a missing include. Running terminals are re-coloured in place over
their control sockets; a restart is not needed.

neovim: reads settings.json directly, since it neither watches the portal
nor keeps a socket open. Tokyo Night ships Day in the same family as
Moon, so light mode keeps the editor's identity instead of turning it
into a different-looking application. The existing readability overrides
were written against Moon and are now dark-only -- applied to Day they
would have put light grey on a light background, the same problem they
exist to fix, inverted. Light mode gets one override of its own:
tokyonight's shipped comment colour measures 2.54:1 against Day's
background, under the 3:1 floor for secondary text, so it is replaced
with 3.25:1 -- readable, still dimmer than Normal's 4.52:1.

An editor already open when the scheme flips re-applies on FocusGained,
which is cheap and fires exactly when the mismatch would be noticed.

Verified both directions: kitty re-coloured 4 live terminals, and neovim
starts as tokyonight-day with background=light and tokyonight-moon with
background=dark.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:47:51 -04:00
Gabriel Brown 5e6dc9e533 Release stale Caffeine inhibitors 2026-08-18 07:43:15 -04:00
Gabriel Brown ca64d4b9b6 Harden Panama launcher actions 2026-08-18 07:31:11 -04:00
Gabriel Brown b28edcd01f Add Panama commands to the launcher 2026-08-18 07:31:11 -04:00
Gabriel Brown 9c574f9eb7 Add a light mode that keeps the same identity
Light is Tokyo Night Day, the palette's own light variant, rather than
one invented to merely not be dark. Both share the same hues at
different lightness, which is what lets the Prism signature survive the
switch: blue still leads into orchid, it is simply a darker blue on a
lighter ground.

Every colour token became a binding on one boolean, so flipping it
repaints the whole shell without any component needing to know it
happened. That only worked because nothing outside Theme.qml defines a
colour; the two places that did are fixed here.

Surface alphas differ by scheme. The 0.34 that reads as glass over a
dark desktop reads as haze over a light one, and text stops being
legible on it.

Two things that draw on this desktop do not read Panama's store: GTK
applications, which read gsettings, and the compositor, which draws
window borders. A toolbar or a border still wearing the other scheme is
more jarring than either scheme on its own, so ColorScheme pushes the
choice to both. It also pushes at startup, since a scheme chosen in a
previous session would otherwise be in effect only for the shell.

The unfocused window border follows too. It is a flat neutral, and a
dark neutral is invisible against a light desktop. The focused border is
the prism gradient and needs no variant.

The live preview follows the scheme as well. A preview that stayed dark
while the shell around it went light did not read as "your desktop", it
read as a screenshot of someone else's.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:20:35 -04:00
Gabriel Brown d1b2cd2083 Make typography choosable instead of hardcoded
Theme.qml was the largest remaining thing in this desktop that could
only be changed by editing a file, and typeface is the first thing
someone changes when they want a desktop to feel like theirs. Interface
font, icon font, and the base text size are settings now.

The two font choices are deliberately separate lists. Theme.fontMono is
used only to draw glyphs -- workspace pills, the status cluster, search
icons -- so a plain monospace family there replaces every icon in the
shell with tofu. The picker offers only Nerd Fonts for that slot and
says why.

Candidates are rendered in the family they name. A list of font names
set in the current font tells you nothing about what you are choosing.

The four type sizes derive from the base rather than being stored
separately, so the relationship between body, caption, heading and title
survives a change instead of four numbers drifting apart.

hypr/looks.lua reads the same key. Following the preference only on the
QML side would leave the compositor and the shell disagreeing about the
interface font, which nobody notices until a tooltip renders in a
different typeface.

Only a family this machine reports is accepted: the value reaches
hl.config as a string, and a settings file moved between machines will
name fonts that are not installed. A missing family is reported rather
than silently substituted by fontconfig.

Also collapses the wallpaper grid to two rows. Sixty tiles is two
screens of pictures on a page that also holds typography, window
geometry and effects -- everything below it was unreachable without
scrolling past all of them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:06:53 -04:00
Gabriel Brown 8c4dee0ca8 Give Settings its own icon and the first dock slot
Panama Settings shipped with Icon=preferences-system-symbolic, a
monochrome glyph drawn for 16px toolbar use. Beside full-colour
application icons in a 48px dock it reads as a missing icon rather than
a quiet one. It now has its own: a gear, because a settings icon has to
be recognisable before it is clever, rendered in the Prism gradient on
the dark tile so it belongs to this desktop.

An earlier attempt drew the gear as a ring with radial strokes; at dock
size the strokes merged into the ring and it read as an X. The shipped
version is a real toothed outline, checked at 48px rather than only at
128.

The dock also pinned GNOME Settings first. Panama now covers what GNOME
Settings did for this desktop and delegates the remainder to it by name,
so pinning the thing it delegates TO put the fallback in front of the
real one. GNOME Settings stays installed and searchable.

link-dotfiles installs icons alongside desktop entries, so this survives
a fresh setup rather than being a file that happens to exist here.

The new contract asserts every pinned application resolves to an
installed desktop entry. DockBody drops an unresolvable pin rather than
drawing a broken icon, which is right at runtime and invisible to debug:
a typo or a renamed desktop id just removes an icon with nothing logged.
It also cost me a false negative while writing it -- DesktopEntries
populates asynchronously, and asking too early reports every pin as
missing.

Also makes tests/quickshell/osd-ui-contract.sh executable. It was
committed mode 644, the only test in the suite that was, so the runner
could not invoke it. It passes.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:54:43 -04:00
Gabriel Brown 71ee18048e Add a living Prism component gallery 2026-08-18 06:50:32 -04:00
Gabriel Brown 668983e16d Add polished system feedback overlays 2026-08-18 06:46:11 -04:00
Gabriel Brown 17e23a6cf4 Merge remaining settings work
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:38:46 -04:00
Gabriel Brown d18fe51553 Make the weather location and graphics device choosable
The last two values that could only be changed by editing a file.

Weather was pinned to hardcoded coordinates, so the card could not be
pointed anywhere else. It is a location search now, not latitude and
longitude fields: nobody knows their own coordinates, and a control that
demands them is one nobody uses. Open-Meteo's geocoding endpoint needs
no key, the same reason the forecast already uses them. Only the search
term leaves the machine -- the stored place name is a label -- and
coordinates are rounded to four decimals, far finer than a weather
reading resolves and coarse enough to keep a precise home location out
of the settings file.

The graphics readout was hardcoded to card1. This machine has two amdgpu
cards, discrete and integrated, so that was right only by luck, and the
path is meaningless on any other machine. GPUs are enumerated with a
readable name from lspci, since sysfs exposes only numeric ids, and the
picker appears only when there is more than one to choose between. A
stored path the machine does not have is refused and reported rather
than silently measuring nothing.

Also merges the per-application notification rules UI. Its three commits
were believed integrated but the page half was not actually in the tree:
main had the service side in Notifs.qml and zero references to
setAppRule in NotificationsPage. Ancestry is not content.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:38:46 -04:00
Gabriel Brown 9f9515ffe0 Merge per-application notification rules
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:29:47 -04:00
Gabriel Brown 94353aa0bd Close the gaps the cross-UI audit found
Audited bar, dock, quick settings, date menu and Settings for three
things: a setting reachable in one UI but not another, a setting that
exists but is unreachable anywhere, and UI that states something false.

Night Light was fully exposed in Quick Settings and had no control
anywhere in Settings. It now has a card on Displays, where GNOME also
puts it, with on/off, schedule, times and temperature.

Adding those controls would have shipped the exact defect this audit
exists to find. NightLight declared enabled, temperature and automatic
as bindings on the store, but toggle() assigns to them, and an
assignment destroys a QML binding permanently -- so the service wrote to
the store and never read from it again. The Settings controls would have
written values the service ignored, while Quick Settings kept working.
It now follows the store. Every other service was swept for the same
pattern; this was the only one.

The night light schedule was two hardcoded literals, so the hours could
not be changed. They are schema keys now, with a row that renders 17.5
as "5:30 PM" and honours the 24-hour preference rather than showing a
decimal nobody reads as a time.

keyboardLayout was in the schema and read by input.lua but had no
control anywhere: configurable in principle, unreachable in practice. It
is surfaced on Input & Shortcuts as read-only, with the reason, because
it needs a compositor reload and a control implying instant apply would
be a smaller lie but still a lie.

Caffeine was a Quick Settings toggle mentioned only in a subtitle in
Settings. It has a real control now.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:25:15 -04:00
Gabriel Brown 3a2295ff53 Make credential setup keyboard-safe and verifiable 2026-08-18 06:14:07 -04:00
Gabriel Brown de1b8a7673 Add private Home Assistant credentials settings 2026-08-18 06:14:07 -04:00
Gabriel Brown 39a26adcc2 Make Home light ordering explicit and durable 2026-08-18 06:14:07 -04:00
Gabriel Brown eff0bc44ff fix: resolve persisted notification app labels 2026-08-18 06:14:07 -04:00
Gabriel Brown c038c57278 fix: harden notification application rule integration 2026-08-18 06:14:07 -04:00
Gabriel Brown 420b7aa1a6 feat: add notification application rules 2026-08-18 06:14:07 -04:00
Gabriel Brown f22e405b50 fix: resolve persisted notification app labels 2026-08-18 05:57:10 -04:00
Gabriel Brown 3cfa592db9 Build complete PipeWire sound settings 2026-08-18 05:54:14 -04:00
Gabriel Brown 81096e2d95 Declare the Sound and notification-rule manifest entries
Registers the components and schema key the codex agent needs for the
Sound page and per-application notification rules, so its branches
compile against a manifest that already holds them rather than each
carrying a conflicting edit to the same file.

An absent notification rule is permissive rather than denying: a newly
installed application must be able to notify without an entry being
written for it first.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:54:07 -04:00
Gabriel Brown c415c4f176 fix: harden notification application rule integration 2026-08-18 05:51:32 -04:00
Gabriel Brown 86742da63d Merge Wi-Fi and Bluetooth settings
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:50:07 -04:00
Gabriel Brown 3c521cf5fa Handle Wi-Fi and Bluetooth in Settings
Network & Devices was 92 lines and two buttons that opened GNOME. It now
scans, joins, and pairs directly through Quickshell.Networking and
Quickshell.Bluetooth -- NetworkManager and BlueZ over DBus, no shelling
out to nmcli or bluetoothctl. That was the founding requirement for this
desktop: never having to drop to a terminal to join a network.

Scanning follows the page being visible. Wi-Fi scanning and especially
Bluetooth discovery hold the radio, and running either for a list nobody
is looking at spends airtime on nothing.

Joining a secured network gets a real password field, not the clipboard
popover's search box with different placeholder text: a Wi-Fi key typed
into a field that echoes it is readable by anyone behind you, and a
search glyph in front of a password prompt is simply wrong.

Two bugs found by looking at the rendered page, both silent:

The device lookups used enum names that do not exist --
NetworkDeviceType.Wifi rather than DeviceType.Wifi -- so both returned
null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi
was connected. Nothing was logged; QML resolves an unknown enum member
to undefined and compares happily.

signalStrength is 0.0-1.0, not a percentage, so thresholds written for
0-100 put every network including the connected one in the bottom
bucket. The labels now use the same buckets as the icons in
quicksettings/WifiList.qml so the two cannot disagree.

The contract compares what the service resolves against what nmcli
reports, rather than only checking that nothing crashed.

Also makes the Home Assistant bridge hermetic: resolve_config read the
user's private env file even when a caller supplied an explicit
environment, so adding a real PANAMA_HOME_ASSISTANT_ENTITIES to that
file silently overrode a fixture asserting the legacy fallback. An
explicit environment is now the whole environment; production still
reads the file. Its live contract skips when no token is configured --
an absent credential is not a defect, and a suite expected to be red
stops being read -- while a configured-but-broken bridge still fails.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:50:07 -04:00
Gabriel Brown c087998710 feat: add notification application rules 2026-08-18 05:38:39 -04:00
Gabriel Brown b9800bfbab Scope compositor isolation to reset tests 2026-08-18 03:25:39 -04:00
Gabriel Brown 7b8270fdd6 Keep Settings recovery isolated and display-safe 2026-08-18 03:19:45 -04:00
Gabriel Brown 6d3f888784 Protect live state during restore and reset 2026-08-18 03:10:31 -04:00
Gabriel Brown c2cd79b547 Serialize display recovery checks 2026-08-18 02:55:43 -04:00
Gabriel Brown 0a18290137 Close display verification races 2026-08-18 02:55:43 -04:00
Gabriel Brown efc53435a2 Verify display restoration and expose outputs 2026-08-18 02:55:43 -04:00
Gabriel Brown 67674d297e Polish display confirmation state 2026-08-18 02:55:43 -04:00
Gabriel Brown 2d4b126f14 Harden display apply and recovery 2026-08-18 02:55:43 -04:00
Gabriel Brown 5671324eb2 Make displays configurable, with a revert countdown
Resolution, refresh rate, scale, and rotation, applied through
hl.monitor{} and stored per output.

This is the only setting in Panama where a wrong value can leave the
user unable to SEE the screen well enough to undo it: a mode the panel
cannot show, or a scale that makes everything unreadable, is not
recoverable through the UI that caused it. So a change is never applied
irreversibly. It is applied, then reverted automatically after fifteen
seconds unless confirmed, and confirming is what writes it to the
settings store -- letting the countdown run leaves nothing behind.

The contract tests that property specifically: it applies a scale, waits
out the countdown, and asserts the display came back and that nothing
was stored. A regression there is not a broken feature, it is a user
staring at a blank monitor.

Modes are grouped by resolution with refresh rates beside them. The
panel reports 35, many differing only in refresh-rate rounding -- 60.00
and 59.94 -- which as a flat list of buttons is noise rather than
choice; equal rounded pairs collapse, leaving 21.

Only mode, scale, and transform are configurable. Colour management and
bit depth stay in monitors.lua because they carry a documented screencopy
tradeoff that a settings page cannot explain at the moment you would be
changing it.

Also replaces the display policy rows with the schema-bound ones, so the
page no longer restates labels that PreferenceSchema already holds.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Four defects found while building:

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-17 23:26:56 -04:00
Gabriel Brown 4bd3638012 Cover partial Phone capabilities 2026-08-17 19:50:27 -04:00
Gabriel Brown 7f540a58e5 Avoid visual Phone harness teardown 2026-08-17 19:42:15 -04:00
Gabriel Brown 7ac5355a77 Record focused verification completion 2026-08-17 19:26:57 -04:00
Gabriel Brown a9eb90f457 Fix Home Assistant composition contract 2026-08-17 19:21:17 -04:00
Gabriel Brown b5c9156e64 Document Home accessory customization 2026-08-17 19:18:45 -04:00
Gabriel Brown 3213a0989a Fix Phone controls runtime accessibility 2026-08-17 19:01:42 -04:00
Gabriel Brown fbf729b25c Add BlueBubbles to Phone controls 2026-08-17 18:48:28 -04:00
Gabriel Brown e37df9d838 Fix Home shelf slider interactions 2026-08-17 18:34:48 -04:00
Gabriel Brown 673aac6aba Build the Home accessory shelf 2026-08-17 18:22:27 -04:00
Gabriel Brown c8eb344a0b Fix Home and Phone settings states 2026-08-17 18:02:09 -04:00
Gabriel Brown 65966200fe Add Home and Phone settings 2026-08-17 17:48:05 -04:00
Gabriel Brown 2130d6a1a5 Block Home actions during fixture transitions 2026-08-17 17:27:43 -04:00
Gabriel Brown 9a99c6064e Fix nested fixture transition draining 2026-08-17 17:16:36 -04:00
Gabriel Brown 4f93522f7d Fix fixture process transitions 2026-08-17 16:59:15 -04:00
Gabriel Brown a325ec807e Fix Home action completion and test isolation 2026-08-17 16:49:50 -04:00
Gabriel Brown c2b19f7545 Compose Home catalog with accessory preferences 2026-08-17 16:35:41 -04:00
Gabriel Brown 925fc0276f Persist Home accessory preferences 2026-08-17 16:24:09 -04:00
Gabriel Brown 65a8197419 Fix Home Assistant catalog QML compatibility 2026-08-17 16:06:05 -04:00
Gabriel Brown ff67387c42 Add Home Assistant light catalog and dimming 2026-08-17 16:01:50 -04:00
Gabriel Brown c42794c5e2 Ignore local feature worktrees 2026-08-17 15:53:11 -04:00
Gabriel Brown 0964af7b73 Plan Home accessory customization 2026-08-17 15:49:20 -04:00
Gabriel Brown 15fb008954 Design Home accessory customization 2026-08-17 14:36:30 -04:00
Gabriel Brown f4c3ffed43 Document the Panama Control Center 2026-08-17 13:47:50 -04:00
Gabriel Brown 5f965daa6a Sanitize Control Center test fixtures 2026-08-17 13:44:23 -04:00
Gabriel Brown 5940d6b4bd Build the Panama Control Center 2026-08-17 13:44:18 -04:00
Gabriel Brown 190e3200e0 Keep offline phone capabilities visible 2026-08-17 13:42:31 -04:00
Gabriel Brown 49ec4d8f6a Add Control Center integration services 2026-08-17 13:31:27 -04:00
Gabriel Brown 173c00fca7 Add the Panama Home Assistant bridge 2026-08-17 13:27:11 -04:00
Gabriel Brown 3a903934c0 Add the Panama KDE Connect bridge 2026-08-17 13:22:31 -04:00
340 changed files with 43921 additions and 1519 deletions
+14
View File
@@ -8,3 +8,17 @@
/config/dot/nvim/lazy-lock.json
# Local visual-companion sessions and generated design mocks.
/.superpowers/
# Local linked worktrees used for reviewed feature development.
/.worktrees/
# Python helper bytecode is local runtime state.
__pycache__/
*.py[cod]
# Generated from the colour scheme; machine state, not configuration.
/config/dot/kitty/current-theme.conf
# Generated from the colour scheme; machine state, not configuration.
/config/dot/gtk-3.0/settings.ini
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf
+1 -1
View File
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
| Piece | What it is |
|---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
@@ -0,0 +1,57 @@
# Tokyo Night Day for btop, the light counterpart to tokyonight-moon.
#
# btop ships no Tokyo Night light variant at all, and the nearest stock light
# theme (flat-remix-light) is a different palette that happens to have a similar
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
# and what runs inside it cannot disagree.
#
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
# Day's darker, more saturated accents: the light versions of these hues are too
# faint to read as a filled meter.
theme[main_bg]="#e1e2e7"
theme[main_fg]="#3760bf"
theme[title]="#3760bf"
theme[hi_fg]="#2e7de9"
theme[selected_bg]="#c4c8da"
theme[selected_fg]="#2e7de9"
theme[inactive_fg]="#7079a8"
theme[proc_misc]="#587539"
theme[cpu_box]="#a8aecb"
theme[mem_box]="#a8aecb"
theme[net_box]="#a8aecb"
theme[proc_box]="#a8aecb"
theme[div_line]="#c4c8da"
theme[temp_start]="#2e7de9"
theme[temp_mid]="#8c6c3e"
theme[temp_end]="#f52a65"
theme[cpu_start]="#2e7de9"
theme[cpu_mid]="#9854f1"
theme[cpu_end]="#f52a65"
theme[free_start]="#c4c8da"
theme[free_mid]="#007197"
theme[free_end]="#2e7de9"
theme[cached_start]="#007197"
theme[cached_mid]="#2e7de9"
theme[cached_end]="#9854f1"
theme[available_start]="#8c6c3e"
theme[available_mid]="#b15c00"
theme[available_end]="#f52a65"
theme[used_start]="#587539"
theme[used_mid]="#8c6c3e"
theme[used_end]="#f52a65"
theme[download_start]="#c4c8da"
theme[download_mid]="#2e7de9"
theme[download_end]="#007197"
theme[upload_start]="#c4c8da"
theme[upload_mid]="#9854f1"
theme[upload_end]="#7847bd"
@@ -0,0 +1,56 @@
# Tokyo Night Moon for btop, matched to Panama's palette.
#
# btop ships a "tokyo-night" theme, but it is the Night variant (#1a1b26). The
# rest of this desktop is Moon (#222436), and two Tokyo Nights side by side read
# as a mistake rather than as a choice.
#
# The *_start/_mid/_end triples are gradients btop draws meters with: low,
# middle, and high. They run blue -> yellow -> red so a saturated resource is
# obvious at a glance without reading the number.
theme[main_bg]="#222436"
theme[main_fg]="#c8d3f5"
theme[title]="#c8d3f5"
theme[hi_fg]="#82aaff"
theme[selected_bg]="#3b4261"
theme[selected_fg]="#82aaff"
theme[inactive_fg]="#636da6"
theme[proc_misc]="#a5e8b5"
theme[cpu_box]="#4d5685"
theme[mem_box]="#4d5685"
theme[net_box]="#4d5685"
theme[proc_box]="#4d5685"
theme[div_line]="#3b4261"
theme[temp_start]="#82aaff"
theme[temp_mid]="#ffc777"
theme[temp_end]="#ff757f"
theme[cpu_start]="#82aaff"
theme[cpu_mid]="#c099ff"
theme[cpu_end]="#ff757f"
theme[free_start]="#3b4261"
theme[free_mid]="#589ed7"
theme[free_end]="#86e1fc"
theme[cached_start]="#86e1fc"
theme[cached_mid]="#82aaff"
theme[cached_end]="#c099ff"
theme[available_start]="#ffc777"
theme[available_mid]="#ff966c"
theme[available_end]="#ff757f"
theme[used_start]="#a5e8b5"
theme[used_mid]="#ffc777"
theme[used_end]="#ff757f"
theme[download_start]="#3b4261"
theme[download_mid]="#82aaff"
theme[download_end]="#86e1fc"
theme[upload_start]="#3b4261"
theme[upload_mid]="#c099ff"
theme[upload_end]="#fca7ea"
@@ -1,3 +1,12 @@
# GENERATED FILE -- edit settings.ini.template instead.
#
# The theme name and dark preference below follow Panama's colour
# scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
# settings daemon, so for GTK3 it is authoritative -- which is why it
# has to change with the scheme rather than being pinned to dark.
#
[Settings]
# These values must match `gsettings get org.gnome.desktop.interface ...`.
#
@@ -6,12 +15,12 @@
# this file becomes authoritative for GTK3 — which is why it previously named
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
# appearing broken.
gtk-theme-name=adw-gtk3-dark
gtk-theme-name=@GTK_THEME@
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=1
gtk-application-prefer-dark-theme=@PREFER_DARK@
gtk-toolbar-style=GTK_TOOLBAR_ICONS
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
-11
View File
@@ -1,11 +0,0 @@
[Settings]
# libadwaita apps normally take their dark preference from the
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
# window before the portal answers.
gtk-application-prefer-dark-theme=1
gtk-theme-name=adw-gtk3-dark
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
+20
View File
@@ -0,0 +1,20 @@
# GENERATED FILE -- edit settings.ini.template instead.
#
# The theme name and dark preference below follow Panama's colour
# scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
# settings daemon, so for GTK3 it is authoritative -- which is why it
# has to change with the scheme rather than being pinned to dark.
#
[Settings]
# libadwaita apps normally take their dark preference from the
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
# window before the portal answers.
gtk-application-prefer-dark-theme=@PREFER_DARK@
gtk-theme-name=@GTK_THEME@
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
+33 -16
View File
@@ -23,7 +23,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
| Quick settings | Wi-Fi, Bluetooth, audio input/output, caffeine, night light, focus | Live |
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live |
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
@@ -35,7 +35,33 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live |
| Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
## System health and recovery
Panama stays silent while the desktop is healthy. A compact bar indicator
appears only for actionable warnings or errors and opens the same **System
Health** page available from Settings and the Vicinae command **Panama: Check
System Health**. The terminal summary is available with:
```bash
~/.config/quickshell/scripts/panama-doctor --summary
```
The doctor reports authored, redacted observations about Panama-owned services,
tools, links, and configured integrations. It does not read secrets, clipboard
or notification contents, calendar events, SSIDs, or device addresses. Repairs
are a small allow-list: Panama user services, Panama-owned links and launcher
commands, duplicate Panama Caffeine inhibitors, and a confirmed shell restart.
They never install packages, invoke `sudo`, delete user data, or rewrite
arbitrary configuration.
Generic Fedora configuration remains with the system tools that own it. The
final System Health card hands network settings, users, sharing, colour
profiles, and digital wellbeing to their exact GNOME Settings panels rather
than presenting inert Hyprland controls.
## GNOME extension migration
@@ -52,8 +78,8 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
| GSConnect | No paired phone profile was present; KDE Connect is installed and awaiting pairing |
| Home Assistant | Useful integration, but needs a purpose-built panel and secure credential migration |
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
| App Hider | Hidden app list is now applied in Vicinae |
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
@@ -63,20 +89,11 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
These are not blockers for normal use, but they are the remaining places where
a polished general-purpose desktop can go beyond the current shell.
1. Home Assistant favourites in a dedicated, secure quick panel. The token must
stay in the keyring or a private state file, never in this repository.
2. Phone integration through KDE Connect, including battery/share status in the
tray. The package is installed and ready for a new pairing.
3. A printer administration GUI. CUPS is already active; the
`system-config-printer` package supplies the missing UI.
4. A real external RustDesk test while hyprlock is active. Local process state
1. A real external RustDesk test while hyprlock is active. Local process state
cannot prove what a remote client sees or whether input is accepted.
Supporting packages tracked in `setup/packages/hyprland-packages`:
```sh
sudo dnf install kde-connect system-config-printer tesseract zbar
```
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
tracked in `setup/packages/hyprland-packages`.
## Global menu decision
+135 -2
View File
@@ -27,6 +27,7 @@ Don't "fix" them.
| File | Contents |
|---|---|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
@@ -43,6 +44,109 @@ Validate any change without leaving your session:
Hyprland --verify-config
```
## Generated configuration
Two ecosystem tools cannot read the shared settings file, and their configs live
in this directory — which is a symlink into the Panama repository, so writing to
them at runtime would put machine state into a tracked file. Both are therefore
generated elsewhere:
| Tool | Generated to | Pointed there by |
|---|---|---|
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
The Settings app shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
```sh
~/.config/quickshell/scripts/panama-idle remove
```
## Settings: one file, both sides
`~/.config/panama/settings.json` is shared with the Quickshell side. The
relationship is:
- **This config is the default.** Every adjustable value is written
`prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it.
- **The Settings app is the editor.** It writes the file *and* applies the change
live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to
`quickshell/config/PreferenceSchema.qml` with a `hypr` block naming the
`hl.config` path, then read it here with `prefs.get`. Nothing else is needed —
persistence, validation, reset, and the live write are all derived from that
entry.
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
wrong-typed settings file costs you your customisations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states.
### Adding a keybind: use `bind`, not `hl.bind`
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
the Settings app without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
Only the **chord** is ever taken from settings — the action is always the Lua
value written here. A stored override can therefore move a shortcut but can
never make one do something else, which is what makes reading overrides from a
file the user can edit safe.
Overrides are keyed by the **shipped chord**, not the description. Descriptions
are not unique: "Calculator" is both `SUPER + C` and the `XF86Calculator`
hardware key, and keying by description moved both onto the same new chord,
silently costing the hardware key.
An override whose value is not a plausible chord is ignored in favour of the
shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
### Keybind descriptions are required
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently.
```lua
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
The page groups shortcuts by what the description says they do, so a new bind
lands in the right section with no change to the UI.
### Never use `hyprctl keyword`
On a Lua-configured Hyprland it refuses the write, prints
`keyword can't work with non-legacy parsers` to **stdout**, and still **exits 0**:
```sh
$ hyprctl getoption decoration:rounding -j # → "int": 18
$ hyprctl keyword decoration:rounding 4 # → the refusal above
$ echo $? # → 0
$ hyprctl getoption decoration:rounding -j # → "int": 18, unchanged
```
Use `hyprctl eval 'hl.config({ ... })'` instead. Note that `eval` *also* exits 0
on syntax and runtime errors, reporting them as an `error:` line on stdout — so
for either command, the only trustworthy signal that a write landed is reading
the value back with `hyprctl getoption`.
## The look
Tokyo Night Moon, with two accents that come from the tmux theme:
@@ -171,7 +275,7 @@ The mental model is unchanged from Forge:
| `SUPER + V` | Clipboard history |
| `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings |
| `SUPER + I` | Panama Settings |
| `SUPER + I` | Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre |
| `SUPER + grave` | Workspace overview |
@@ -184,7 +288,7 @@ The mental model is unchanged from Forge:
### Apps
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
`E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
Settings remains searchable in Vicinae for hardware and account panels.
## Calendar and notifications
@@ -213,6 +317,35 @@ before a timed event until 5 minutes after it starts. It displays relative time
(`12m` or `Now`); the event title is available on hover but otherwise stays out
of the bar. All-day events never trigger the capsule.
## Control Center
Click the right-side status cluster or press `SUPER + S` for Panama's Control
Center. It is attached directly beneath the bar and keeps Wi-Fi, Bluetooth,
Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
one place. Home and Phone continue the same surface rather than opening extra
dashboard windows.
Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Settings → Home & Phone** to choose favourites,
set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous
level.
Credentials stay private in the gitignored `config/bash/env` file, with the
existing GNOME extension and Secret Service setup retained as a compatibility
fallback. Favourites, aliases, and order live in Quickshell state. No shell
restart is required after changing credentials; close and reopen Control Center
to refresh. If Home Assistant is offline, the last known values stay visible
with a stale-state label and Retry action.
Phone uses KDE Connect for the capabilities the paired iPhone actually
advertises: Send File, Send Clipboard, and Ring. The device remains visible
while iOS suspends KDE Connect, but those actions stay disabled until it
reconnects. Messages opens BlueBubbles independently of KDE Connect. No battery
percentage is invented when iOS reports none. Active file sends appear in
Daybook's Ongoing page; successful sends become a quiet recent exchange.
## Screen Intelligence
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
+3 -1
View File
@@ -9,6 +9,8 @@
-- instead. They still work here; see the session notes in autostart.lua.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── GPU selection ───────────────────────────────────────────────────────────
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
@@ -47,7 +49,7 @@ end
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
-- XCursor path, which is what we want.
hl.env("XCURSOR_THEME", "oreo_blue_cursors")
hl.env("XCURSOR_THEME", prefs.get("cursorTheme", "oreo_blue_cursors"))
hl.env("XCURSOR_SIZE", "24")
-- ── Toolkits ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -15,7 +15,7 @@
general {
# `pidof` guard prevents stacking lockers if this fires twice.
lock_cmd = pidof hyprlock || hyprlock
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
before_sleep_cmd = loginctl lock-session
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
+5
View File
@@ -14,6 +14,11 @@
-- Check: Hyprland --verify-config
-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences first: looks/input/monitors read their defaults through it.
-- It never raises, so a missing or malformed settings file costs customisations
-- and nothing else.
require("prefs")
require("env")
require("monitors")
require("looks")
@@ -1,8 +1,17 @@
# ─────────────────────────────────────────────────────────────────────────────
# hyprlock — lock screen
# hyprlock — lock screen.
#
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
# GENERATED FILE. Edit hyprlock.conf.template and re-run
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
# next colour scheme change.
#
# The colours here follow the desktop's light/dark setting. They used to be
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
# stayed dark when everything else went light.
#
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
# template carries "R, G, B" triples where the rest of Panama uses hex. The two
# Pango markup values are the exception and want ##rrggbb.
#
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
# ─────────────────────────────────────────────────────────────────────────────
@@ -45,7 +54,7 @@ background {
vibrancy_darkness = 0.05
# Shown if the screenshot is unavailable.
color = rgba(34, 36, 54, 1.0)
color = rgba(@BG@, 1.0)
zindex = -1
}
@@ -54,7 +63,7 @@ background {
label {
monitor =
text = cmd[update:1000] date +"%-I:%M"
color = rgba(200, 211, 245, 1.0)
color = rgba(@FG@, 1.0)
font_size = 120
font_family = Adwaita Sans Light
position = 0, 260
@@ -65,7 +74,7 @@ label {
label {
monitor =
text = cmd[update:60000] date +"%A, %B %-d"
color = rgba(130, 139, 184, 1.0)
color = rgba(@MUTED@, 1.0)
font_size = 24
font_family = Adwaita Sans
position = 0, 160
@@ -84,18 +93,18 @@ input-field {
outline_thickness = 2
rounding = 26
outer_color = rgba(130, 170, 255, 0.9)
inner_color = rgba(46, 47, 61, 0.85)
font_color = rgba(200, 211, 245, 1.0)
check_color = rgba(130, 170, 255, 1.0)
fail_color = rgba(255, 117, 127, 1.0)
outer_color = rgba(@ACCENT@, 0.9)
inner_color = rgba(@FIELD@, 0.85)
font_color = rgba(@FG@, 1.0)
check_color = rgba(@ACCENT@, 1.0)
fail_color = rgba(@ERROR@, 1.0)
dots_size = 0.25
dots_spacing = 0.3
dots_center = true
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
fade_on_empty = false
hide_input = false
@@ -105,7 +114,7 @@ input-field {
label {
monitor =
text = $USER
color = rgba(200, 211, 245, 0.9)
color = rgba(@FG@, 0.9)
font_size = 16
font_family = Adwaita Sans
position = 0, -110
+20 -10
View File
@@ -5,28 +5,38 @@
-- acceleration changes. The goal is that muscle memory transfers untouched.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
hl.config({
input = {
kb_layout = "us",
kb_variant = "",
kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = prefs.get("keyboardVariant", ""),
kb_model = "",
kb_options = "",
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
kb_rules = "",
numlock_by_default = true,
numlock_by_default = prefs.get("numlockByDefault", true),
-- GNOME's defaults are 500ms delay / 33Hz repeat.
repeat_delay = 500,
repeat_rate = 33,
repeat_delay = prefs.get("keyRepeatDelay", 500),
repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
follow_mouse = 1,
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
-- so click-to-focus is 0. Changing the shipped value is a behaviour
-- decision rather than a correction, so the value is left alone and
-- only the description is fixed; Settings exposes all four.
follow_mouse = prefs.getInt("followMouse", 1),
-- Don't refocus on mouse move alone -- only on click.
-- Softens follow_mouse: with this off, focus changes only when the
-- pointer crosses a window boundary, not on every movement inside one.
-- Still focus-follows-pointer, just less twitchy.
mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming.
sensitivity = 0,
sensitivity = prefs.get("pointerSensitivity", 0),
accel_profile = "flat",
-- Clicking a floating window raises and focuses it.
+149 -96
View File
@@ -11,6 +11,8 @@
-- SUPER + CTRL -> change layout structure (split, float, swap)
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
local mod = "SUPER"
-- Programs, matched to the GNOME custom keybindings and media-key settings.
@@ -28,114 +30,153 @@ local sysmonitor = "flatpak run io.missioncenter.MissionCenter"
-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
-- window against the already-running server (started in autostart.lua).
local launcher = "vicinae toggle"
local osd = function(action)
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
end
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
-- ── Applications ────────────────────────────────────────────────────────────
hl.bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
hl.bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
hl.bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
hl.bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
hl.bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
hl.bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
hl.bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
hl.bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── User rebinding ──────────────────────────────────────────────────────────
-- Every bind below goes through `bind` rather than `hl.bind` directly, so a
-- chord can be replaced from Panama Settings without this file changing.
--
-- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken
-- from settings -- the action is always the Lua value written here. A stored
-- override can therefore move a shortcut but can never make one do something
-- else, which is the property that makes reading them from a JSON file the
-- user can edit safe.
--
-- Keyed by chord rather than by description because chords are unique and
-- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator
-- hardware key, and keying by description moved both of them onto the same new
-- chord, silently costing the hardware key.
--
-- An override whose chord is not a plausible chord is ignored and the shipped
-- one is used, so a hand-edited settings file cannot cost you a keymap.
local overrides = prefs.get("keybindOverrides", {})
local function valid_chord(chord)
if type(chord) ~= "string" or chord == "" or #chord > 64 then
return false
end
-- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272"
return chord:match("^[%w_+%s:]+$") ~= nil
end
local function bind(chord, action, opts)
local override = overrides[chord]
if valid_chord(override) then
chord = override
end
return hl.bind(chord, action, opts)
end
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ────────────────────────────────────────────────────────────────
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
-- emoji and window switching, so a separate run dialog and app grid are gone.
hl.bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
hl.bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
hl.bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- Emergency fallback launcher. Vicinae runs as a systemd user service and the
-- bar is Quickshell; if either fails to come up, this is how you start an
-- application without dropping to a TTY. Depends on nothing but wofi itself.
hl.bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
-- Clipboard history and emoji, straight into the relevant launcher view.
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
hl.bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
{ description = "Clipboard history" })
hl.bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
{ description = "Emoji picker" })
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
-- SUPER+S was GNOME's quick settings; kept.
hl.bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
-- Start a 45-minute focus session on the current workspace, or reveal its
-- Signal Glass controls if one is already running.
hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a
-- modifier misfires when you're fast with SUPER+key combos, so it lives on a
-- real chord instead. SUPER+grave was Forge's "cycle windows of same app",
-- which Hyprland has no equivalent for.
hl.bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
-- Notification centre.
hl.bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
-- Screenshot / screen record. One key, then pick screen / window / region and
-- whether to capture or record -- reproducing GNOME's Print-screen UI.
hl.bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
hl.bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
hl.bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
-- Local OCR and QR/barcode recognition through the same region picker. This
-- opens directly in Selection + Read mode; Print still exposes every mode.
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
{ description = "Screen Intelligence" })
-- Colour picker: copies the hex under the cursor to the clipboard.
hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
-- ── Window management ───────────────────────────────────────────────────────
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
hl.bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
-- Forge: window-toggle-float / window-toggle-always-float.
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
-- is the nearest useful thing -- the window floats above every workspace.
hl.bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
hl.bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical.
hl.bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
hl.bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
hl.bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
-- Forge: window-shrink / window-expand / window-reset-sizes.
hl.bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
hl.bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
hl.bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge.
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
hl.bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" }))
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" }))
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" }))
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" }))
bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
-- Move (Forge: window-move-*).
hl.bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
hl.bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
hl.bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
hl.bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
-- Swap (Forge: window-swap-*).
hl.bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
hl.bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
hl.bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
--
@@ -145,24 +186,36 @@ hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descrip
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
-- and "increase" always grows while "decrease" always shrinks.
local step = 60
hl.bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
hl.bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
hl.bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
hl.bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
hl.bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
hl.bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
hl.bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
hl.bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
hl.bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
hl.bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
-- showing what you are choosing between.
--
-- The gesture needs three binds, not two. Tab steps the selection, and the
-- switch is only COMMITTED when the modifier is released -- which is the sole
-- way the compositor can tell the gesture is finished. That release bind is on
-- the bare modifier, so it fires on EVERY Super release in the session; the
-- handler returns immediately when no switch is open, which is why this is
-- affordable.
--
-- The release bind carries no description on purpose: it is not a shortcut
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
-- Jump back to the previously focused window.
hl.bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
-- Mouse: drag to move, right-drag to resize.
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
-- ── Workspaces ──────────────────────────────────────────────────────────────
-- ALT is the workspace modifier, matching the GNOME setup.
@@ -170,27 +223,27 @@ hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
-- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping.
hl.bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
hl.bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
hl.bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
hl.bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
-- GNOME also had these on CTRL+ALT+Up/Down.
hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }))
hl.bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }))
bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
-- Direct jump. ALT+0 is workspace 10.
for i = 1, 10 do
local key = i % 10
hl.bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
hl.bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
end
-- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
-- (Scrolling the workspace indicator in the bar does the same; that's handled
-- in quickshell/modules/bar/Workspaces.qml.)
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }))
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- Minimise, as far as Hyprland has one.
--
@@ -202,42 +255,42 @@ hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
--
-- The scratchpad is the honest equivalent: the window goes away, and the same
-- key brings it back. Bound to X to match the muscle memory it replaces.
hl.bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
hl.bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
-- ── Session ─────────────────────────────────────────────────────────────────
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
-- CTRL+ALT+L -- the other binding most people already have in muscle memory.
hl.bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
-- ── Media and volume ────────────────────────────────────────────────────────
-- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting.
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
hl.bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true })
hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true })
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true })
bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
-- Hardware keys GNOME mapped that have obvious equivalents.
hl.bind("XF86Tools", hl.dsp.exec_cmd(settings))
hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator))
hl.bind("XF86Explorer", hl.dsp.exec_cmd(files))
hl.bind("XF86WWW", hl.dsp.exec_cmd(browser))
hl.bind("XF86Mail", hl.dsp.exec_cmd(mail))
hl.bind("XF86Search", hl.dsp.exec_cmd(launcher))
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
return true
+75 -36
View File
@@ -12,25 +12,32 @@
-- display.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
hl.config({
general = {
gaps_in = 5,
gaps_out = 10,
gaps_in = prefs.get("gapsIn", 5),
gaps_out = prefs.get("gapsOut", 10),
border_size = 2,
border_size = prefs.get("borderSize", 2),
col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair
-- is visible on both a tall and a wide window. Same two colours as
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
-- tmux theme this palette came from.
-- The focused accent role: blue leads, orchid follows, on a
-- diagonal so the pair is visible on both a tall and a wide
-- window. ColorScheme never writes this role; a future accent
-- picker can own it without fighting light/dark mode.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means
-- something if exactly one window on screen is wearing it.
inactive_border = "rgba(3b426199)",
-- The neutral inactive role follows the colour scheme because a
-- dark neutral disappears against a light desktop.
-- services/ColorScheme.qml applies the same values live; this is
-- the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)",
},
resize_on_border = true,
resize_on_border = prefs.get("resizeOnBorder", true),
extend_border_grab_area = prefs.getInt("borderGrabArea", 15),
hover_icon_on_border = prefs.get("hoverIconOnBorder", true),
-- Enables the per-window "immediate" rule used for games in rules.lua.
-- Harmless on its own; tearing only happens where a rule opts in.
@@ -38,22 +45,28 @@ hl.config({
layout = "dwindle",
snap = { enabled = true },
snap = {
enabled = true,
window_gap = prefs.getInt("snapWindowGap", 10),
monitor_gap = prefs.getInt("snapMonitorGap", 10),
respect_gaps = prefs.get("snapRespectGaps", false),
},
},
decoration = {
-- 18 to match the shell's popover radius, so a window and a panel sitting
-- next to each other read as the same object family.
rounding = 18,
rounding_power = 2,
rounding = prefs.get("windowRounding", 18),
rounding_power = prefs.get("roundingPower", 2),
active_opacity = 1.0,
inactive_opacity = 1.0,
active_opacity = prefs.get("activeOpacity", 1.0),
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = {
enabled = true,
size = 8,
passes = 3,
enabled = prefs.get("blurEnabled", true),
size = prefs.get("blurSize", 8),
passes = prefs.get("blurPasses", 3),
-- Required for blur to be affordable. Never turn this off.
new_optimizations = true,
@@ -75,11 +88,15 @@ hl.config({
},
shadow = {
enabled = true,
range = 20,
render_power = 3,
sharp = false,
enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20),
render_power = prefs.getInt("shadowRenderPower", 3),
sharp = prefs.get("shadowSharp", false),
color = "rgba(15161eee)",
-- Deliberately not a setting: a two-axis offset needs a control we
-- do not have, and a slider bound to half a value is worse than
-- leaving it alone. SystemSettings understands the vec2 shape
-- already, so adding it later is only a matter of the widget.
offset = { 0, 4 },
scale = 1.0,
},
@@ -88,8 +105,8 @@ hl.config({
-- border is the signature, and a strong halo would compete with it.
-- This is just enough to lift the focused window off the wallpaper.
glow = {
enabled = true,
range = 8,
enabled = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 8),
render_power = 2,
color = "rgba(82aaff33)",
color_inactive = "rgba(00000000)",
@@ -99,26 +116,48 @@ hl.config({
motion_blur = { enabled = false },
},
animations = { enabled = true },
animations = { enabled = prefs.get("animationsEnabled", true) },
dwindle = {
-- Keep the split orientation a window was created with. Closest match
-- to how the Forge extension behaved on GNOME.
preserve_split = true,
preserve_split = prefs.get("preserveSplit", true),
smart_resizing = true,
},
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
-- but Settings offers master as a choice, and a layout you can select and
-- cannot configure is barely a choice at all.
master = {
mfact = prefs.get("masterFactor", 0.55),
orientation = prefs.get("masterOrientation", "left"),
new_status = prefs.get("masterNewStatus", "slave"),
new_on_top = prefs.get("masterNewOnTop", false),
},
misc = {
force_default_wallpaper = 0,
disable_hyprland_logo = true,
disable_splash_rendering = true,
-- Stored as "show the logo / show the splash" and written as Hyprland's
-- `disable_*`, matching the `invert` flag on these entries in
-- PreferenceSchema so both sides agree about which way round they are.
disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
disable_splash_rendering = not prefs.get("hyprlandSplash", false),
font_family = "Adwaita Sans",
-- Keep native Wayland selection paste and GTK's matching preference
-- in lockstep. DesktopStyle applies the GTK half only after this value
-- has been read back and stored by SystemSettings.
middle_click_paste = prefs.get("middleClickPaste", true),
-- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree
-- about the interface font and nobody would notice until a tooltip
-- rendered in a different typeface.
font_family = prefs.get("interfaceFont", "Adwaita Sans"),
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
-- content type is "video" or "game" -- the tag rules.lua applies to
-- games. Keeps VRR off the desktop, where it causes visible flicker.
vrr = 3,
vrr = prefs.getInt("vrrPolicy", 3),
-- Blur behind the lock screen.
session_lock_blur = true,
@@ -137,12 +176,12 @@ hl.config({
-- 1 = automatically flip the monitor into HDR for fullscreen content
-- that asks for it, and back out afterwards. This is how games get HDR
-- without the desktop paying the screencopy cost. See monitors.lua.
cm_auto_hdr = 1,
cm_auto_hdr = prefs.getInt("autoHdr", 1),
-- 2 = direct scanout only for windows tagged content = "game"
-- (set by the rules in rules.lua). Bypasses compositing for real
-- fullscreen games.
direct_scanout = 2,
direct_scanout = prefs.getInt("directScanoutPolicy", 2),
},
cursor = {
@@ -158,12 +197,12 @@ hl.config({
sync_gsettings_theme = true,
-- Fade the cursor out after 4s of no movement, like GNOME does.
inactive_timeout = 4,
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
},
ecosystem = {
no_update_news = true,
no_donation_nag = true,
no_update_news = not prefs.get("hyprlandUpdateNews", false),
no_donation_nag = not prefs.get("hyprlandDonationNag", false),
},
xwayland = {
+135 -3
View File
@@ -17,11 +17,125 @@
-- overrides.lua) and read the notes in that file first.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- Per-output overrides written by Panama Settings, keyed by output name:
-- { ["DP-2"] = {
-- mode = "3840x2160@60", scale = 2, transform = 0,
-- x = 0, y = 0, primary = true,
-- } }
--
-- Only mode, scale, and transform are read. Colour management and bit depth
-- stay here, because those are the settings with a documented reason attached
-- (see the header) rather than preferences, and a settings page has no way to
-- explain the screencopy tradeoff at the moment you would be changing it.
local displays = prefs.get("displays", {})
if type(displays) ~= "table" then
displays = {}
end
local function mode_dimensions(mode)
if type(mode) ~= "string" then
return nil, nil
end
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
if width == nil then
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
end
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
if width == nil or height == nil or refresh == nil
or width <= 0 or height <= 0 or refresh <= 0 then
return nil, nil
end
return width, height
end
local function valid_mode(mode)
local width = mode_dimensions(mode)
return width ~= nil
end
local function valid_scale(mode, scale)
local width, height = mode_dimensions(mode)
if width == nil or type(scale) ~= "number" or scale ~= scale
or scale <= 0 or scale > 4 then
return false
end
local logical_width = width / scale
local logical_height = height / scale
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
end
local function valid_transform(transform)
return type(transform) == "number"
and transform == math.floor(transform)
and transform >= 0
and transform <= 3
end
local function valid_coordinate(value)
return type(value) == "number"
and value == value
and value == math.floor(value)
and value >= -100000
and value <= 100000
end
local function valid_position(entry)
return valid_coordinate(entry.x) and valid_coordinate(entry.y)
end
local function valid_primary(entry)
return type(entry.primary) == "boolean"
end
local function has_layout_fields(entry)
return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil
end
local function display_entry(output)
if type(output) ~= "string" or output == ""
or output:match("^[%w_.-]+$") == nil then
return nil
end
local entry = displays[output]
if type(entry) ~= "table" then
return nil
end
if not valid_mode(entry.mode)
or not valid_scale(entry.mode, entry.scale)
or not valid_transform(entry.transform) then
return nil
end
-- Legacy records have none of the layout fields and keep automatic
-- placement. A partially written extended record is unsafe: accepting its
-- mode but guessing its position could overlap or strand another output.
if has_layout_fields(entry)
and (not valid_position(entry) or not valid_primary(entry)) then
return nil
end
return entry
end
local function display_position(entry, fallback)
if entry ~= nil and has_layout_fields(entry) then
return string.format("%dx%d", entry.x, entry.y)
end
return fallback
end
local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5
local shipped_transform = 0
local dp2 = display_entry("DP-2")
hl.monitor({
output = "DP-2",
mode = "4500x3000@60",
position = "0x0",
scale = 1.5,
mode = dp2 and dp2.mode or shipped_mode,
position = display_position(dp2, "0x0"),
scale = dp2 and dp2.scale or shipped_scale,
transform = dp2 and dp2.transform or shipped_transform,
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
@@ -32,6 +146,24 @@ hl.monitor({
cm = "auto",
})
-- Other connected outputs use the same validated per-output store. They keep
-- automatic placement and the compositor's normal colour policy; DP-2 alone
-- carries the panel-specific 10-bit policy documented above.
for output, _ in pairs(displays) do
if output ~= "DP-2" then
local entry = display_entry(output)
if entry ~= nil then
hl.monitor({
output = output,
mode = entry.mode,
position = display_position(entry, "auto"),
scale = entry.scale,
transform = entry.transform,
})
end
end
end
-- Any monitor not named above: sane defaults rather than nothing.
hl.monitor({
output = "",
+252
View File
@@ -0,0 +1,252 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences
--
-- Reads the same file Panama Settings writes:
-- $XDG_CONFIG_HOME/panama/settings.json (default ~/.config/panama/settings.json)
--
-- This is what makes the desktop one product rather than two that happen to
-- agree. The relationship is:
--
-- * the Lua config is the DEFAULT -- every prefs.get() call passes the shipped
-- value as its fallback, so this config still works standalone with no JSON
-- file at all;
-- * the JSON file is the TRUTH -- both Hyprland and Quickshell read it;
-- * Panama Settings is the EDITOR -- it writes the file and applies the change
-- live through `hyprctl eval`, so nothing needs a reload and the two sides
-- cannot drift apart.
--
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
-- file must cost the user nothing worse than their customisations; it must
-- never cost them a working compositor. Every failure path returns the caller's
-- fallback.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = {}
-- ── A small JSON reader ─────────────────────────────────────────────────────
-- Hyprland's Lua has no JSON support and pulling in a rock for a flat object of
-- scalars is not worth the dependency. Handles the whole format apart from
-- non-ASCII \u escapes, which are replaced rather than decoded -- no setting is
-- a non-ASCII string, and mangling one is preferable to failing the parse.
local function decode(text)
local pos = 1
local function skipSpace()
pos = text:find("[^ \t\r\n]", pos) or #text + 1
end
local parseValue
local function parseString()
pos = pos + 1 -- opening quote
local parts = {}
while true do
local char = text:sub(pos, pos)
if char == "" then
error("unterminated string")
elseif char == '"' then
pos = pos + 1
break
elseif char == "\\" then
local escape = text:sub(pos + 1, pos + 1)
local simple = {
n = "\n", t = "\t", r = "\r", b = "\b", f = "\f",
['"'] = '"', ["\\"] = "\\", ["/"] = "/",
}
if simple[escape] then
parts[#parts + 1] = simple[escape]
pos = pos + 2
elseif escape == "u" then
local code = tonumber(text:sub(pos + 2, pos + 5), 16)
parts[#parts + 1] = (code and code < 128) and string.char(code) or "?"
pos = pos + 6
else
error("invalid escape")
end
else
parts[#parts + 1] = char
pos = pos + 1
end
end
return table.concat(parts)
end
local function parseNumber()
local literal = text:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos)
if not literal or literal == "" then
error("invalid number")
end
pos = pos + #literal
local value = tonumber(literal)
if not value then
error("invalid number")
end
return value
end
local function parseObject()
pos = pos + 1 -- opening brace
local out = {}
skipSpace()
if text:sub(pos, pos) == "}" then
pos = pos + 1
return out
end
while true do
skipSpace()
if text:sub(pos, pos) ~= '"' then
error("expected key")
end
local key = parseString()
skipSpace()
if text:sub(pos, pos) ~= ":" then
error("expected colon")
end
pos = pos + 1
out[key] = parseValue()
skipSpace()
local char = text:sub(pos, pos)
pos = pos + 1
if char == "}" then
return out
elseif char ~= "," then
error("expected comma or closing brace")
end
end
end
local function parseArray()
pos = pos + 1 -- opening bracket
local out = {}
skipSpace()
if text:sub(pos, pos) == "]" then
pos = pos + 1
return out
end
while true do
out[#out + 1] = parseValue()
skipSpace()
local char = text:sub(pos, pos)
pos = pos + 1
if char == "]" then
return out
elseif char ~= "," then
error("expected comma or closing bracket")
end
end
end
parseValue = function()
skipSpace()
local char = text:sub(pos, pos)
if char == "{" then
return parseObject()
elseif char == "[" then
return parseArray()
elseif char == '"' then
return parseString()
elseif text:sub(pos, pos + 3) == "true" then
pos = pos + 4
return true
elseif text:sub(pos, pos + 4) == "false" then
pos = pos + 5
return false
elseif text:sub(pos, pos + 3) == "null" then
pos = pos + 4
return nil
elseif char == "" then
error("unexpected end of input")
else
return parseNumber()
end
end
local value = parseValue()
if type(value) ~= "table" then
error("top level value is not an object")
end
return value
end
-- ── Loading ─────────────────────────────────────────────────────────────────
local function settingsPath()
local configHome = os.getenv("XDG_CONFIG_HOME")
if configHome == nil or configHome == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
configHome = home .. "/.config"
end
return configHome .. "/panama/settings.json"
end
local function read()
local path = settingsPath()
if not path then
return {}
end
local file = io.open(path, "r")
if not file then
return {} -- no file yet is the normal first-run case, not an error
end
local text = file:read("*a")
file:close()
if not text or text:match("^%s*$") then
return {}
end
local ok, parsed = pcall(decode, text)
if ok and type(parsed) == "table" then
return parsed
end
return {}
end
-- Loaded once at config time. A pcall around the whole thing so that even an
-- unanticipated failure in the reader degrades to shipped defaults.
local values = {}
do
local ok, parsed = pcall(read)
if ok and type(parsed) == "table" then
values = parsed
end
end
-- ── Public interface ────────────────────────────────────────────────────────
-- Returns the stored value for `key`, or `fallback` when it is absent or is not
-- the same type as the fallback. The type guard matters: a stale or hand-edited
-- file that puts a string where Hyprland needs a number would otherwise abort
-- the config, taking down far more than the one setting that was wrong.
function prefs.get(key, fallback)
local value = values[key]
if value == nil then
return fallback
end
if type(value) ~= type(fallback) then
return fallback
end
return value
end
-- Hyprland has no boolean-to-integer coercion for options that take 0/1, and
-- several of them read more naturally as a toggle in the settings UI.
function prefs.getInt(key, fallback)
local value = values[key]
if type(value) == "boolean" then
return value and 1 or 0
end
if type(value) ~= "number" then
return fallback
end
return math.floor(value + 0.5)
end
-- True when a settings file was actually read. Useful from overrides.lua.
function prefs.loaded()
return next(values) ~= nil
end
return prefs
+2 -32
View File
@@ -183,7 +183,6 @@ font_size 14.0
#: Cursor customization {{{
cursor #c8d3f5
#: Default cursor color. If set to the special value none the cursor
#: will be rendered with a "reverse video" effect. It's color will be
@@ -193,7 +192,6 @@ cursor #c8d3f5
#: precedence. Also, the cursor colors are modified if the cell
#: background and foreground colors have very low contrast.
cursor_text_color #111325
#: The color of text under the cursor. If you want it rendered with
#: the background color of the cell underneath instead, use the
@@ -312,7 +310,6 @@ cursor_shape block
#: robustly with the ever-changing sea of bugs that is Cocoa is too
#: much effort.
url_color #4fd6be
# url_style curly
#: The color and style for highlighting URLs on mouse-over. url_style
@@ -734,12 +731,10 @@ window_border_width 1pt
#: placed centrally. A value of top-left means the padding will be
#: only at the bottom and right edges.
active_border_color #82aaff
#: The color for the border of the active window. Set this to none to
#: not draw borders around the active window.
inactive_border_color #2f334d
#: The color for the border of inactive windows.
@@ -960,16 +955,11 @@ tab_powerline_style round
#: Template to use for active tabs. If not specified falls back to
#: tab_title_template.
active_tab_foreground #1e2030
active_tab_background #82aaff
# active_tab_font_style bold-italic
inactive_tab_foreground #c8d3f5
inactive_tab_background #42465a
# inactive_tab_font_style normal
#: Tab bar colors and styles.
tab_bar_background #222436
#: Background color for the tab bar. Defaults to using the terminal
#: background color.
@@ -983,8 +973,8 @@ tab_bar_margin_color none
#: Color scheme {{{
foreground #c8d3f5
background #222436
include current-theme.conf
#: The foreground and background colors.
@@ -1043,8 +1033,6 @@ dim_opacity 1
#: How much to dim text that has the DIM/FAINT attribute set. One
#: means no dimming and zero means fully dimmed (i.e. invisible).
selection_foreground #2d3f76
selection_background #c8d3f5
#: The foreground and background colors for text selected with the
#: mouse. Setting both of these to none will cause a "reverse video"
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
#: dull and bright version, for the first 16 colors. You can set the
#: remaining 240 colors as color16 to color255.
color0 #1b1d2b
color8 #444a73
#: black
color1 #ff757f
color9 #ff757f
#: red
color2 #a5e8b5
color10 #a5e8b5
#: green
color3 #ffc777
color11 #ffc777
#: yellow
color4 #82aaff
color12 #82aaff
#: blue
color5 #c099ff
color13 #c099ff
#: magenta
color6 #86e1fc
color14 #86e1fc
#: cyan
color7 #828bb8
color15 #c8d3f5
#: white
color16 #ff966c
color17 #c53b53
# mark1_foreground black
@@ -0,0 +1,38 @@
# Tokyo Night Day — the palette's own light variant.
#
# The same hues as Moon at a different lightness, so a terminal in light mode
# still belongs to this desktop rather than looking like a different machine.
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
cursor #3760bf
cursor_text_color #e1e2e7
url_color #118c74
active_border_color #2e7de9
inactive_border_color #c4c8da
active_tab_foreground #e1e2e7
active_tab_background #2e7de9
inactive_tab_foreground #3760bf
inactive_tab_background #c4c8da
tab_bar_background #e1e2e7
foreground #3760bf
background #e1e2e7
selection_foreground #e1e2e7
selection_background #99a7df
color0 #e9e9ed
color8 #a1a6c5
color1 #f52a65
color9 #f52a65
color2 #587539
color10 #587539
color3 #8c6c3e
color11 #8c6c3e
color4 #2e7de9
color12 #2e7de9
color5 #9854f1
color13 #9854f1
color6 #007197
color14 #007197
color7 #6172b0
color15 #3760bf
color16 #b15c00
color17 #c64343
@@ -0,0 +1,42 @@
# Tokyo Night Moon — the dark theme Panama ships.
#
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
# includes current-theme.conf, which Panama generates from the colour scheme
# setting; that generated file is gitignored because it is machine state.
#
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
# remote control. Without it a scheme change would only reach terminals opened
# afterwards.
cursor #c8d3f5
cursor_text_color #111325
url_color #4fd6be
active_border_color #82aaff
inactive_border_color #2f334d
active_tab_foreground #1e2030
active_tab_background #82aaff
inactive_tab_foreground #c8d3f5
inactive_tab_background #42465a
tab_bar_background #222436
foreground #c8d3f5
background #222436
selection_foreground #2d3f76
selection_background #c8d3f5
color0 #1b1d2b
color8 #444a73
color1 #ff757f
color9 #ff757f
color2 #a5e8b5
color10 #a5e8b5
color3 #ffc777
color11 #ffc777
color4 #82aaff
color12 #82aaff
color5 #c099ff
color13 #c099ff
color6 #86e1fc
color14 #86e1fc
color7 #828bb8
color15 #c8d3f5
color16 #ff966c
color17 #c53b53
+3
View File
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
vim.fn.winrestview(view)
end,
})
-- Follow the desktop's light/dark setting while running, not only at startup.
require("config.panama").watch()
+88
View File
@@ -0,0 +1,88 @@
-- Panama desktop integration.
--
-- Neovim is the one application here that neither reads the desktop portal nor
-- has a control socket open by default, so it reads the shared settings file
-- directly -- the same ~/.config/panama/settings.json that the shell and the
-- Hyprland config read.
--
-- Nothing here may raise. A missing or malformed settings file must cost the
-- user their colour scheme preference and nothing else; editing text is more
-- important than matching the desktop.
local M = {}
local function settings_path()
local config_home = os.getenv("XDG_CONFIG_HOME")
if config_home == nil or config_home == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
config_home = home .. "/.config"
end
return config_home .. "/panama/settings.json"
end
-- "dark" or "light". Defaults to dark, which is what Panama ships.
function M.color_scheme()
local path = settings_path()
if not path then
return "dark"
end
local ok, contents = pcall(function()
local file = io.open(path, "r")
if not file then
return nil
end
local text = file:read("*a")
file:close()
return text
end)
if not ok or not contents or contents == "" then
return "dark"
end
local decoded_ok, decoded = pcall(vim.json.decode, contents)
if not decoded_ok or type(decoded) ~= "table" then
return "dark"
end
return decoded.colorScheme == "light" and "light" or "dark"
end
function M.is_light()
return M.color_scheme() == "light"
end
-- tokyonight ships a light variant in the same family, so light mode stays the
-- same identity rather than becoming a different editor theme.
function M.tokyonight_style()
return M.is_light() and "day" or "moon"
end
-- Re-apply the scheme when the window regains focus.
--
-- Neovim reads the setting once at startup and has no control socket open by
-- default, so an editor already running when the desktop scheme flips would
-- otherwise stay on the old palette until it was restarted. FocusGained is
-- cheap, happens exactly when you would notice the mismatch, and does nothing
-- at all unless the scheme actually changed.
function M.watch()
local applied = M.color_scheme()
vim.api.nvim_create_autocmd("FocusGained", {
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
callback = function()
local current = M.color_scheme()
if current == applied then
return
end
applied = current
vim.o.background = current
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
end,
})
end
return M
+62 -25
View File
@@ -1,31 +1,68 @@
-- Tokyo Night, following the desktop's colour scheme.
--
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
-- so the editor keeps the identity the rest of the desktop has rather than
-- becoming a different-looking application when the scheme flips.
--
-- The readability fixes below are deliberately dark-only. They were written
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
-- applying them to Day would put light grey text on a light background, which
-- is exactly the legibility problem they exist to solve, inverted.
local panama = require("config.panama")
return {
{
"folke/tokyonight.nvim",
opts = {
style = "moon",
transparent = true,
on_colors = function(colors)
colors.comment = "#a0a7c5"
colors.fg_gutter = "#787f93"
colors.terminal_black = "#828bb8"
end,
on_highlights = function(highlights, colors)
-- Fix inline code visibility in markdown
highlights["@markup.raw.markdown_inline"] = {
bg = colors.terminal_black,
fg = colors.fg,
}
highlights["RenderMarkdownCodeInline"] = {
bg = colors.terminal_black,
fg = colors.fg,
}
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
-- imports, making text nearly invisible against LspReferenceText's background
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
end,
},
opts = function()
local light = panama.is_light()
return {
style = panama.tokyonight_style(),
light_style = "day",
transparent = true,
on_colors = function(colors)
if light then
-- Day's defaults are tuned for a light ground and mostly need no
-- help. Comments are the exception: the shipped #848cb5 measures
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
-- for secondary text. This is 3.25:1 -- readable, and still clearly
-- dimmer than Normal's 4.52:1 so it does not compete with code.
colors.comment = "#7079a8"
return
end
colors.comment = "#a0a7c5"
colors.fg_gutter = "#787f93"
colors.terminal_black = "#828bb8"
end,
on_highlights = function(highlights, colors)
-- Inline code in markdown is invisible at both lightnesses without
-- an explicit background, because the theme leaves it unset.
highlights["@markup.raw.markdown_inline"] = {
bg = light and colors.bg_highlight or colors.terminal_black,
fg = colors.fg,
}
highlights["RenderMarkdownCodeInline"] = {
bg = light and colors.bg_highlight or colors.terminal_black,
fg = colors.fg,
}
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
-- imports, making text nearly invisible against LspReferenceText's
-- background.
local reference = {
bg = light and colors.bg_visual or colors.fg_gutter,
fg = colors.fg,
}
highlights["LspReferenceText"] = reference
highlights["LspReferenceRead"] = reference
highlights["LspReferenceWrite"] = reference
end,
}
end,
},
{
"LazyVim/LazyVim",
@@ -0,0 +1,136 @@
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
import "services/AudioStreams.js" as AudioStreams
import qs.services
ShellRoot {
readonly property int audioOutStreamFlag: 4
property var fixtureNodes: [
{
id: 10,
ready: true,
type: audioOutStreamFlag,
properties: {
"application.id": "org.chromium.Chromium",
"application.name": "Chromium",
"application.icon_name": "chromium"
},
description: "Chromium audio",
audio: { volume: 0.4, muted: false }
},
{
id: 11,
ready: true,
type: audioOutStreamFlag,
properties: {
"application.id": "org.chromium.Chromium",
"application.name": "Chromium",
"application.icon_name": "chromium"
},
description: "Chromium audio",
audio: { volume: 0.8, muted: true }
},
{
id: 20,
ready: true,
type: audioOutStreamFlag,
properties: {
"application.process.binary": "spotify",
"application.name": "Spotify"
},
description: "Spotify",
audio: { volume: 0.25, muted: false }
},
{
id: 30,
ready: true,
type: 2,
properties: { "application.name": "Microphone capture" },
description: "Input stream",
audio: { volume: 0.5, muted: false }
},
{
id: 40,
ready: false,
type: audioOutStreamFlag,
properties: { "application.name": "Not ready" },
description: "Unready stream",
audio: { volume: 0.5, muted: false }
},
{
id: 99,
ready: true,
type: audioOutStreamFlag,
properties: {},
description: "",
audio: { volume: 1, muted: false }
}
]
function groups(): var {
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
}
IpcHandler {
target: "application-volume-test"
function summary(): string {
const applications = groups();
const chromium = applications.find(application =>
application.key === "org.chromium.Chromium");
return JSON.stringify({
groups: applications.map(application => ({
key: application.key,
label: application.label,
icon: application.icon,
count: application.nodes.length
})),
chromiumVolume: AudioStreams.volume(chromium),
chromiumMuted: AudioStreams.muted(chromium)
});
}
function mutateVolume(): string {
const chromium = groups().find(application =>
application.key === "org.chromium.Chromium");
const changed = AudioStreams.setVolume(chromium, 0.7);
return JSON.stringify({
changed,
volumes: chromium.nodes.map(node => node.audio.volume),
muted: chromium.nodes.map(node => node.audio.muted)
});
}
function mutateMute(): string {
const chromium = groups().find(application =>
application.key === "org.chromium.Chromium");
const changed = AudioStreams.setMuted(chromium, true);
return JSON.stringify({
changed,
muted: chromium.nodes.map(node => node.audio.muted)
});
}
function serviceSummary(): string {
const applications = AudioDevices.applications;
return JSON.stringify({
count: applications.length,
validTypes: applications.every(application =>
application.nodes.every(node =>
(node.type & PwNodeType.AudioOutStream)
=== PwNodeType.AudioOutStream))
});
}
function invalidMutations(): string {
return JSON.stringify({
nullVolume: AudioDevices.setApplicationVolume(null, 0.5),
emptyMute: AudioDevices.setApplicationMuted({ nodes: [] }, true)
});
}
}
}
@@ -1,8 +1,23 @@
pragma Singleton
// User choices that Panama Settings may change at runtime. This is deliberately
// separate from Settings.qml: the latter remains the stable public surface used
// by the shell, while this object owns durable mutation and shipped defaults.
// ─────────────────────────────────────────────────────────────────────────────
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
//
// This object owns reading, validating, and writing. It knows nothing about
// which settings exist -- that is the schema's job -- so adding a setting never
// requires touching this file. That is the point: the previous implementation
// restated every key four times (a property alias, a JSON adapter property, a
// change handler, and a line in reset), and each omission failed silently.
//
// Reads go through get(), writes through set(). Typed accessors for the values
// the shell reads on every frame live in Settings.qml, which remains the stable
// public surface.
//
// The store lives at ~/.config/panama/settings.json rather than inside
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
// able to back it up, diff it, or keep it in a dotfiles repo.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
@@ -11,110 +26,152 @@ import QtQuick
Singleton {
id: root
property alias use24Hour: values.use24Hour
property alias showSeconds: values.showSeconds
property alias showWeekday: values.showWeekday
property alias showCpu: values.showCpu
property alias showMemory: values.showMemory
property alias showGpu: values.showGpu
property alias dockAutohide: values.dockAutohide
property alias dockRevealDelayMs: values.dockRevealDelayMs
property alias dockHideDelayMs: values.dockHideDelayMs
property alias focusDurationMinutes: values.focusDurationMinutes
property alias autoHdr: values.autoHdr
property alias vrrPolicy: values.vrrPolicy
property alias directScanoutPolicy: values.directScanoutPolicy
property alias nightLightEnabled: values.nightLightEnabled
property alias nightLightAutomatic: values.nightLightAutomatic
property alias nightLightTemperature: values.nightLightTemperature
property alias lastPage: values.lastPage
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
// Bumped on every accepted change. get() reads it so bindings built on get()
// have something to invalidate; a bare function call would otherwise capture
// no dependency and every reader would silently go stale.
property int revision: 0
// Everything currently on disk, including keys this build does not know
// about. Unknown keys are carried through untouched so that rolling back to
// an older Panama does not discard a newer version's settings.
property var values: ({})
property bool loaded: false
function get(key: string): var {
root.revision;
const stored = root.values[key];
if (stored === undefined)
return PreferenceSchema.defaultFor(key);
const coerced = PreferenceSchema.coerce(key, stored);
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
}
// Returns false when the key is unknown or the value cannot be represented,
// so a caller can surface the rejection instead of assuming it took.
function set(key: string, value: var): bool {
if (!PreferenceSchema.has(key))
return false;
const coerced = PreferenceSchema.coerce(key, value);
if (coerced === undefined)
return false;
if (root.values[key] === coerced)
return true;
// Reassign rather than mutate: QML does not notify on in-place changes
// to a var property's contents.
const next = Object.assign({}, root.values);
next[key] = coerced;
root.values = next;
root.revision++;
persistTimer.restart();
return true;
}
// Restores every schema default in one write. Complete by construction --
// there is no hand-maintained list to fall out of sync with the schema.
function resetDesktopDefaults(): void {
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
root.values = next;
root.revision++;
persistTimer.restart();
}
// Re-read the file from disk. Used after something outside the shell has
// rewritten it -- restoring a snapshot, or a hand edit -- so the running
// desktop reflects the new contents without waiting for the next change.
function reload(): void {
preferencesFile.reload();
root.load();
}
function load(): void {
let parsed = {};
try {
const text = preferencesFile.text();
if (text && text.trim().length > 0)
parsed = JSON.parse(text);
} catch (error) {
// A corrupt file must not cost the user a working desktop. Fall
// back to shipped defaults and let the next write replace it.
parsed = {};
}
const raw = (parsed && typeof parsed === "object") ? parsed : {};
// Upgrade before anything reads a value. A stored key the current
// schema no longer recognises is carried through untouched and silently
// stops taking effect, so the conversion has to happen here rather than
// being noticed later by whoever owns that setting.
const result = Migrations.apply(raw);
root.values = result.values;
if (result.migrated)
console.info("Settings migrated from version", result.from, "to", result.to + ":",
result.applied.join("; "));
// Write whenever the version moved, which includes stamping a file
// written before versioning existed. Left unwritten, the stamp lives
// only in memory and is redone on every launch, and a migration that is
// not idempotent would compound.
if (result.changed)
persistTimer.restart();
root.revision++;
root.loaded = true;
}
FileView {
id: preferencesFile
path: Quickshell.stateDir + "/panama-settings.json"
path: root.path
blockLoading: true
printErrors: false
atomicWrites: true
JsonAdapter {
id: values
property bool use24Hour: false
property bool showSeconds: true
property bool showWeekday: true
property bool showCpu: true
property bool showMemory: true
property bool showGpu: true
property bool dockAutohide: true
property int dockRevealDelayMs: 0
property int dockHideDelayMs: 250
property int focusDurationMinutes: 45
property bool autoHdr: true
property int vrrPolicy: 3
property int directScanoutPolicy: 2
property bool nightLightEnabled: false
property bool nightLightAutomatic: false
property int nightLightTemperature: 3500
property string lastPage: "home"
}
onLoaded: root.load()
// No file yet is the normal first-run case, not an error.
onLoadFailed: root.load()
}
// Listen after the adapter has been constructed instead of writing from
// FileView.onAdapterUpdated. The latter also fires for default-property
// initialization, which can overwrite a valid file before it is loaded.
Connections {
target: values
function onUse24HourChanged(): void { persistTimer.restart(); }
function onShowSecondsChanged(): void { persistTimer.restart(); }
function onShowWeekdayChanged(): void { persistTimer.restart(); }
function onShowCpuChanged(): void { persistTimer.restart(); }
function onShowMemoryChanged(): void { persistTimer.restart(); }
function onShowGpuChanged(): void { persistTimer.restart(); }
function onDockAutohideChanged(): void { persistTimer.restart(); }
function onDockRevealDelayMsChanged(): void { persistTimer.restart(); }
function onDockHideDelayMsChanged(): void { persistTimer.restart(); }
function onFocusDurationMinutesChanged(): void { persistTimer.restart(); }
function onAutoHdrChanged(): void { persistTimer.restart(); }
function onVrrPolicyChanged(): void { persistTimer.restart(); }
function onDirectScanoutPolicyChanged(): void { persistTimer.restart(); }
function onNightLightEnabledChanged(): void { persistTimer.restart(); }
function onNightLightAutomaticChanged(): void { persistTimer.restart(); }
function onNightLightTemperatureChanged(): void { persistTimer.restart(); }
function onLastPageChanged(): void { persistTimer.restart(); }
Component.onCompleted: {
migration.adopt();
root.load();
}
// Singleton construction can happen after FileView's preload phase when a
// test or a lazy page first references it. An explicit reload makes the
// durable state authoritative in that case as well as in the main shell.
Component.onCompleted: preferencesFile.reload()
// Coalesce a group of UI changes into one atomic write. Writing from the
// adapter's change signal itself can race a second property assignment and
// reload the older value before the batch has finished.
// Coalesce a burst of changes into one atomic write. Writing on every
// assignment races a second assignment and can reload the older value.
Timer {
id: persistTimer
interval: 0
onTriggered: preferencesFile.writeAdapter()
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
}
function resetDesktopDefaults(): void {
values.use24Hour = false;
values.showSeconds = true;
values.showWeekday = true;
values.showCpu = true;
values.showMemory = true;
values.showGpu = true;
values.dockAutohide = true;
values.dockRevealDelayMs = 0;
values.dockHideDelayMs = 250;
values.focusDurationMinutes = 45;
values.autoHdr = true;
values.vrrPolicy = 3;
values.directScanoutPolicy = 2;
values.nightLightEnabled = false;
values.nightLightAutomatic = false;
values.nightLightTemperature = 3500;
values.lastPage = "home";
// One-time move from the pre-Stage-1 location inside Quickshell's state
// directory. Reads the old file only when the new one does not exist yet, so
// it can never overwrite newer settings, and never deletes the original.
QtObject {
id: migration
function adopt(): void {
if (preferencesFile.text())
return;
try {
const legacy = legacyFile.text();
if (legacy && legacy.trim().length > 0)
preferencesFile.setText(legacy);
} catch (error) {
// Nothing to migrate.
}
}
}
FileView {
id: legacyFile
path: Quickshell.stateDir + "/panama-settings.json"
blockLoading: true
printErrors: false
}
}
@@ -0,0 +1,176 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property alias initialized: values.initialized
property alias favorites: values.favorites
property string saveError: ""
FileView {
id: preferencesFile
path: Quickshell.stateDir + "/panama-home.json"
blockLoading: true
printErrors: false
atomicWrites: true
onSaved: root.saveError = ""
onSaveFailed: error => root.saveError = "Could not save Home favourites."
JsonAdapter {
id: values
property bool initialized: false
property var favorites: []
}
}
Connections {
target: values
function onInitializedChanged(): void { persistTimer.restart(); }
function onFavoritesChanged(): void { persistTimer.restart(); }
}
Component.onCompleted: preferencesFile.reload()
Timer {
id: persistTimer
interval: 180
repeat: false
onTriggered: preferencesFile.writeAdapter()
}
function cloneFavorites(): var {
var clone = [];
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
clone.push({
id: favorite.id,
alias: favorite.alias
});
}
return clone;
}
function isValidEntityId(entityId: var): bool {
return typeof entityId === "string" && /^light\.[a-z0-9_]+$/.test(entityId);
}
function initialize(legacyIds: var): void {
if (values.initialized) {
return;
}
var seededFavorites = [];
var seen = {};
if (legacyIds && typeof legacyIds.length === "number") {
for (var index = 0; index < legacyIds.length; index++) {
var entityId = legacyIds[index];
if (!isValidEntityId(entityId) || seen[entityId]) {
continue;
}
seen[entityId] = true;
seededFavorites.push({ id: entityId, alias: "" });
}
}
values.favorites = seededFavorites;
values.initialized = true;
}
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) {
return true;
}
}
return false;
}
function aliasFor(entityId: string, sourceName: string): string {
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId && favorite.alias !== "") {
return favorite.alias;
}
}
return sourceName;
}
function add(entityId: string): void {
if (!isValidEntityId(entityId) || isSelected(entityId)) {
return;
}
var nextFavorites = cloneFavorites();
nextFavorites.push({ id: entityId, alias: "" });
values.favorites = nextFavorites;
}
function remove(entityId: string): void {
var nextFavorites = [];
var removed = false;
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId) {
removed = true;
continue;
}
nextFavorites.push({ id: favorite.id, alias: favorite.alias });
}
if (removed) {
values.favorites = nextFavorites;
}
}
function setAlias(entityId: string, alias: string): void {
var nextFavorites = cloneFavorites();
var updated = false;
var trimmedAlias = String(alias).trim();
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
nextFavorites[index] = { id: entityId, alias: trimmedAlias };
updated = true;
break;
}
}
if (updated) {
values.favorites = nextFavorites;
}
}
function move(entityId: string, targetIndex: int): void {
var nextFavorites = cloneFavorites();
var currentIndex = -1;
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
currentIndex = index;
break;
}
}
if (currentIndex === -1) {
return;
}
var favorite = nextFavorites.splice(currentIndex, 1)[0];
var clampedIndex = Math.max(0, Math.min(targetIndex, nextFavorites.length));
nextFavorites.splice(clampedIndex, 0, favorite);
values.favorites = nextFavorites;
}
function retrySave(): void {
preferencesFile.writeAdapter();
}
}
+121
View File
@@ -0,0 +1,121 @@
pragma Singleton
// Versioned upgrades for the settings file.
//
// The schema is the single source of truth for what a setting IS, but it cannot
// describe what a setting USED to be. Renaming a key, changing its units, or
// splitting one setting into two all leave a stored value that the new schema
// does not recognise -- and an unrecognised key is silently carried through
// untouched, so the user's choice simply stops taking effect with nothing to
// say why. That is the failure this exists to prevent.
//
// HOW IT WORKS
//
// settings.json carries a schemaVersion. On load, every migration with a
// version ABOVE the stored one runs in order, then the file is stamped with
// `current`. A file with no schemaVersion at all is a file written before this
// existed; it is stamped at `baseline` and NOT migrated, because those
// migrations were never written for it.
//
// WRITING ONE
//
// { version: 2, describe: "rename dockDelay to dockHideDelayMs",
// migrate: values => { ... return values; } }
//
// Rules that make this safe to run against a real user's file:
//
// * migrate() receives the whole values object and returns it. Mutating and
// returning the same object is fine.
// * NEVER delete a key you are not replacing. Unknown keys are deliberately
// preserved so that rolling back to an older Panama does not discard a
// newer version's settings, and a migration is the one place that promise
// could quietly be broken.
// * A migration must tolerate its input being absent or the wrong type. It
// runs against files written by every previous version, including ones
// that were hand-edited.
// * Migrations never run twice: the stored version only moves forward.
import QtQuick
QtObject {
id: root
// What a file written today is stamped with. Bump this when adding a
// migration, to the version of the migration you added.
readonly property int current: 1
// Files predating versioning are stamped here without being migrated.
readonly property int baseline: 1
readonly property string versionKey: "schemaVersion"
// Ordered by version. Empty is the correct state until the first breaking
// schema change -- this exists so that change is a routine edit rather than
// an emergency.
readonly property var steps: []
function storedVersion(values: var): int {
const raw = values ? values[root.versionKey] : undefined;
return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0;
}
// Returns { values, migrated, changed, from, to, applied }.
//
// `applied` names each step that ran, so the caller can log something
// meaningful rather than "settings changed somehow". `changed` is the one
// the caller should write on: stamping a pre-versioning file changes it
// without running any step, and left unwritten the stamp would live only in
// memory and be redone on every launch.
function apply(values: var): var {
return root.applyWith(values, root.steps, root.current, root.baseline);
}
// The same logic with the step list injected, so the machinery can be
// tested against fixture migrations. The real list is empty until the first
// breaking schema change, and a mechanism that has never run against a
// failing step is not one to find out about during an upgrade.
function applyWith(values: var, steps: var, current: int, baseline: int): var {
const safe = (values && typeof values === "object") ? values : {};
const from = root.storedVersion(safe);
// No version: written before versioning existed. Stamp it and stop.
// Running the migration list against it would apply upgrades designed
// for schemas this file never had.
if (from === 0) {
safe[root.versionKey] = baseline;
return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] };
}
// A file from a NEWER Panama. Left completely alone: downgrading its
// keys is not something this can do correctly, and unknown keys are
// already preserved, so the older build simply ignores what it does not
// understand.
if (from > current)
return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] };
const applied = [];
let working = safe;
for (const step of steps) {
if (step.version <= from || step.version > current)
continue;
try {
const result = step.migrate(working);
if (result && typeof result === "object")
working = result;
applied.push(step.version + ": " + step.describe);
} catch (error) {
// One bad migration must not cost the user every setting. Stop
// at the last good version so the next launch retries from
// here rather than skipping the failed step forever.
console.warn("Migrations: step", step.version, "failed:", error);
working[root.versionKey] = step.version - 1;
return { values: working, migrated: applied.length > 0, changed: applied.length > 0,
from: from, to: step.version - 1, applied: applied };
}
}
working[root.versionKey] = current;
return { values: working, migrated: applied.length > 0, changed: from !== current,
from: from, to: current, applied: applied };
}
}
File diff suppressed because it is too large Load Diff
+29 -46
View File
@@ -15,90 +15,73 @@ Singleton {
// ── Clock ───────────────────────────────────────────────────────────────
// Carried over from GNOME: 12-hour, weekday + date shown, seconds on.
readonly property bool use24Hour: DesktopPreferences.use24Hour
readonly property bool showSeconds: DesktopPreferences.showSeconds
readonly property bool showWeekday: DesktopPreferences.showWeekday
readonly property bool use24Hour: DesktopPreferences.get("use24Hour")
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
// ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already
// resolved the location. Uses Open-Meteo, which needs no API key.
readonly property real latitude: 27.7375
readonly property real longitude: -82.6861
readonly property real latitude: DesktopPreferences.get("weatherLatitude")
readonly property real longitude: DesktopPreferences.get("weatherLongitude")
// Open-Meteo returns coordinates but no friendly place name. Keep the
// label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them.
readonly property string weatherLocation: "Local weather"
readonly property string temperatureUnit: "fahrenheit"
readonly property int weatherRefreshMinutes: 20
readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
// ── Vitals ──────────────────────────────────────────────────────────────
// The GNOME Vitals extension showed processor usage, memory usage and GPU
// usage, in that order. Same here.
readonly property int vitalsIntervalMs: 2000
readonly property bool showCpu: DesktopPreferences.showCpu
readonly property bool showMemory: DesktopPreferences.showMemory
readonly property bool showGpu: DesktopPreferences.showGpu
readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu")
// amdgpu exposes utilisation here. Verified present on this machine; the
// widget hides itself if the path is missing rather than showing zeros.
readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent"
readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath")
// ── Night light ─────────────────────────────────────────────────────────
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
readonly property int nightLightTemperature: DesktopPreferences.nightLightTemperature
readonly property real nightLightFrom: 17.0
readonly property real nightLightTo: 10.0
readonly property bool nightLightEnabledByDefault: DesktopPreferences.nightLightEnabled
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ───────────────────────────────────────────────────────
readonly property int notificationTimeoutMs: 5000
readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire
readonly property int notificationHistoryLimit: 100
readonly property int maxVisibleToasts: 4
readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and
// the keyboard shortcut should start a useful session in one action.
readonly property int focusDurationMinutes: DesktopPreferences.focusDurationMinutes
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
// ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites.
readonly property list<string> dockPinned: [
"org.gnome.Settings",
"kitty",
"org.gnome.Nautilus",
"com.bitwarden.desktop",
"org.gnome.Software",
"helium",
"org.mozilla.thunderbird_esr",
"com.slack.Slack",
"app.bluebubbles.BlueBubbles",
"rustdesk",
"io.podman_desktop.PodmanDesktop",
"claude-desktop",
"codex-desktop",
"md.obsidian.Obsidian",
"com.obsproject.Studio",
"steam"
]
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
// Dash-to-Dock was set to intellihide against all windows: the dock hides
// when any window would overlap it, and comes back on hover.
readonly property bool dockAutohide: DesktopPreferences.dockAutohide
readonly property bool dockAutohide: DesktopPreferences.get("dockAutohide")
// 0: reveal the instant the pointer reaches the bottom edge. A reveal delay
// is indistinguishable from lag, because the user has already committed to
// the gesture by the time the strip is hit.
readonly property int dockRevealDelayMs: DesktopPreferences.dockRevealDelayMs
readonly property int dockRevealDelayMs: DesktopPreferences.get("dockRevealDelayMs")
// Hiding keeps a delay, so brushing past the bottom edge or crossing the
// gap between two icons doesn't make the dock flicker.
readonly property int dockHideDelayMs: DesktopPreferences.dockHideDelayMs
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
// ── Capture ─────────────────────────────────────────────────────────────
readonly property string screenshotDir: "Pictures/Screenshots"
readonly property string recordingDir: "Videos/Recordings"
readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
readonly property string recordingDir: DesktopPreferences.get("recordingDir")
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming.
readonly property string recorderArgs: "-c h264_vaapi -d /dev/dri/renderD128"
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
}
+51 -32
View File
@@ -18,36 +18,47 @@ import QtQuick
Singleton {
id: root
// ── Colour scheme ───────────────────────────────────────────────────────
// Tokyo Night ships an official light variant (Day), so light mode is that
// rather than a palette invented to merely not be dark. The two share the
// same hues at different lightness, which is what lets the Prism identity
// survive the switch: blue still leads into orchid, it is simply a darker
// blue on a lighter ground.
//
// Every token below is a binding on this, so flipping it repaints the whole
// shell without anything needing to know it happened.
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// ── Palette ─────────────────────────────────────────────────────────────
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
readonly property color bg: "#222436"
readonly property color bgDark: "#1e2030"
readonly property color bgHighlight: "#2f334d"
readonly property color bgPanel: "#2e2f3d" // dock glass surface
readonly property color bgPopover: "#21212f" // Openbar submenu background
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
readonly property color fg: "#c8d3f5"
readonly property color fgDim: "#828bb8"
readonly property color fgMuted: "#636da6"
readonly property color gutter: "#3b4261"
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
// The pair. `accent` is the primary and carries every state meaning
// (focused, active, on). `accentSecondary` is the orchid from the tmux
// theme — it never appears alone, only as the far end of a gradient. That
// restraint is the whole point: the two colours meeting is the signature,
// so the pink stops being special the moment it's used as a flat fill.
readonly property color accent: "#82aaff" // blue
readonly property color accentSecondary: "#b172b0" // orchid, from tmux
readonly property color accentAlt: "#65bcff" // blue1, a lighter blue
readonly property color cyan: "#86e1fc"
readonly property color teal: "#4fd6be"
readonly property color green: "#c3e88d"
readonly property color yellow: "#ffc777"
readonly property color orange: "#ff966c"
readonly property color red: "#ff757f"
readonly property color redDeep: "#c53b53"
readonly property color magenta: "#c099ff"
readonly property color pink: "#fca7ea"
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
readonly property color green: root.dark ? "#c3e88d" : "#587539"
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
// Semantic aliases — prefer these in widgets so intent survives a repaint.
readonly property color ok: green
@@ -59,11 +70,14 @@ Singleton {
// The dock sits on compositor blur, so its fill is deliberately very
// translucent. Popovers are near-opaque because they carry text that must
// stay legible. The bar itself has no surface or alpha token.
readonly property real dockAlpha: 0.34
readonly property real popoverAlpha: 0.92
readonly property real overlayAlpha: 0.55
readonly property real hoverAlpha: 0.14
readonly property real activeAlpha: 0.24
// Light surfaces need more opacity for the same sense of a solid panel: the
// same 0.34 that reads as glass over a dark desktop reads as haze over a
// light one, and text on it stops being legible.
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
// ── Geometry ────────────────────────────────────────────────────────────
readonly property int barHeight: 36
@@ -78,6 +92,8 @@ Singleton {
readonly property int popoverRadius: 18
readonly property int popoverPadding: 14
readonly property int popoverWidth: 380
readonly property int controlCenterWidth: 430
readonly property int controlCenterTopGap: 2
readonly property int cardRadius: 12
readonly property int pillRadius: 999
@@ -88,12 +104,12 @@ Singleton {
// Adwaita Sans for everything the user reads as text. No exceptions: a
// monospaced clock or percentage reads as a terminal readout pasted into a
// UI, which is the opposite of the intent here.
readonly property string fontFamily: "Adwaita Sans"
readonly property string fontFamily: DesktopPreferences.get("interfaceFont")
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
// pragmatic alternative to freedesktop symbolic icons, which ship with a
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
readonly property string fontMono: "VictorMono Nerd Font"
readonly property string fontMono: DesktopPreferences.get("iconFont")
// Apply to any text whose digits change in place — clocks, percentages,
// elapsed timers, dimension readouts. Tabular figures all share one
@@ -105,10 +121,13 @@ Singleton {
readonly property var tabularFigures: ({
"tnum": 1
})
readonly property int fontSize: 13
readonly property int fontSizeSmall: 11
readonly property int fontSizeLarge: 16
readonly property int fontSizeTitle: 20
// The four sizes are a scale, not four independent numbers: they move
// together so the relationship between body, caption, heading and title
// survives a change to the base rather than drifting apart.
readonly property int fontSize: DesktopPreferences.get("interfaceFontSize")
readonly property int fontSizeSmall: Math.max(9, root.fontSize - 2)
readonly property int fontSizeLarge: root.fontSize + 3
readonly property int fontSizeTitle: root.fontSize + 7
// ── Motion ──────────────────────────────────────────────────────────────
// Event-driven only. Nothing in this shell animates while idle — no pulse,
+3
View File
@@ -1,4 +1,7 @@
module qs.config
singleton DesktopPreferences 1.0 DesktopPreferences.qml
singleton PreferenceSchema 1.0 PreferenceSchema.qml
singleton HomePreferences 1.0 HomePreferences.qml
singleton Settings 1.0 Settings.qml
singleton Theme 1.0 Theme.qml
singleton Migrations 1.0 Migrations.qml
@@ -0,0 +1,28 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
IpcHandler {
target: "connectivity-test"
function status(): string {
return JSON.stringify({
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
networks: Connectivity.networks.length,
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
adapter: Connectivity.adapter ? true : false,
btDevices: Connectivity.bluetoothDevices.length
});
}
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
function setActive(on: bool): void { Connectivity.active = on; }
}
}
@@ -0,0 +1,81 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.settings
import qs.services
ShellRoot {
DisplayIdentify {}
QtObject {
id: fixtureService
property var monitors: [
{
name: "DP-2", description: "Primary display", width: 4500, height: 3000,
refreshRate: 60, mode: "[email protected]", scale: 1.5,
transform: 0, x: 0, y: 0, primary: true
},
{
name: "HDMI-A-1", description: "Second display", width: 2560, height: 1440,
refreshRate: 60, mode: "[email protected]", scale: 1,
transform: 0, x: 3000, y: 0, primary: false
}
]
property var applied: []
function currentLayout(): var {
return monitors.map(record => Object.assign({}, record));
}
function applyLayout(layout: var): bool {
applied = layout.map(record => Object.assign({}, record));
return true;
}
}
DisplayArrangement {
id: arrangement
width: 800
displayService: fixtureService
selectedOutput: "HDMI-A-1"
}
IpcHandler {
target: "display-arrangement-test"
function status(width: int): string {
arrangement.width = width;
arrangement.resetDraft();
return JSON.stringify(arrangement.canvasSnapshot());
}
function dragFixture(): string {
arrangement.resetDraft();
arrangement.setDraftPosition("HDMI-A-1", 3016, 0, true);
arrangement.applyDraft();
return JSON.stringify(fixtureService.applied);
}
function keyboardFixture(): string {
arrangement.resetDraft();
arrangement.nudge("HDMI-A-1", -10, 0);
const afterArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
arrangement.nudge("HDMI-A-1", -100, 0);
const afterShiftArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
return JSON.stringify({ afterArrow, afterShiftArrow });
}
function primaryFixture(): string {
arrangement.resetDraft();
arrangement.makePrimary("HDMI-A-1");
return JSON.stringify(fixtureService.applied.map(record => ({
name: record.name, x: record.x, y: record.y, primary: record.primary
})));
}
function identify(): void { Displays.identify(); }
function identifying(): bool { return Displays.identifying; }
}
}
@@ -0,0 +1,50 @@
import Quickshell
import Quickshell.Io
import QtQuick
import "services/DisplayLayout.js" as DisplayLayout
ShellRoot {
readonly property var fixture: [
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true },
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
]
IpcHandler {
target: "display-layout-test"
function status(): string {
const normalized = DisplayLayout.normalize(fixture);
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
const near = normalized.map(record => Object.assign({}, record));
near[1].x = 3016;
const far = normalized.map(record => Object.assign({}, record));
far[1].x = 3017;
return JSON.stringify({
valid: DisplayLayout.validate(fixture),
sizes: fixture.map(DisplayLayout.logicalSize),
normalized: normalized.map(record => ({ name: record.name, x: record.x, y: record.y, primary: record.primary })),
bounds: canvas.bounds,
canvasScale: canvas.scale,
canvasRects: canvas.rects,
near: DisplayLayout.snap(near, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x,
far: DisplayLayout.snap(far, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x
});
}
function invalid(): string {
const base = fixture.map(record => Object.assign({}, record));
const cases = [];
const add = layout => cases.push(DisplayLayout.validate(layout));
add([base[0], Object.assign({}, base[1], { name: "DP-2" })]);
add(base.map(record => Object.assign({}, record, { primary: false })));
add(base.map(record => Object.assign({}, record, { primary: true })));
add([Object.assign({}, base[0], { x: 0.5 }), base[1]]);
add([Object.assign({}, base[0], { scale: 0 }), base[1]]);
add([Object.assign({}, base[0], { transform: 4 }), base[1]]);
add([Object.assign({}, base[0], { width: Infinity }), base[1]]);
add([Object.assign({}, base[0], { width: 0 }), base[1]]);
return JSON.stringify(cases);
}
}
}
+145
View File
@@ -0,0 +1,145 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "displays-test"
function status(): string {
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
return JSON.stringify({
count: Displays.monitors.length,
name: monitor ? monitor.name : "",
width: monitor ? monitor.width : 0,
height: monitor ? monitor.height : 0,
refresh: monitor ? monitor.refreshRate : 0,
mode: monitor ? monitor.mode : "",
scale: monitor ? monitor.scale : 0,
transform: monitor ? monitor.transform : -1,
x: monitor ? monitor.x : 0,
y: monitor ? monitor.y : 0,
primary: monitor ? monitor.primary : false,
modes: monitor ? monitor.modes.length : 0,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
secondsLeft: Displays.secondsLeft,
lastError: Displays.lastError,
overridden: monitor ? Displays.isOverridden(monitor.name) : false
});
}
function applyScale(scale: real): bool {
const monitor = Displays.monitors[0];
if (!monitor) return false;
const mode = monitor.mode;
return Displays.apply(monitor.name, mode, scale, monitor.transform);
}
function transactionStatus(): string {
return JSON.stringify({
layout: Displays.currentLayout(),
pending: Displays.pendingRequestedLayout,
previous: Displays.pendingPreviousLayout,
reverting: Displays.revertExpectedLayout,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
busy: Displays.busy,
generation: Displays.operationGeneration,
revertGeneration: Displays.revertGeneration,
lastError: Displays.lastError
});
}
function applyLayoutFixture(secondX: int, secondY: int): bool {
const layout = Displays.currentLayout();
if (layout.length !== 2) return false;
layout[0].x = 0;
layout[0].y = 0;
layout[0].primary = true;
layout[1].x = secondX;
layout[1].y = secondY;
layout[1].primary = false;
return Displays.applyLayout(layout);
}
function makePrimaryFixture(output: string): bool {
return Displays.makePrimary(output);
}
function injectReadback(text: string, generation: int): void {
Displays.parse(text, generation);
}
function expireApplyVerification(): void {
Displays.verificationTimedOut();
}
function expireRevertVerification(): void {
Displays.revertVerificationTimedOut();
}
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
"[email protected]",
"[email protected]"
]);
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
return JSON.stringify({
count: modes.length,
modes: modes.map(mode => mode.mode),
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
});
}
function positionFixture(): string {
const previous = Displays.monitors;
Displays.parse(JSON.stringify([
{
name: "DP-2", description: "Primary", width: 4500, height: 3000,
refreshRate: 60, scale: 1.5, transform: 0, x: 140, y: 80,
availableModes: ["[email protected]"]
},
{
name: "HDMI-A-1", description: "Second", width: 2560, height: 1440,
refreshRate: 60, scale: 1, transform: 0, x: 3140, y: 80,
availableModes: ["[email protected]"]
}
]), Displays.operationGeneration);
const result = JSON.stringify(Displays.monitors.map(monitor => ({
name: monitor.name,
x: monitor.x,
y: monitor.y,
primary: monitor.primary
})));
Displays.monitors = previous;
return result;
}
function applyBad(kind: string): bool {
const monitor = Displays.monitors[0];
if (!monitor) return false;
const mode = monitor.mode;
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
if (kind === "dirtyScale") {
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
}
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
return false;
}
function confirmChange(): bool { return Displays.confirm(); }
function revertChange(): void { Displays.revert(); }
function forget(): void {
const monitor = Displays.monitors[0];
if (monitor) Displays.forget(monitor.name);
}
function refresh(): void { Displays.refresh(); }
}
}
@@ -0,0 +1,35 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
// DesktopEntries populates asynchronously. Reading `applications.values`
// here rather than calling byId() blind is the documented way to wait for
// it -- and is exactly the trap noted in modules/settings/README.md.
readonly property var ids: {
const out = {};
for (const entry of DesktopEntries.applications.values)
out[entry.id] = entry;
return out;
}
IpcHandler {
target: "dock-pin-test"
function count(): int { return DesktopEntries.applications.values.length; }
function resolve(): string {
const pinned = DesktopPreferences.get("dockPinned");
const missing = [];
let first = null;
for (const id of pinned) {
const entry = DesktopEntries.byId(id);
if (!entry) missing.push(id);
if (first === null)
first = { id: id, found: !!entry, name: entry ? entry.name : "", icon: entry ? entry.icon : "" };
}
return JSON.stringify({ total: pinned.length, first: first, missing: missing });
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
// The production singleton still performs its delayed startup scan. The
// harness turns it off before its 2200 ms deadline so each fixture drives
// only the state transition it is asserting.
Component.onCompleted: Health.startupScanEnabled = false
IpcHandler {
target: "health-test"
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
function queue(): void { Health.refresh(); Health.refresh(); }
function status(): string { return JSON.stringify(Health.diagnostics()); }
function repair(id: string): bool { return Health.repair(id, false); }
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
function copy(): bool { return Health.copyReport(); }
}
}
@@ -0,0 +1,54 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.quicksettings
ShellRoot {
id: root
property int confirmedValue: 30
property int commitCount: 0
property int lastCommit: -1
HomeBrightnessSlider {
id: slider
width: 200
value: root.confirmedValue
accessibleName: "Desk lamp brightness"
onCommitted: value => {
root.commitCount += 1;
root.lastCommit = value;
}
}
IpcHandler {
target: "home-brightness-slider-test"
function reset(value: int): void {
root.confirmedValue = value;
root.commitCount = 0;
root.lastCommit = -1;
slider.cancelPointerInteraction();
}
function external(value: int): void { root.confirmedValue = value; }
function press(position: int): void { slider.beginPointerInteraction(position); }
function move(position: int): void { slider.movePointerInteraction(position); }
function release(): void { slider.releasePointerInteraction(); }
function cancel(): void { slider.cancelPointerInteraction(); }
function wheel(delta: int): void { slider.commitWheel(delta); }
function status(): string {
return JSON.stringify({
confirmedValue: root.confirmedValue,
previewValue: slider.previewValue,
interactionActive: slider.interactionActive,
commitCount: root.commitCount,
lastCommit: root.lastCommit,
accessibleRoleIsSlider: slider.Accessible.role === Accessible.Slider,
accessibleName: slider.Accessible.name,
accessibleDescription: slider.Accessible.description,
accessibleFocusable: slider.Accessible.focusable
});
}
}
}
@@ -0,0 +1,26 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
IpcHandler {
target: "home-pref-test"
function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
function add(id: string): void { HomePreferences.add(id); }
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); }
function reset(): void { HomePreferences.resetHomeDefaults(); }
function status(): string {
return JSON.stringify({
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites,
saveError: HomePreferences.saveError,
stateDir: Quickshell.stateDir
});
}
}
}
@@ -0,0 +1,58 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "keybinds-test"
function rebind(current: string, next: string): bool {
return Keybinds.rebind(current, next);
}
function resetBind(current: string): bool { return Keybinds.resetBind(current); }
function resetAll(): void { Keybinds.resetAll(); }
function seedResetCollision(shipped: string, current: string, occupantShipped: string): void {
const overrides = {};
overrides[shipped] = current;
overrides[occupantShipped] = shipped;
DesktopPreferences.set("keybindOverrides", overrides);
}
function chordFor(description: string): string {
const found = Keybinds.binds.find(bind => bind.description === description);
return found ? found.luaChord : "";
}
function overrideState(): string {
return JSON.stringify({
overrides: Keybinds.overrides,
count: Object.keys(Keybinds.overrides).length,
lastError: Keybinds.lastError
});
}
function status(): string {
const grouped = Keybinds.grouped();
let groupedCount = 0;
for (const group of grouped)
groupedCount += group.binds.length;
const superT = Keybinds.binds.find(bind => bind.description === "Terminal");
return JSON.stringify({
loaded: Keybinds.loaded,
count: Keybinds.binds.length,
groupedCount: groupedCount,
groups: grouped.map(group => group.name),
sample: superT ? superT.chord : "",
emptyDescriptions: Keybinds.binds.filter(bind => !bind.description).length,
lastError: Keybinds.lastError
});
}
}
}
@@ -0,0 +1,32 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "lock-screen-test"
function status(): string {
return JSON.stringify({
generated: LockScreen.generated,
path: LockScreen.path,
fallback: LockScreen.fallback,
lastError: LockScreen.lastError,
busy: LockScreen.busy
});
}
function burst(): void {
DesktopPreferences.set("lockBlurLevel", 1);
DesktopPreferences.set("lockBlurLevel", 2);
DesktopPreferences.set("lockBlurLevel", 4);
}
function refresh(): void {
LockScreen.refresh();
}
}
}
@@ -0,0 +1,83 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.settings
ShellRoot {
id: root
readonly property string fixtureWallpaper: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
LockScreenPreview {
id: screenshotPreview
width: 620
backgroundMode: "screenshot"
blurLevel: 4
showClock: true
showDate: true
showUser: true
fadeOnEmpty: false
wallpaperPath: root.fixtureWallpaper
}
LockScreenPreview {
id: wallpaperPreview
width: 620
backgroundMode: "wallpaper"
blurLevel: 2
showClock: false
showDate: true
showUser: false
fadeOnEmpty: true
wallpaperPath: root.fixtureWallpaper
}
LockScreenPreview {
id: solidPreview
width: 620
backgroundMode: "solid"
blurLevel: 0
showClock: true
showDate: false
showUser: true
fadeOnEmpty: false
wallpaperPath: root.fixtureWallpaper
}
IpcHandler {
target: "lock-screen-settings-test"
function status(): string {
return JSON.stringify({
screenshot: {
mode: screenshotPreview.previewMode,
wallpaperVisible: screenshotPreview.wallpaperVisible,
blurStrength: screenshotPreview.blurStrength,
clockVisible: screenshotPreview.clockVisible,
dateVisible: screenshotPreview.dateVisible,
userVisible: screenshotPreview.userVisible,
passwordVisible: screenshotPreview.passwordVisible
},
wallpaper: {
mode: wallpaperPreview.previewMode,
wallpaperVisible: wallpaperPreview.wallpaperVisible,
blurStrength: wallpaperPreview.blurStrength,
clockVisible: wallpaperPreview.clockVisible,
dateVisible: wallpaperPreview.dateVisible,
userVisible: wallpaperPreview.userVisible,
passwordVisible: wallpaperPreview.passwordVisible
},
solid: {
mode: solidPreview.previewMode,
wallpaperVisible: solidPreview.wallpaperVisible,
blurStrength: solidPreview.blurStrength,
clockVisible: solidPreview.clockVisible,
dateVisible: solidPreview.dateVisible,
userVisible: solidPreview.userVisible,
passwordVisible: solidPreview.passwordVisible
}
});
}
}
}
@@ -0,0 +1,56 @@
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
// case. Run by tests/quickshell/migrations-contract.sh.
//
// Fixture steps rather than the real list: the real one is empty until the
// first breaking schema change, and a mechanism that has never been run against
// a failing step is not one to discover the behaviour of during an upgrade.
import Quickshell
import QtQuick
import qs.config
ShellRoot {
Component.onCompleted: {
const steps = [
{ version: 2, describe: "add b", migrate: v => { v.b = (v.a ?? 0) + 1; return v; } },
{ version: 3, describe: "add c", migrate: v => { v.c = "three"; return v; } },
{ version: 4, describe: "explode", migrate: v => { throw new Error("boom"); } }
];
const results = {};
// No version at all: a file written before versioning. Stamped, never
// migrated.
let r = Migrations.applyWith({ a: 1 }, steps, 3, 1);
results.unversioned = { v: r.values.schemaVersion, migrated: r.migrated,
changed: r.changed, untouched: r.values.b === undefined };
// Older file: every step above its version runs, in order.
r = Migrations.applyWith({ a: 1, schemaVersion: 1 }, steps, 3, 1);
results.upgrade = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
count: r.applied.length, migrated: r.migrated };
// Already current: nothing runs.
r = Migrations.applyWith({ schemaVersion: 3, keep: "me" }, steps, 3, 1);
results.current = { v: r.values.schemaVersion, migrated: r.migrated,
kept: r.values.keep === "me", b: r.values.b === undefined };
// From the future: left completely alone, including its unknown keys.
r = Migrations.applyWith({ schemaVersion: 9, futureKey: "x" }, steps, 3, 1);
results.future = { v: r.values.schemaVersion, migrated: r.migrated,
changed: r.changed, kept: r.values.futureKey === "x" };
// A failing step stops at the last good version rather than losing the
// file or skipping past the failure forever.
r = Migrations.applyWith({ schemaVersion: 1, a: 5 }, steps, 4, 1);
results.failure = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
kept: r.values.a === 5, count: r.applied.length };
// Unknown keys survive a migration: rolling back to an older Panama
// must not discard a newer version's settings.
r = Migrations.applyWith({ schemaVersion: 1, unknownFromFuture: true }, steps, 3, 1);
results.preserved = { kept: r.values.unknownFromFuture === true };
console.info("PANAMA-MIGRATIONS " + JSON.stringify(results));
Qt.callLater(() => Qt.quit());
}
}
@@ -13,6 +13,9 @@ Pill {
horizontalPadding: 8
onActivated: ShellState.toggle("activity")
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
onSecondaryActivated: ShellState.openSettings("privacy")
Text {
anchors.verticalCenter: parent.verticalCenter
text: {
@@ -108,6 +108,10 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
}
HealthIndicator {
anchors.verticalCenter: parent.verticalCenter
}
ActivityIndicator {
anchors.verticalCenter: parent.verticalCenter
}
@@ -15,6 +15,9 @@ Pill {
horizontalPadding: 8
onActivated: ShellState.openDateMenu("agenda")
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
onSecondaryActivated: ShellState.openSettings("datetime")
ToolTip.visible: root.hovered && root.visible
ToolTip.delay: 500
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
@@ -26,6 +26,11 @@ Pill {
onActivated: ShellState.toggleDateMenu("agenda")
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
// format toggles are mirrored on Appearance, but someone right-clicking a
// clock is far more often after the time itself than its typography.
onSecondaryActivated: ShellState.openSettings("datetime")
Text {
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, root.format)
@@ -0,0 +1,89 @@
// A deliberately absent-until-needed health affordance. Healthy and merely
// unconfigured systems leave no ornament or layout residue in the bar.
import QtQuick
import QtQuick.Controls
import qs.config
import qs.services
Rectangle {
id: root
signal activated
readonly property int issueCount: Health.summary.warnings + Health.summary.errors
readonly property color tone: Health.status === "error"
? Theme.danger
: (Health.status === "warning" ? Theme.warn : "transparent")
readonly property string statusText: root.issueCount === 1
? "1 system health issue"
: root.issueCount + " system health issues"
readonly property string accessibleLabel: Health.status === "error"
? "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue requires action" : " issues require action")
: "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue needs attention" : " issues need attention")
readonly property string tooltipText: root.accessibleLabel
visible: Health.actionable
implicitWidth: visible ? content.implicitWidth + 16 : 0
implicitHeight: visible ? 24 : 0
width: implicitWidth
height: implicitHeight
radius: 9
color: visible ? Theme.alpha(root.tone, 0.09) : "transparent"
border.width: activeFocus ? 2 : 1
border.color: visible ? Theme.alpha(root.tone, activeFocus ? 0.72 : 0.20) : "transparent"
activeFocusOnTab: visible
Accessible.role: Accessible.Button
Accessible.name: root.accessibleLabel
Accessible.description: "Open System Health"
Accessible.onPressAction: root.activated()
onActivated: {
ShellState.openSettings("services");
Health.refresh();
}
Keys.onReturnPressed: root.activated()
Keys.onEnterPressed: root.activated()
Keys.onSpacePressed: root.activated()
Row {
id: content
anchors.centerIn: parent
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0ECD}" // md-shield-alert-outline
color: root.tone
font.family: Theme.fontMono
font.pixelSize: 14
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: String(root.issueCount)
color: root.tone
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
MouseArea {
id: pointer
anchors.fill: parent
enabled: root.visible
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
ToolTip.visible: pointer.containsMouse && root.visible
ToolTip.delay: 500
ToolTip.text: root.tooltipText
}
@@ -7,6 +7,7 @@
import QtQuick
import Quickshell.Services.Mpris
import qs.config
import qs.services
import qs.widgets
Pill {
@@ -34,6 +35,9 @@ Pill {
onActivated: if (root.player?.canTogglePlaying)
root.player.togglePlaying()
// Right-click opens the settings that govern this widget. Output device and per-application volume.
onSecondaryActivated: ShellState.openSettings("sound")
// Scroll up = previous, down = next — the same direction as the workspace
// switcher, so the whole bar scrolls consistently.
onScrolled: delta => {
@@ -10,6 +10,7 @@ import Quickshell.Networking
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
@@ -23,6 +24,9 @@ Pill {
onActivated: root.requestQuickSettings()
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
onSecondaryActivated: ShellState.openSettings("connectivity")
// ── Audio ───────────────────────────────────────────────────────────────
// Without a tracker, volume and muted silently read as zero/false.
PwObjectTracker {
@@ -106,4 +110,10 @@ Pill {
glyph: "\u{F0176}" // md-coffee
color: Theme.warn
}
StatusGlyph {
visible: KdeConnect.phoneReachable
glyph: "\u{F03F2}" // md-cellphone-link
color: Theme.cyan
}
}
@@ -13,6 +13,10 @@ import qs.widgets
Pill {
id: root
// Right-click opens the settings that govern this widget. Which readouts
// appear in the bar, and how often they update.
onSecondaryActivated: ShellState.openSettings("appearance")
interactive: false
Row {
@@ -10,6 +10,9 @@ import qs.widgets
Pill {
id: root
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
onSecondaryActivated: ShellState.openSettings("home")
interactive: false
visible: Weather.available
+177
View File
@@ -0,0 +1,177 @@
// Compact, click-through system feedback. Repeated hardware-key presses update
// this one surface in place, avoiding a stack of notification cards.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
PanelWindow {
id: root
property var modelData: null
readonly property var presentation: OsdState.state
readonly property bool belongsHere: !OsdState.monitorName || !root.modelData
|| root.modelData.name === OsdState.monitorName
readonly property bool requestedVisible: OsdState.active && root.belongsHere
readonly property int horizontalPadding: 18
readonly property int contentGap: 14
readonly property int progressWidth: 156
readonly property int progressLabelWidth: 48
readonly property int messageWidth: Math.min(300, Math.max(52, Math.ceil(labelMetrics.advanceWidth)))
readonly property int desiredWidth: root.horizontalPadding * 2 + 24 + root.contentGap
+ (root.presentation.progress
? root.progressWidth + root.contentGap + root.progressLabelWidth
: root.messageWidth)
screen: root.modelData
anchors.bottom: true
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
implicitWidth: root.desiredWidth
implicitHeight: 64
color: "transparent"
mask: Region {}
WlrLayershell.namespace: "qs-popover-osd"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
property bool mapped: false
visible: root.mapped
onRequestedVisibleChanged: {
if (root.requestedVisible) {
unmapTimer.stop();
root.mapped = true;
entrance.restart();
} else if (root.mapped) {
unmapTimer.restart();
}
}
Component.onCompleted: {
if (root.requestedVisible)
root.mapped = true;
}
Timer {
id: unmapTimer
interval: Theme.durNormal
onTriggered: root.mapped = false
}
Rectangle {
id: surface
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, 0.9)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
opacity: root.requestedVisible ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
}
transform: Translate {
id: slide
y: root.requestedVisible ? 0 : 8
Behavior on y {
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
}
}
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Row {
anchors.centerIn: parent
spacing: root.contentGap
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 24
icon: root.presentation.icon
iconFallback: "dialog-information-symbolic"
tint: Theme.fg
}
Item {
visible: root.presentation.progress
anchors.verticalCenter: parent.verticalCenter
width: visible ? root.progressWidth : 0
height: 8
Rectangle {
anchors.fill: parent
radius: height / 2
color: Theme.alpha(Theme.fg, 0.14)
}
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * root.presentation.ratio
radius: height / 2
color: Theme.accent
Behavior on width {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
}
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: root.presentation.progress ? root.progressLabelWidth : root.messageWidth
text: root.presentation.label
color: Theme.fg
elide: Text.ElideRight
maximumLineCount: 1
horizontalAlignment: root.presentation.progress ? Text.AlignRight : Text.AlignLeft
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
}
}
TextMetrics {
id: labelMetrics
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
text: root.presentation.label
}
ParallelAnimation {
id: entrance
NumberAnimation {
target: surface
property: "opacity"
from: 0
to: 1
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
NumberAnimation {
target: slide
property: "y"
from: 8
to: 0
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
}
@@ -0,0 +1,87 @@
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function finiteNumber(value, fallback) {
var parsed = Number(value);
return isFinite(parsed) ? parsed : fallback;
}
function iconFor(kind, ratio) {
var name = String(kind || "").toLowerCase();
if (name === "volume-muted")
return "audio-volume-muted-symbolic";
if (name === "volume") {
if (ratio <= 0)
return "audio-volume-muted-symbolic";
if (ratio < 0.34)
return "audio-volume-low-symbolic";
if (ratio < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
if (name === "microphone-muted")
return "microphone-sensitivity-muted-symbolic";
if (name === "microphone")
return "audio-input-microphone-symbolic";
if (name === "brightness")
return "display-brightness-symbolic";
if (name === "media-play" || name === "media-playing")
return "media-playback-start-symbolic";
if (name === "media-pause" || name === "media-paused")
return "media-playback-pause-symbolic";
if (name === "media-next")
return "media-skip-forward-symbolic";
if (name === "media-previous")
return "media-skip-backward-symbolic";
if (name === "media-stop")
return "media-playback-stop-symbolic";
return name || "dialog-information-symbolic";
}
function normalizedDuration(value) {
var parsed = finiteNumber(value, 1400);
return Math.max(0, Math.round(parsed));
}
function progressState(kind, rawValue, rawMaximum, rawLabel, rawDuration) {
var maximum = Math.max(1, finiteNumber(rawMaximum, 100));
var value = clamp(finiteNumber(rawValue, 0), 0, maximum);
var ratio = value / maximum;
var label = String(rawLabel || "");
if (!label)
label = Math.round(ratio * 100) + "%";
return {
kind: String(kind || ""),
value: value,
maximum: maximum,
ratio: ratio,
label: label,
icon: iconFor(kind, ratio),
duration: normalizedDuration(rawDuration),
progress: true
};
}
function messageState(kind, rawLabel, rawDuration) {
return {
kind: String(kind || ""),
value: 0,
maximum: 100,
ratio: 0,
label: String(rawLabel || ""),
icon: iconFor(kind, 0),
duration: normalizedDuration(rawDuration),
progress: false
};
}
if (typeof module !== "undefined") {
module.exports = {
clamp: clamp,
iconFor: iconFor,
progressState: progressState,
messageState: messageState
};
}
+2
View File
@@ -0,0 +1,2 @@
module qs.modules.osd
Osd 1.0 Osd.qml
@@ -6,6 +6,7 @@ import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
import qs.services
Item {
id: root
@@ -16,23 +17,12 @@ Item {
implicitHeight: list.implicitHeight
readonly property var nodes: {
return Pipewire.nodes.values.filter(n => {
if (n.isStream)
return false;
// Sources have to be filtered on the type flags: !isSink also
// matches video nodes (webcams show up here otherwise).
return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource;
});
}
readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs
readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource
readonly property var current: AudioDevices.current(root.output)
function select(node): void {
if (root.output)
Pipewire.preferredDefaultAudioSink = node;
else
Pipewire.preferredDefaultAudioSource = node;
AudioDevices.select(root.output, node)
}
ScrollColumn {
@@ -52,7 +42,7 @@ Item {
implicitHeight: 38
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-symbolic"
label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name
label: AudioDevices.label(nodeRow.modelData)
selected: nodeRow.modelData === root.current
onClicked: root.select(nodeRow.modelData)
}
@@ -1,24 +1,44 @@
// Backlight slider, via brightnessctl.
// Brightness, from whichever source this machine actually has.
//
// This machine drives an external DisplayPort monitor and has no backlight
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
// row removes itself rather than sitting there as a dead control. Probed once
// at startup — backlight devices do not appear and disappear.
// Two exist and they are not interchangeable:
//
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
//
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
// only brightness an external display has, and it is per-monitor.
//
// A machine may have neither, either, or both, so this renders a row per source
// found and removes itself entirely when there are none, rather than sitting
// there as a dead control.
//
// Connector labels appear only when there is more than one row. A single
// slider needs no explanation of which screen it dims.
import QtQuick
import Quickshell
import Quickshell.Io
import qs.widgets
import qs.config
import qs.services
Item {
id: root
property bool available: false
property real value: 0
property bool backlightAvailable: false
property real backlightValue: 0
visible: root.available
implicitHeight: root.available ? 32 : 0
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
readonly property bool labelled: root.rowCount > 1
visible: root.rowCount > 0
implicitHeight: rows.implicitHeight
// Probing I2C takes on the order of a second, so it waits until the panel
// is actually on screen rather than running at shell 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()
// `-m` is the machine-readable form: name,class,current,percent,max
Process {
@@ -34,36 +54,105 @@ Item {
const fields = line.split(",");
if (fields.length < 5 || fields[1] !== "backlight")
continue;
root.available = true;
root.value = parseInt(fields[3]) / 100;
root.backlightAvailable = true;
root.backlightValue = parseInt(fields[3]) / 100;
return;
}
}
function apply(v: real): void {
root.value = v;
function applyBacklight(v: real): void {
root.backlightValue = v;
// Never go fully dark: a 0% backlight looks like a broken shell.
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
}
// ValueSlider draws its own leading icon, but symbolic icons need
// recolouring to be visible — see ThemedIcon.
ThemedIcon {
id: glyph
Column {
id: rows
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
size: 17
icon: "display-brightness-symbolic"
anchors.right: parent.right
spacing: 4
BrightnessRow {
width: rows.width
visible: root.backlightAvailable
label: "Built-in"
value: root.backlightValue
onMoved: v => root.applyBacklight(v)
}
Repeater {
model: Brightness.displays
BrightnessRow {
required property var modelData
width: rows.width
// Hyprland already knows what each output is called, so the
// name comes from there rather than from a second source that
// could disagree with the Displays page. The connector is the
// fallback, so a display is never an unlabelled slider.
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
value: modelData.value / 100
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
}
}
}
ValueSlider {
anchors.left: glyph.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
value: root.value
onMoved: v => root.apply(v)
component BrightnessRow: Item {
id: row
property string label: ""
property real value: 0
signal moved(real value)
implicitHeight: caption.height + control.height
Text {
id: caption
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 6
anchors.rightMargin: 6
anchors.top: parent.top
visible: root.labelled
height: visible ? implicitHeight + 2 : 0
text: row.label
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
// The slider and its glyph share one strip so the two stay aligned
// whether or not a caption sits above them. Anchoring the glyph to
// both a caption and a centre line instead would conflict, and an
// anchor set to undefined is not released.
Item {
id: control
anchors.left: parent.left
anchors.right: parent.right
anchors.top: caption.bottom
height: 32
// ValueSlider draws its own leading icon, but symbolic icons need
// recolouring to be visible — see ThemedIcon.
ThemedIcon {
id: glyph
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
size: 17
icon: "display-brightness-symbolic"
}
ValueSlider {
anchors.left: glyph.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
value: row.value
onMoved: v => row.moved(v)
}
}
}
}
@@ -0,0 +1,50 @@
import QtQuick
import qs.config
Item {
id: root
property string label: ""
property string action: ""
property bool actionEnabled: true
signal actionTriggered
implicitHeight: 24
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.label.toUpperCase()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.9
}
Text {
id: actionLabel
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: root.action !== ""
text: root.action
color: root.actionEnabled
? (actionMouse.containsMouse ? Theme.accentAlt : Theme.accent)
: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
Behavior on color { ColorAnimation { duration: Theme.durFast } }
MouseArea {
id: actionMouse
anchors.fill: parent
anchors.margins: -6
enabled: root.actionEnabled
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.actionTriggered()
}
}
}
@@ -0,0 +1,169 @@
// A light dimmer that previews locally and sends one value when interaction
// ends. Home Assistant never sees the intermediate pointer positions.
import QtQuick
import qs.config
Item {
id: root
property int value: 0
property int previewValue: 0
property bool interactionActive: false
property string accessibleName: "Brightness"
readonly property bool pressed: root.interactionActive
signal previewChanged(int value)
signal committed(int value)
implicitWidth: 160
implicitHeight: 32
activeFocusOnTab: root.enabled
opacity: root.enabled ? 1 : 0.42
Accessible.role: Accessible.Slider
Accessible.name: root.accessibleName
// Qt 6.11's installed Accessible attached type has no structured value or
// range properties, so expose both in the supported live description.
Accessible.description: root.previewValue + " percent, range 0 to 100"
Accessible.focusable: root.enabled
Accessible.focused: root.activeFocus
Accessible.onIncreaseAction: root.commitStep(5)
Accessible.onDecreaseAction: root.commitStep(-5)
onValueChanged: {
if (!root.interactionActive)
root.updatePreview(root.value, false);
}
Component.onCompleted: root.updatePreview(root.value, false)
Keys.onLeftPressed: root.commitStep(-5)
Keys.onDownPressed: root.commitStep(-5)
Keys.onRightPressed: root.commitStep(5)
Keys.onUpPressed: root.commitStep(5)
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 10
radius: 5
color: Theme.alpha(Theme.fg, 0.105)
border.width: root.activeFocus ? 1 : 0
border.color: Theme.alpha(Theme.warn, 0.72)
Rectangle {
id: fill
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: track.width * root.previewValue / 100
radius: track.radius
color: Theme.warn
Behavior on width {
enabled: !root.interactionActive
NumberAnimation {
duration: Theme.durFast
easing.type: Easing.OutQuad
}
}
}
Rectangle {
id: knob
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(track.width - width,
track.width * root.previewValue / 100 - width / 2))
width: 16
height: 16
radius: 8
color: Theme.fg
border.width: 1
border.color: Theme.alpha(Theme.bgDark, 0.38)
}
}
// The 32 px interaction surface is intentionally much taller than the
// 10 px track, while remaining inside this component's layout bounds.
MouseArea {
id: drag
anchors.fill: parent
enabled: root.enabled
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: event => {
root.forceActiveFocus();
root.beginPointerInteraction(event.x);
event.accepted = true;
}
onPositionChanged: event => root.movePointerInteraction(event.x)
onReleased: event => {
root.releasePointerInteraction();
event.accepted = true;
}
onCanceled: root.cancelPointerInteraction()
onWheel: event => {
root.commitWheel(event.angleDelta.y);
event.accepted = true;
}
}
function clamp(candidate: real): int {
return Math.max(0, Math.min(100, Math.round(candidate)));
}
function updatePreview(candidate: real, announce: bool): void {
const nextValue = root.clamp(candidate);
if (root.previewValue === nextValue)
return;
root.previewValue = nextValue;
if (announce)
root.previewChanged(nextValue);
}
function previewAt(pointerX: real): void {
root.updatePreview(pointerX / Math.max(1, drag.width) * 100, true);
}
function beginPointerInteraction(pointerX: real): void {
if (!root.enabled)
return;
root.interactionActive = true;
root.previewAt(pointerX);
}
function movePointerInteraction(pointerX: real): void {
if (root.interactionActive)
root.previewAt(pointerX);
}
function releasePointerInteraction(): void {
if (!root.interactionActive)
return;
root.interactionActive = false;
root.committed(root.previewValue);
}
function cancelPointerInteraction(): void {
root.interactionActive = false;
root.updatePreview(root.value, false);
}
function commitWheel(delta: int): void {
if (delta === 0)
return;
root.commitStep(delta > 0 ? 5 : -5);
}
function commitStep(delta: int): void {
if (!root.enabled)
return;
root.updatePreview(root.previewValue + delta, true);
root.committed(root.previewValue);
}
}
@@ -0,0 +1,285 @@
import QtQuick
import qs.config
import qs.services
Item {
id: root
property bool expanded: false
signal toggleExpanded
readonly property bool hasSelection: HomeAssistant.selectedEntities.length > 0
readonly property bool showSetup: HomeAssistant.phase === "ready" && !root.hasSelection
implicitHeight: content.implicitHeight
Column {
id: content
width: parent.width
spacing: 6
ControlSectionHeader {
width: parent.width
label: "Home"
action: root.headerAction()
actionEnabled: HomeAssistant.phase !== "loading"
onActionTriggered: {
if (root.hasSelection) {
root.toggleExpanded();
} else if (root.showSetup) {
root.openHomeSettings();
} else {
HomeAssistant.refresh();
}
}
}
Rectangle {
id: restingCard
width: parent.width
height: visible ? restingGrid.implicitHeight + 20 : 0
visible: root.hasSelection && !root.expanded
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.warn, 0.028)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.095)
Grid {
id: restingGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 10
columns: 2
spacing: 8
Repeater {
model: HomeAssistant.visibleEntities
HomeTile {
required property var modelData
width: (restingGrid.width - restingGrid.spacing) / 2
entity: modelData
busy: HomeAssistant.isBusy(modelData.id)
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
actionError: HomeAssistant.errorFor(modelData.id)
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
}
}
}
}
Rectangle {
width: parent.width
height: visible ? 72 : 0
visible: !root.hasSelection
radius: Theme.cardRadius
color: root.showSetup
? Theme.alpha(Theme.accent, 0.055)
: Theme.alpha(Theme.fg, 0.045)
border.width: 1
border.color: root.showSetup
? Theme.alpha(Theme.accent, 0.12)
: Theme.alpha(Theme.fg, 0.055)
Text {
id: stateIcon
anchors.left: parent.left
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
text: root.showSetup ? "\u{F0335}" : "\u{F02DC}"
color: root.showSetup ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 17
}
Column {
anchors.left: stateIcon.right
anchors.leftMargin: 11
anchors.right: emptyAction.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.emptyTitle()
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.emptyDetail()
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
id: emptyAction
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: root.showSetup ? 68 : 52
height: 30
radius: 10
visible: HomeAssistant.phase !== "loading"
color: emptyActionMouse.containsMouse
? Theme.alpha(Theme.accent, 0.20)
: Theme.alpha(Theme.accent, 0.105)
Text {
anchors.centerIn: parent
text: root.showSetup ? "Manage" : "Open"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: emptyActionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.showSetup)
root.openHomeSettings();
else
HomeAssistant.open();
}
}
}
}
Rectangle {
width: parent.width
height: visible ? 34 : 0
visible: HomeAssistant.stale
radius: 10
color: Theme.alpha(Theme.warn, 0.075)
Text {
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Last known state · " + root.countSummary()
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: HomeAssistant.refresh()
}
}
}
Section {
width: parent.width
expanded: root.expanded && root.hasSelection
ScrollColumn {
width: parent.width
maxHeight: 324
spacing: 8
Grid {
id: expandedGrid
width: parent.width
columns: 2
spacing: 8
Repeater {
model: HomeAssistant.selectedEntities
HomeTile {
required property var modelData
width: (expandedGrid.width - expandedGrid.spacing) / 2
entity: modelData
busy: HomeAssistant.isBusy(modelData.id)
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
actionError: HomeAssistant.errorFor(modelData.id)
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
}
}
}
RowButton {
width: parent.width
icon: "preferences-system-symbolic"
label: "Manage in Settings"
sublabel: root.countSummary()
onClicked: root.openHomeSettings()
}
}
}
}
function headerAction(): string {
if (root.hasSelection) {
const noun = HomeAssistant.configuredCount === 1 ? "accessory" : "accessories";
return HomeAssistant.configuredCount + " " + noun + (root.expanded ? " ⌃" : " ›");
}
if (HomeAssistant.phase === "loading")
return "Loading";
if (root.showSetup)
return "Manage";
return "Retry";
}
function countSummary(): string {
return HomeAssistant.configuredCount + " selected · "
+ HomeAssistant.discoveredCount + " discovered";
}
function emptyTitle(): string {
if (root.showSetup)
return "Choose your accessories";
if (HomeAssistant.phase === "loading")
return "Loading your home";
if (HomeAssistant.lastError === "authentication-required")
return "Authentication required";
if (HomeAssistant.lastError === "not-configured")
return "Home Assistant is not configured";
return "Home Assistant is unavailable";
}
function emptyDetail(): string {
if (root.showSetup) {
if (HomeAssistant.discoveredCount === 0)
return "No lights discovered yet";
const noun = HomeAssistant.discoveredCount === 1 ? "light" : "lights";
return HomeAssistant.discoveredCount + " " + noun + " ready to add";
}
if (HomeAssistant.phase === "loading")
return "Finding your selected lights";
if (HomeAssistant.lastError === "authentication-required")
return "Update the long-lived access token";
if (HomeAssistant.lastError === "not-configured")
return "Connect Home Assistant in Settings";
return "Check the connection, then retry";
}
function openHomeSettings(): void {
ShellState.close();
ShellState.openSettings("home-phone");
}
}
@@ -0,0 +1,170 @@
import QtQuick
import qs.config
Rectangle {
id: root
required property var entity
property bool busy: false
property int pendingBrightness: -1
property string actionError: ""
signal powerRequested
signal brightnessRequested(int value)
readonly property int confirmedBrightness: root.clamp(root.entity.brightnessPct ?? 0)
readonly property int displayedBrightness: brightnessSlider.previewValue
readonly property bool powerEnabled: root.entity.available && !root.busy
readonly property bool dimmerEnabled: root.entity.available
&& root.entity.dimmable
&& !root.busy
implicitHeight: 124
radius: 14
color: {
if (root.entity.active)
return Theme.alpha(Theme.warn, powerArea.containsMouse ? 0.145 : 0.095);
return Theme.alpha(Theme.fg, powerArea.containsMouse ? 0.078 : 0.045);
}
border.width: 1
border.color: {
if (root.activeFocus)
return Theme.alpha(Theme.accent, 0.72);
if (root.entity.active)
return Theme.alpha(Theme.warn, 0.17);
return Theme.alpha(Theme.fg, 0.065);
}
activeFocusOnTab: root.powerEnabled
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durFast } }
Keys.onSpacePressed: {
if (root.powerEnabled)
root.powerRequested();
}
Keys.onReturnPressed: {
if (root.powerEnabled)
root.powerRequested();
}
Rectangle {
id: bulb
anchors.left: parent.left
anchors.leftMargin: 13
anchors.top: parent.top
anchors.topMargin: 13
width: 30
height: 30
radius: 10
color: root.entity.active
? Theme.alpha(Theme.warn, 0.16)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.centerIn: parent
text: root.busy ? "\u{F0772}" : "\u{F0335}"
color: root.entity.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 13
anchors.verticalCenter: bulb.verticalCenter
text: root.entity.dimmable ? root.displayedBrightness + "%" : "—"
color: root.entity.active || root.pendingBrightness >= 0
? Theme.warn
: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.features: Theme.tabularFigures
}
Text {
id: aliasLabel
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: parent.right
anchors.rightMargin: 13
anchors.top: bulb.bottom
anchors.topMargin: 8
text: root.entity.name
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
anchors.left: aliasLabel.left
anchors.right: aliasLabel.right
anchors.top: aliasLabel.bottom
anchors.topMargin: 2
text: root.secondaryText()
color: root.actionError !== ""
? Theme.warn
: (root.entity.active ? Theme.warn : Theme.fgMuted)
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
MouseArea {
id: powerArea
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: brightnessSlider.top
anchors.bottomMargin: 1
enabled: root.powerEnabled
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: root.forceActiveFocus()
onClicked: root.powerRequested()
}
HomeBrightnessSlider {
id: brightnessSlider
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: parent.right
anchors.rightMargin: 13
anchors.bottom: parent.bottom
anchors.bottomMargin: 5
value: root.pendingBrightness >= 0
? root.pendingBrightness
: root.confirmedBrightness
enabled: root.dimmerEnabled
accessibleName: root.entity.name + " brightness"
onCommitted: value => root.brightnessRequested(value)
}
function clamp(candidate: real): int {
return Math.max(0, Math.min(100, Math.round(candidate)));
}
function secondaryText(): string {
if (root.actionError !== "")
return root.errorText(root.actionError);
if (!root.entity.available)
return "Unavailable";
if (root.busy && root.pendingBrightness >= 0)
return "Setting " + root.pendingBrightness + "%";
if (root.busy)
return "Updating";
return root.entity.active ? "On" : "Off";
}
function errorText(code: string): string {
if (code === "authentication-required")
return "Authentication required";
if (code === "entity-not-discovered")
return "Light is unavailable";
return "Couldn’t update light";
}
}
@@ -0,0 +1,24 @@
import QtQuick
import qs.services
// Nonvisual owner of Phone Controls' stable action model and enablement rules.
// Keeping this logic free of delegates and icons makes it safe to exercise in
// disposable diagnostic engines.
QtObject {
id: root
readonly property var actionModels: [
{ id: "share", glyph: "\u{F0142}", label: "Send file" },
{ id: "clipboard", glyph: "\u{F014C}", label: "Clipboard" },
{ id: "ring", glyph: "\u{F009A}", label: "Ring" },
{ id: "messages", glyph: "\u{F0365}", label: "Messages" }
]
function actionEnabled(action: string): bool {
if (action === "messages")
return SystemSettings.bluebubblesAvailable;
return KdeConnect.phoneReachable
&& !KdeConnect.transferActive
&& KdeConnect.supports(action);
}
}
@@ -0,0 +1,311 @@
import QtQuick
import QtQuick.Dialogs
import qs.config
import qs.services
Item {
id: root
property bool expanded: false
signal toggleExpanded
readonly property var phone: KdeConnect.preferredPhone
readonly property var actionModels: phoneActions.actionModels
PhoneActions {
id: phoneActions
}
implicitHeight: content.implicitHeight
Column {
id: content
width: parent.width
spacing: 6
ControlSectionHeader {
width: parent.width
label: "Phone"
action: root.phone ? (root.expanded ? "Details ⌃" : "Details ›") : "Refresh"
onActionTriggered: {
if (root.phone)
root.toggleExpanded();
else
KdeConnect.refresh();
}
}
Rectangle {
width: parent.width
height: phoneContent.implicitHeight + 22
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.fg, 0.055)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.05)
Column {
id: phoneContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
spacing: 9
Item {
width: parent.width
height: 46
Rectangle {
id: phoneSilhouette
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 29
height: 43
radius: 8
color: Theme.alpha(Theme.accent, 0.07)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.42)
Rectangle {
anchors.top: parent.top
anchors.topMargin: 4
anchors.horizontalCenter: parent.horizontalCenter
width: 10
height: 2
radius: 1
color: Theme.alpha(Theme.fg, 0.35)
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: -3
anchors.top: parent.top
anchors.topMargin: 10
width: 6
height: 6
radius: 3
visible: KdeConnect.phoneReachable
color: Theme.ok
}
}
Column {
anchors.left: phoneSilhouette.right
anchors.leftMargin: 11
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.phone?.name ?? (KdeConnect.available ? "No paired phone" : "KDE Connect unavailable")
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.phone
? (KdeConnect.phoneReachable ? "● Nearby on Wi-Fi" : "Not nearby")
: (KdeConnect.available ? "Pair a device to continue" : "Open settings to repair the service")
color: KdeConnect.phoneReachable ? Theme.ok : Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Grid {
id: actionsGrid
width: parent.width
columns: 4
spacing: 7
Repeater {
model: root.actionModels
Rectangle {
id: actionButton
required property var modelData
width: (actionsGrid.width - actionsGrid.spacing * Math.max(0, root.actionModels.length - 1)) / Math.max(1, root.actionModels.length)
height: 48
radius: 11
color: actionMouse.containsMouse && actionMouse.enabled
? Theme.alpha(Theme.accent, 0.15)
: Theme.alpha(Theme.accent, 0.075)
border.width: actionMouse.activeFocus ? 2 : 0
border.color: Theme.accent
opacity: actionMouse.enabled ? 1 : 0.48
Accessible.role: Accessible.Button
Accessible.name: actionButton.modelData.label
Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)
Accessible.focusable: actionMouse.enabled
Accessible.focused: actionMouse.activeFocus
Accessible.onPressAction: {
if (actionMouse.enabled)
root.invoke(actionButton.modelData.id);
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 7
text: actionButton.modelData.glyph
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 13
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
text: actionButton.modelData.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.Medium
}
MouseArea {
id: actionMouse
anchors.fill: parent
enabled: root.actionEnabled(actionButton.modelData.id)
hoverEnabled: true
activeFocusOnTab: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.invoke(actionButton.modelData.id)
Keys.onReturnPressed: root.invoke(actionButton.modelData.id)
Keys.onSpacePressed: root.invoke(actionButton.modelData.id)
}
}
}
}
Text {
width: parent.width
visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0
text: "KDE Connect actions are unavailable until the iPhone reconnects"
color: Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: !SystemSettings.bluebubblesAvailable
text: "BlueBubbles is not installed"
color: Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: KdeConnect.lastError !== ""
text: root.errorText(KdeConnect.lastError)
color: Theme.warn
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
width: parent.width
height: 1
visible: KdeConnect.recentExchange !== null
color: Theme.alpha(Theme.fg, 0.07)
}
RecentExchange {
width: parent.width
height: visible ? implicitHeight : 0
visible: KdeConnect.recentExchange !== null
exchange: KdeConnect.recentExchange ?? ({})
}
}
}
Section {
width: parent.width
expanded: root.expanded && root.phone !== null
Column {
width: parent.width
spacing: 2
RowButton {
width: parent.width
icon: "network-wireless-symbolic"
label: "Connection"
sublabel: KdeConnect.phoneReachable ? "Nearby on Wi-Fi" : "Waiting for the phone"
selected: KdeConnect.phoneReachable
onClicked: KdeConnect.refresh()
}
RowButton {
width: parent.width
icon: "preferences-system-symbolic"
label: "Device settings"
sublabel: "Pairing, plugins and service health"
onClicked: {
ShellState.close();
ShellState.openSettings("connectivity");
}
}
}
}
}
FileDialog {
id: fileDialog
title: root.phone ? "Send a file to " + root.phone.name : "Send a file"
fileMode: FileDialog.OpenFile
nameFilters: ["All files (*)"]
onAccepted: {
const path = root.localPath(selectedFile);
if (path !== "")
KdeConnect.sendFile(path);
}
}
function invoke(action: string): void {
if (action === "share")
fileDialog.open();
else if (action === "clipboard")
KdeConnect.sendClipboard();
else if (action === "ring")
KdeConnect.ring();
else if (action === "messages") {
if (SystemSettings.bluebubblesAvailable && SystemSettings.openApplication("bluebubbles"))
ShellState.close();
}
}
function actionEnabled(action: string): bool {
return phoneActions.actionEnabled(action);
}
function actionAccessibleDescription(action: string): string {
if (action === "messages")
return SystemSettings.bluebubblesAvailable ? "Opens BlueBubbles" : "BlueBubbles is not installed";
return root.actionEnabled(action) ? "Available through KDE Connect" : "Unavailable through KDE Connect";
}
function localPath(selectedUrl: url): string {
const value = String(selectedUrl);
if (!value.startsWith("file://"))
return "";
return decodeURIComponent(value.slice(7));
}
function errorText(code: string): string {
if (code === "device-offline")
return "The iPhone is not nearby";
if (code === "unsupported-action")
return "That action is not available on this iPhone";
return "The phone action did not finish";
}
}
@@ -18,15 +18,21 @@ PanelWindow {
visible: ShellState.quickSettingsOpen
color: "transparent"
// Hangs off the top-right corner, clear of the floating bar.
// Hangs directly beneath the top-right status cluster. Two logical pixels
// preserve the Prism edge without making the surface feel detached.
anchors.top: true
anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2
margins.top: Theme.barHeight + Theme.controlCenterTopGap
margins.right: Theme.barSideMargin
exclusiveZone: 0
implicitWidth: Theme.popoverWidth
implicitWidth: Theme.controlCenterWidth
implicitHeight: panel.implicitHeight
readonly property string expandedSection: panel.expandedSection
function expand(name: string): void {
panel.expand(name);
}
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
WlrLayershell.namespace: "qs-popover-quicksettings"
@@ -36,10 +42,13 @@ PanelWindow {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
onVisibleChanged: {
if (root.visible)
if (root.visible) {
KdeConnect.refresh();
HomeAssistant.refresh();
openAnim.restart();
else
} else {
panel.reset();
}
}
// Closes the panel when a click lands anywhere else on the desktop.
@@ -18,7 +18,7 @@ import qs.widgets
Item {
id: root
implicitWidth: Theme.popoverWidth
implicitWidth: Theme.controlCenterWidth
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
@@ -233,6 +233,19 @@ Item {
width: content.width
}
// ── Connected life ─────────────────────────────────────────────────
HomeControls {
width: content.width
expanded: root.expandedSection === "home"
onToggleExpanded: root.expand("home")
}
PhoneControls {
width: content.width
expanded: root.expandedSection === "phone"
onToggleExpanded: root.expand("phone")
}
Rectangle {
width: content.width
height: 1
@@ -0,0 +1,64 @@
import QtQuick
import qs.config
Item {
id: root
required property var exchange
implicitHeight: 40
Rectangle {
width: 24
height: 24
radius: 8
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: Theme.alpha(Theme.ok, 0.10)
Text {
anchors.centerIn: parent
text: "\u{F012C}"
color: Theme.ok
font.family: Theme.fontMono
font.pixelSize: 12
}
}
Column {
anchors.left: parent.left
anchors.leftMargin: 34
anchors.right: timeLabel.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.exchange.fileName || "File"
color: Theme.fg
elide: Text.ElideMiddle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Sent to " + (root.exchange.deviceName || "phone")
color: Theme.fgMuted
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
Text {
id: timeLabel
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Now"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
@@ -0,0 +1,19 @@
module qs.modules.quicksettings
AudioDeviceList 1.0 AudioDeviceList.qml
AudioSlider 1.0 AudioSlider.qml
BluetoothList 1.0 BluetoothList.qml
BrightnessControl 1.0 BrightnessControl.qml
ControlSectionHeader 1.0 ControlSectionHeader.qml
HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml
HomeControls 1.0 HomeControls.qml
HomeTile 1.0 HomeTile.qml
IconButton 1.0 IconButton.qml
PhoneActions 1.0 PhoneActions.qml
PhoneControls 1.0 PhoneControls.qml
QuickSettings 1.0 QuickSettings.qml
QuickSettingsPanel 1.0 QuickSettingsPanel.qml
RecentExchange 1.0 RecentExchange.qml
RowButton 1.0 RowButton.qml
ScrollColumn 1.0 ScrollColumn.qml
Section 1.0 Section.qml
WifiList 1.0 WifiList.qml
@@ -3,39 +3,111 @@ import Quickshell
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "A curated Hyprland desktop built around focus, speed, and good taste."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
Component.onCompleted: {
if (!MachineInfo.scanned)
MachineInfo.refresh();
if (GraphicsDevices.devices.length === 0)
GraphicsDevices.refresh();
}
SettingsCard {
title: "Panama Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
SettingsCard {
title: "Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
TextRow {
label: "Hyprland"
value: SystemSettings.hyprlandVersion || "Detecting…"
}
TextRow {
label: "Quickshell"
value: SystemSettings.quickshellVersion
}
TextRow {
label: "Display"
value: SystemSettings.monitorName || "Detecting…"
}
TextRow {
label: "Configuration"
detail: Quickshell.shellDir
value: "Local"
divider: false
}
}
// What GNOME's About panel answers and this page did not: what am I running
// 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) => ({
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 {
title: "This machine"
subtitle: "Hardware and system, as the kernel reports it."
Repeater {
model: root.machineRows
TextRow {
id: machineRow
required property var modelData
required property int index
label: machineRow.modelData.label
value: machineRow.modelData.value
divider: machineRow.index < root.machineRows.length - 1
}
}
SettingsCard {
title: "Design principles"
SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" }
SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" }
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false }
}
TextRow {
visible: MachineInfo.scanned && root.machineRows.length === 0
label: "Hardware"
detail: "The system did not report anything readable"
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
}
}
}
@@ -0,0 +1,94 @@
// Accessibility.
//
// Pointer size and text scale have to agree across three consumers that share
// no configuration system -- the compositor, GTK applications, and the shell.
// Panama's store is the source of truth and services/Accessibility.qml pushes
// the value to the other two.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Accessibility"
lede: "Make the desktop easier to see and easier to hit."
SettingsCard {
title: "Pointer"
subtitle: "Applied to the compositor and to applications at the same time."
SliderRow { setting: "cursorSize" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard {
title: "Text"
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
SliderRow { setting: "textScale"; divider: false }
}
SettingsCard {
title: "Motion"
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
ToggleRow { setting: "animationsEnabled"; divider: false }
}
// 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" }
SliderRow { setting: "dimStrength"; divider: false }
}
// What this session genuinely cannot do, said plainly.
//
// Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is
// an X11 SERVER feature. XKB under Wayland has no accessx option group at
// all, and Hyprland does not implement one. The compositor will accept
// "accessx:enable" as a keyboard option and store it, and nothing will ever
// act on it -- so there is no switch here, and pointing at GNOME's panel
// would be no better, since the daemon that applies those keys is not
// running either.
SettingsCard {
title: "Keyboard accessibility"
subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on."
ActionRow {
label: "Screen reader"
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
action: "Start Orca"
onTriggered: SystemSettings.openApplication("orca")
}
ActionRow {
label: "GNOME accessibility settings"
detail: "For the parts GNOME's own stack still owns"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("universal-access")
}
}
SettingsCard {
visible: Accessibility.lastError !== ""
title: "Could not apply"
subtitle: Accessibility.lastError
}
}
@@ -0,0 +1,34 @@
// A row whose trailing control is a button rather than a setting.
//
// ActionRow {
// label: "Keyboard"
// detail: "Use Fedora's hardware-backed input panels"
// action: "Open keyboard"
// onTriggered: SystemSettings.openGnomePanel("keyboard")
// }
//
// Not schema-bound: these hand off to GNOME, launch an application, or run a
// one-shot like restoring defaults. There is no key to name.
import QtQuick
import qs.config
SettingRow {
id: root
property string action: ""
property bool enabled: true
signal triggered
controlWidth: Math.max(110, button.implicitWidth + 8)
SettingsButton {
id: button
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.action
enabled: root.enabled
onClicked: root.triggered()
}
}
@@ -1,61 +1,351 @@
// Appearance — the page the rest of the settings vocabulary was built for.
//
// Before Stage 3 this page adjusted a clock format and three vitals toggles,
// and its "Theme" card was two rows of text pretending to be controls. It now
// drives the compositor directly: every row here is applied through
// `hyprctl eval` and confirmed by reading the value back before it is stored.
//
// The preview is pinned above the controls rather than sitting inline, because
// the numbers are meaningless on their own -- "outer gaps 24" only means
// something once you have watched the windows move apart by that much, at the
// scale of the display you actually use.
import QtQuick
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
property string expandedPicker: ""
title: "Appearance"
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
header: Component {
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
spacing: 9
Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
DesktopPreview {
width: parent.width
}
SettingsCard {
title: "Clock"
SettingRow {
label: "24-hour time"
detail: "Use 18:30 instead of 6:30 PM"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.use24Hour; onToggled: value => DesktopPreferences.use24Hour = value }
}
SettingRow {
label: "Show seconds"
detail: "Keep a precise clock in the center of the bar"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showSeconds; onToggled: value => DesktopPreferences.showSeconds = value }
}
SettingRow {
label: "Show weekday"
detail: "Include the abbreviated weekday before the date"
divider: false
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showWeekday; onToggled: value => DesktopPreferences.showWeekday = value }
}
}
SettingsCard {
title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator."
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value } }
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showMemory; onToggled: value => DesktopPreferences.showMemory = value } }
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showGpu; onToggled: value => DesktopPreferences.showGpu = value } }
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: SystemSettings.lastError !== ""
? SystemSettings.lastError
: "Live preview — the focused window wears the prism border"
color: SystemSettings.lastError !== "" ? Theme.warn : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
SettingsCard {
title: "Background"
subtitle: Wallpaper.lastError !== ""
? Wallpaper.lastError
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperControls {
id: wallpaperControls
width: parent.width
}
WallpaperPicker {
id: wallpapers
width: parent.width
mode: wallpaperControls.mode
selectedOutput: wallpaperControls.selectedOutput
}
ActionRow {
visible: wallpapers.hidden > 0 || wallpapers.expanded
label: wallpapers.expanded ? "Showing every image" : wallpapers.hidden + " more available"
detail: "The grid is kept short so the rest of this page stays reachable"
action: wallpapers.expanded ? "Show fewer" : "Show all"
onTriggered: wallpapers.expanded = !wallpapers.expanded
}
ActionRow {
label: "Look for new images"
detail: Wallpaper.scanning
? "Scanning…"
: Wallpaper.available.length + " image" + (Wallpaper.available.length === 1 ? "" : "s") + " found"
action: "Rescan"
divider: false
enabled: !Wallpaper.scanning
onTriggered: Wallpaper.rescan()
}
}
SettingsCard {
title: "Lock screen"
subtitle: LockScreen.lastError !== ""
? LockScreen.lastError
: "A representative preview of the screen shown before authentication."
LockScreenPreview {
width: parent.width
}
ChoiceRow { setting: "lockBackgroundMode" }
SliderRow { setting: "lockBlurLevel"; zeroLabel: "Off" }
ToggleRow { setting: "lockShowClock" }
ToggleRow { setting: "lockShowDate" }
ToggleRow { setting: "lockShowUser" }
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
}
SettingsCard {
title: "Colour scheme"
subtitle: ColorScheme.lastError !== ""
? ColorScheme.lastError
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
ChoiceRow { setting: "colorScheme"; divider: false }
}
SettingsCard {
title: "Shell typography"
subtitle: Fonts.lastError !== ""
? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name."
SliderRow { setting: "interfaceFontSize" }
TextRow {
label: "Interface font"
detail: Fonts.interfaceMissing
? "Not installed on this machine — fontconfig is substituting something else"
: "Used for all shell text"
value: Fonts.interfaceFont
}
FontPicker {
width: parent.width
families: Fonts.interfaceFonts
current: Fonts.interfaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No interface fonts found"
onPicked: family => Fonts.setInterface(family)
}
TextRow {
label: "Icon font"
detail: Fonts.iconMissing
? "Not installed — the shell's glyphs will not draw correctly"
: "Draws the shell's glyphs, so only Nerd Fonts are offered"
value: Fonts.iconFont
}
FontPicker {
width: parent.width
families: Fonts.iconFonts
current: Fonts.iconFont
emptyText: "No Nerd Fonts installed"
onPicked: family => Fonts.setIcon(family)
}
}
SettingsCard {
title: "Application typography"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
ActionRow {
label: "Application font"
detail: DesktopStyle.applicationFont
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
}
FontPicker {
visible: root.expandedPicker === "application-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.applicationFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
onPicked: family => {
if (DesktopStyle.setApplicationFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "applicationFontSize" }
ActionRow {
label: "Document font"
detail: DesktopStyle.documentFont
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
}
FontPicker {
visible: root.expandedPicker === "document-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.documentFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
onPicked: family => {
if (DesktopStyle.setDocumentFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "documentFontSize" }
ActionRow {
label: "Monospace font"
detail: DesktopStyle.monospaceFont
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
}
FontPicker {
visible: root.expandedPicker === "monospace-font"
width: parent.width
families: Fonts.monospaceFonts
current: DesktopStyle.monospaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
onPicked: family => {
if (DesktopStyle.setMonospaceFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "monospaceFontSize" }
ChoiceRow { setting: "fontHinting" }
ChoiceRow { setting: "fontAntialiasing"; divider: false }
}
SettingsCard {
title: "Icons & pointer"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Installed themes only. The pointer updates in applications and Hyprland together."
ActionRow {
label: "Application icons"
detail: DesktopStyle.iconTheme
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
enabled: DesktopStyle.catalogLoaded
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
}
SearchPicker {
visible: root.expandedPicker === "icon-theme"
width: parent.width
items: DesktopStyle.iconThemes
current: DesktopStyle.iconTheme
placeholder: "Search icon themes"
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
onPicked: value => {
if (DesktopStyle.setIconTheme(value))
root.expandedPicker = "";
}
}
ActionRow {
label: "Pointer theme"
detail: DesktopStyle.cursorTheme
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
enabled: DesktopStyle.catalogLoaded
divider: false
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
}
SearchPicker {
visible: root.expandedPicker === "cursor-theme"
width: parent.width
items: DesktopStyle.cursorThemes
current: DesktopStyle.cursorTheme
placeholder: "Search pointer themes"
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
onPicked: value => {
if (DesktopStyle.setCursorTheme(value))
root.expandedPicker = "";
}
}
}
SettingsCard {
title: "Titlebars"
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
ChoiceRow { setting: "titlebarButtonSide" }
ToggleRow { setting: "titlebarMaximizeButton" }
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
}
SettingsCard {
title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
SliderRow { setting: "windowRounding" }
SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" }
SliderRow { setting: "roundingPower" }
SliderRow { setting: "inactiveOpacity" }
SliderRow { setting: "activeOpacity" }
SliderRow { setting: "fullscreenOpacity"; divider: false }
}
SettingsCard {
title: "Effects"
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
ToggleRow { setting: "blurEnabled" }
SliderRow { setting: "blurSize" }
SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
SliderRow { setting: "shadowRenderPower" }
ToggleRow { setting: "shadowSharp" }
ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false }
}
SettingsCard {
title: "Clock"
ToggleRow { setting: "use24Hour" }
ToggleRow { setting: "showSeconds" }
ToggleRow { setting: "showWeekday"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator."
ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: true }
// Refresh interval was on the Home page, which split one concept across
// two pages -- what the vitals show here, how often they update there.
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
// Only worth asking when there is a choice to make.
ChoiceGrid {
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
width: parent.width
label: "Graphics device"
detail: GraphicsDevices.selectionMissing
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
: "Which GPU the graphics readout measures."
options: GraphicsDevices.devices.map(device => ({
value: device.path,
label: GraphicsDevices.shortName(device.name)
}))
current: GraphicsDevices.selectedPath
divider: false
onPicked: value => GraphicsDevices.select(value)
}
}
}
@@ -0,0 +1,48 @@
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.services
Column {
id: root
property var applications: AudioDevices.applications
property bool pipewireReady: Pipewire.ready
readonly property int rowCount: applicationRepeater.count
readonly property string statusText: {
if (!root.pipewireReady)
return "PipeWire is unavailable";
if (root.applications.length === 0)
return "Applications playing sound will appear here";
return "";
}
width: parent ? parent.width : 620
spacing: 8
Repeater {
id: applicationRepeater
model: root.applications
ApplicationVolumeRow {
required property var modelData
width: root.width
application: modelData
}
}
Text {
width: parent.width
visible: root.statusText !== ""
text: root.statusText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
topPadding: 18
bottomPadding: 18
}
}
@@ -0,0 +1,106 @@
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.modules.quicksettings
import qs.services
import qs.widgets
Rectangle {
id: root
required property var application
readonly property real volume: AudioDevices.applicationVolume(root.application)
readonly property bool muted: AudioDevices.applicationMuted(root.application)
width: parent ? parent.width : 620
implicitHeight: 78
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.025)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
PwObjectTracker {
objects: root.application?.nodes ?? []
}
ThemedIcon {
id: applicationIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.top: parent.top
anchors.topMargin: 11
size: 20
icon: root.application?.icon ?? "audio-x-generic-symbolic"
iconFallback: "audio-x-generic-symbolic"
tint: Theme.fg
}
Column {
anchors.left: applicationIcon.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: applicationIcon.verticalCenter
spacing: 1
Text {
width: parent.width
text: root.application?.label ?? "Unknown application"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: (root.application?.nodes?.length ?? 0) > 1
text: `${root.application?.nodes?.length ?? 0} audio streams`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
IconButton {
id: muteButton
anchors.left: parent.left
anchors.leftMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
size: 30
iconSize: 17
icon: root.muted || root.volume <= 0.001
? "audio-volume-muted-symbolic"
: "audio-volume-high-symbolic"
iconFallback: "audio-volume-high-symbolic"
onClicked: AudioDevices.setApplicationMuted(root.application, !root.muted)
}
ValueSlider {
anchors.left: muteButton.right
anchors.leftMargin: 7
anchors.right: volumeText.left
anchors.rightMargin: 10
anchors.verticalCenter: muteButton.verticalCenter
value: root.muted ? 0 : root.volume
onMoved: value => AudioDevices.setApplicationVolume(root.application, value)
}
Text {
id: volumeText
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: muteButton.verticalCenter
width: 38
text: Math.round(root.volume * 100) + "%"
color: Theme.fgDim
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
}
}
@@ -0,0 +1,273 @@
// Applications and session startup.
import Quickshell
import QtQuick
import qs.services
SettingsPage {
id: root
objectName: "applications"
title: "Applications"
lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: ""
property bool addingAutostart: false
readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] }
]
function desktopId(entry: var): string {
const entryId = String(entry?.id ?? "");
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
}
function displayName(entry: var): string {
return String(entry?.name || entry?.genericName || root.desktopId(entry));
}
function currentHandler(role: string): string {
return String(DefaultApps.handlers[role] ?? "");
}
function currentEntry(role: string): var {
const handler = root.currentHandler(role);
return root.applications.find(entry => root.desktopId(entry) === handler) ?? null;
}
function matchesRole(entry: var, role: var): bool {
const rawCategories = Array.isArray(entry.categories)
? entry.categories
: [String(entry.categories ?? "")];
const categories = [];
for (const rawCategory of rawCategories) {
for (const value of String(rawCategory).split(";")) {
const category = value.trim().toLowerCase();
if (category !== "")
categories.push(category);
}
}
const metadata = [entry.name, entry.genericName]
.map(value => String(value ?? "").toLowerCase())
.join(" ");
return role.categorySets.some(set => set.every(category => categories.includes(category)))
|| role.terms.some(term => metadata.includes(term));
}
function choicesForRole(role: var): var {
const choices = root.applications.filter(entry => root.matchesRole(entry, role));
const currentEntry = root.currentEntry(role.key);
if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry)))
choices.push(currentEntry);
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
}
TextRow {
visible: DefaultApps.lastError !== ""
label: "Application settings need attention"
detail: DefaultApps.lastError
value: ""
divider: false
}
SettingsCard {
title: "Default applications"
subtitle: "Open a row to choose from applications that advertise the matching role."
Repeater {
model: root.roles
delegate: Column {
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 {
label: roleBlock.modelData.label
detail: roleBlock.modelData.detail
value: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
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 int index
readonly property string candidateId: root.desktopId(candidateRow.modelData)
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
label: root.displayName(candidateRow.modelData)
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
value: candidateRow.selected ? "Current" : ""
activatable: !candidateRow.selected && !DefaultApps.busy
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
onActivated: {
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
root.expandedRole = "";
}
}
}
}
}
}
}
// 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."
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 {
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 {
title: "User autostart"
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
ActionRow {
label: "Add an application"
detail: root.addingAutostart
? "Search the applications installed on this machine"
: "Start another installed application when you sign in"
action: root.addingAutostart ? "Close" : "Choose"
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
enabled: !DefaultApps.busy
onTriggered: root.addingAutostart = !root.addingAutostart
}
AutostartAppPicker {
visible: root.addingAutostart
width: parent.width
existing: DefaultApps.autostartEntries.map(entry => entry.id)
onPicked: id => {
DefaultApps.addAutostart(id);
root.addingAutostart = false;
}
}
TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
label: "No user autostart entries"
detail: "Applications can add entries to ~/.config/autostart."
value: ""
divider: false
}
Repeater {
model: DefaultApps.autostartEntries
delegate: SettingRow {
id: autostartRow
required property var modelData
required property int index
label: autostartRow.modelData.name
detail: autostartRow.modelData.id
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
activatable: !DefaultApps.busy
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
}
}
}
SettingsCard {
title: "Compositor autostart"
subtitle: "These are started from the Hyprland configuration. They are read-only here."
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
label: "No compositor entries found"
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua."
value: ""
divider: false
}
Repeater {
model: DefaultApps.luaAutostartEntries
delegate: TextRow {
id: luaRow
required property var modelData
required property int index
label: luaRow.modelData.name
detail: luaRow.modelData.command
value: "Hyprland"
divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1
}
}
}
SettingsCard {
title: "Refresh"
ActionRow {
label: "Reload application settings"
detail: "Re-read desktop entries, defaults, and user autostart files"
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy
divider: false
onTriggered: DefaultApps.refresh()
}
}
}
@@ -0,0 +1,72 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
import qs.widgets
SettingRow {
id: root
property var node: null
label: "Balance"
detail: "Adjust the left and right channels"
controlWidth: 270
visible: root.available
divider: false
PwObjectTracker {
objects: root.node ? [root.node] : []
}
function channelIndex(channel): int {
if (!root.node?.audio)
return -1;
const channels = root.node.audio.channels;
for (let index = 0; index < channels.length; index++) {
if (channels[index] === channel)
return index;
}
return -1;
}
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
readonly property bool available: !!root.node?.audio
&& root.leftIndex >= 0 && root.rightIndex >= 0
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
readonly property real position: {
if (!root.available)
return 0.5;
const left = root.node.audio.volumes[root.leftIndex];
const right = root.node.audio.volumes[root.rightIndex];
const level = Math.max(left, right);
if (level <= 0.001)
return 0.5;
return right >= left ? 0.5 + (1 - left / level) * 0.5
: 0.5 - (1 - right / level) * 0.5;
}
function setBalance(value: real): void {
if (!root.available)
return;
const next = Array.from(root.node.audio.volumes);
const level = Math.max(next[root.leftIndex], next[root.rightIndex], 0.001);
if (value < 0.5) {
next[root.leftIndex] = level;
next[root.rightIndex] = level * value * 2;
} else {
next[root.leftIndex] = level * (1 - value) * 2;
next[root.rightIndex] = level;
}
root.node.audio.volumes = next;
}
ValueSlider {
anchors.fill: parent
value: root.position
icon: "audio-speakers-symbolic"
onMoved: value => root.setBalance(value)
}
}
@@ -0,0 +1,77 @@
// Adds an installed application to the user's freedesktop autostart directory.
import QtQuick
import Quickshell
import qs.config
import qs.modules.clipboard
Column {
id: root
required property var existing
signal picked(string id)
spacing: 0
function desktopId(entry: var): string {
const id = String(entry?.id ?? "");
return id.endsWith(".desktop") ? id : id + ".desktop";
}
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
const desktopId = root.desktopId(entry);
if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0)
continue;
const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase();
if (haystack.indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
SearchField {
id: search
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
label: String(candidate.modelData.name || root.desktopId(candidate.modelData))
detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData))
divider: candidate.index < root.matches.length - 1
controlWidth: 86
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Add"
onClicked: {
root.picked(root.desktopId(candidate.modelData));
search.text = "";
}
}
}
}
SettingRow {
visible: search.text.trim() !== "" && root.matches.length === 0
label: "No matching applications"
detail: "Only installed desktop applications can start with the session"
divider: false
}
}
@@ -0,0 +1,75 @@
import QtQuick
import qs.config
Item {
id: root
required property var entity
signal addRequested(string id)
width: parent ? parent.width : 620
implicitHeight: 62
Column {
anchors.left: parent.left
anchors.right: stateCopy.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.entity.sourceName
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.entity.id
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideMiddle
}
}
Text {
id: stateCopy
anchors.right: addButton.left
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
width: 92
text: root.entity.available === false ? "Unavailable" : String(root.entity.state || "Unknown")
color: root.entity.available === false ? Theme.fgMuted : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
SettingsButton {
id: addButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Add"
tone: "accent"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : Theme.alpha(Theme.fg, 0)
onClicked: root.addRequested(root.entity.id)
Keys.onReturnPressed: root.addRequested(root.entity.id)
Keys.onSpacePressed: root.addRequested(root.entity.id)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
}
@@ -0,0 +1,95 @@
// Bluetooth, at page size.
//
// Paired devices first, because reconnecting to something you already own is
// what you are here for nine times out of ten; discovered devices follow.
// Battery is shown where BlueZ reports it, which is the one thing people
// routinely open a terminal for.
import QtQuick
import Quickshell
import Quickshell.Bluetooth
import qs.config
import qs.services
Column {
id: root
spacing: 0
function primaryAction(device: var): void {
if (device.connected) {
device.disconnect();
return;
}
if (device.paired) {
device.connect();
return;
}
device.pair();
}
function stateLabel(device: var): string {
if (device.pairing)
return "Pairing…";
if (device.connected)
return device.batteryAvailable
? `Connected · ${Math.round(device.battery * 100)}% battery`
: "Connected";
if (device.paired)
return "Paired";
return device.address || "Not paired";
}
Repeater {
model: Connectivity.bluetoothDevices
SettingRow {
id: entry
required property var modelData
required property int index
width: parent.width
label: entry.modelData.name || entry.modelData.address || "Unknown device"
detail: root.stateLabel(entry.modelData)
divider: entry.index < Connectivity.bluetoothDevices.length - 1
controlWidth: 200
activatable: !entry.modelData.pairing
onActivated: root.primaryAction(entry.modelData)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
enabled: !entry.modelData.pairing
text: entry.modelData.connected
? "Disconnect"
: (entry.modelData.paired ? "Connect" : "Pair")
onClicked: root.primaryAction(entry.modelData)
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.paired
text: "Forget"
onClicked: entry.modelData.forget()
}
}
}
}
SettingRow {
width: parent.width
visible: Connectivity.bluetoothDevices.length === 0
label: !Connectivity.adapter
? "No Bluetooth adapter"
: (Connectivity.adapter.enabled ? "Looking for devices…" : "Bluetooth is off")
detail: Connectivity.adapter && !Connectivity.adapter.enabled
? "Turn it on above to discover devices"
: "Put the device into pairing mode to make it appear"
divider: false
}
}
@@ -0,0 +1,118 @@
// A row of choices that wraps, for options that do not fit a segmented control.
//
// ChoiceRow puts two or three options on one line. Scales and rotations are
// more numerous and their labels are wider, so they wrap into a grid rather
// than shrinking to illegibility on a narrow, tiled window.
//
// Unlike ChoiceRow this is not schema-bound: it reports a value and lets the
// caller decide what to do with it, because a display change has to go through
// an apply-then-confirm cycle rather than straight into the store.
import QtQuick
import qs.config
Column {
id: root
property string label: ""
property string detail: ""
property var options: []
property var current: null
property bool enabled: true
property bool divider: true
signal picked(var value)
spacing: 9
bottomPadding: 12
Column {
width: parent.width
spacing: 3
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
visible: root.detail !== ""
text: root.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Flow {
width: parent.width
spacing: 7
Repeater {
model: root.options
Rectangle {
id: option
required property var modelData
readonly property bool selected: root.current === option.modelData.value
implicitWidth: Math.max(78, caption.implicitWidth + 26)
implicitHeight: 32
radius: 9
opacity: root.enabled ? 1 : 0.45
color: option.selected ? "transparent" : Theme.alpha(Theme.fg, hover.hovered && root.enabled ? 0.11 : 0.06)
border.width: option.selected ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
// The prism marks the selection here as everywhere else.
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: option.selected
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
}
}
Text {
id: caption
anchors.centerIn: parent
text: option.modelData.label
color: option.selected ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
font.weight: option.selected ? Font.DemiBold : Font.Normal
}
HoverHandler {
id: hover
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: root.enabled && !option.selected
onTapped: root.picked(option.modelData.value)
}
}
}
}
Rectangle {
width: parent.width
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
}
@@ -0,0 +1,98 @@
// An enum setting, bound to a schema key by name.
//
// ChoiceRow { setting: "vrrPolicy" }
//
// The options are the schema's, so a row can never offer a value the store
// would reject. Rendered as a segmented control rather than a dropdown: every
// choice here has two or three options, and showing them all is both faster to
// use and self-documenting.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
readonly property var current: DesktopPreferences.get(root.setting)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: Math.max(120, root.options.length * 92)
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
implicitWidth: segments.implicitWidth + 4
implicitHeight: 30
radius: 9
color: Theme.alpha(Theme.fg, 0.07)
border.width: 0
Row {
id: segments
anchors.centerIn: parent
spacing: 0
Repeater {
model: root.options
Rectangle {
id: segment
required property var modelData
readonly property bool selected: root.current === segment.modelData.value
implicitWidth: Math.max(70, caption.implicitWidth + 24)
implicitHeight: 26
radius: 7
border.width: 0
color: "transparent"
// The selected segment is the only place the prism appears
// in a row: blue leads into orchid, never orchid alone.
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: segment.selected
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.30) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.30) }
}
}
Rectangle {
anchors.fill: parent
radius: parent.radius
border.width: 0
visible: !segment.selected && hover.hovered
color: Theme.alpha(Theme.fg, 0.06)
}
Text {
id: caption
anchors.centerIn: parent
text: segment.modelData.label
color: segment.selected ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: segment.selected ? Font.DemiBold : Font.Normal
}
HoverHandler { id: hover }
TapHandler {
onTapped: SystemSettings.commitPreference(root.setting, segment.modelData.value)
}
}
}
}
}
}
@@ -1,88 +1,192 @@
// Network & Devices.
//
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
// the founding requirement for this desktop: never having to drop to a terminal
// to join a network.
//
// 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
// looking at is battery and airtime spent on nothing.
import QtQuick
import Quickshell
import Quickshell.Networking
import Quickshell.Bluetooth
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
SettingsPage {
id: root
readonly property var wifiDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi)
return device;
}
return null;
title: "Network & Devices"
lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
// Drive the scanners only while this page is the one being shown.
Component.onCompleted: {
Connectivity.active = true;
if (!WifiShare.scanned)
WifiShare.refresh();
}
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
Component.onDestruction: Connectivity.active = false
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsCard {
title: "Wired"
visible: Connectivity.wiredDevice !== null
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
TextRow {
label: "Ethernet"
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
divider: false
}
}
Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsCard {
title: "Wi-Fi"
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
// contradiction; with no radio the card simply does not belong.
visible: Connectivity.wifiDevice !== null
subtitle: "Networks are re-scanned while this page is open."
SettingsCard {
title: "Wi‑Fi"
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
SettingRow {
label: "Wi‑Fi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
onToggled: value => Networking.wifiEnabled = value
}
}
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
SettingRow {
label: "Advanced network settings"
detail: "VPN, wired profiles, DNS, and connection details"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
SettingRow {
label: "Wi-Fi"
detail: Connectivity.wifiEnabled ? "On" : "Off"
controlWidth: 48
divider: Connectivity.wifiEnabled
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wifiEnabled
enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value
}
}
WifiPanel {
width: parent.width
visible: Connectivity.wifiEnabled
}
}
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
// alternative is reading a passphrase out loud.
//
// The image holds the password in machine-readable form, so it is generated
// on demand rather than up front, and the helper writes it to tmpfs under
// XDG_RUNTIME_DIR instead of anywhere persistent.
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 {
id: shareRow
required property var modelData
required property int index
label: shareRow.modelData.ssid
detail: WifiShare.sharing === shareRow.modelData.name
? "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
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
implicitHeight: visible ? plate.height + 20 : 0
Rectangle {
id: plate
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
Image {
anchors.centerIn: parent
width: 184
height: 184
smooth: false
fillMode: Image.PreserveAspectFit
cache: false
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
}
}
}
}
SettingsCard {
title: "Bluetooth"
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
SettingRow {
label: "Bluetooth"
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.bluetoothAdapter?.enabled ?? false
enabled: root.bluetoothAdapter !== null
onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; }
}
}
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
SettingRow {
label: "Advanced Bluetooth settings"
detail: "Device details and system-level options"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") }
SettingsCard {
title: "Bluetooth"
visible: Connectivity.adapter !== null
subtitle: "Discovery runs while this page is open."
SettingRow {
label: "Bluetooth"
detail: Connectivity.adapter
? (Connectivity.adapter.enabled ? "On" : "Off")
: "Unavailable"
controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
enabled: Connectivity.adapter !== null
onToggled: value => {
if (Connectivity.adapter)
Connectivity.adapter.enabled = value;
}
}
}
BluetoothPanel {
width: parent.width
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
}
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
ActionRow {
label: "Network connections"
detail: "VPN, proxies, and per-connection settings"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("network")
}
ActionRow {
label: "Printers"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("printers")
}
ActionRow {
label: "Online accounts"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
}
@@ -0,0 +1,116 @@
// Date & Time.
//
// These belong to the machine rather than to Panama, so nothing here is stored
// in Panama's settings file -- it would be a second answer to a question the
// system already answers. Timezone and network time are read from and written
// to timedatectl directly; the clock's presentation lives on Appearance,
// because that genuinely is a Panama preference.
//
// Changing the timezone or network time needs privilege. timedatectl asks
// polkit, and a cancelled dialog surfaces as an error rather than as a value
// that appears to have been accepted.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.modules.clipboard
SettingsPage {
id: root
title: "Date & Time"
lede: "Timezone and network time, shared with the whole machine."
SettingsCard {
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")
}
SettingRow {
label: "Set automatically"
detail: DateTime.ntpEnabled
? (DateTime.ntpSynchronised ? "Synchronised with a time server" : "Waiting to synchronise")
: "The clock is set by hand"
controlWidth: 48
divider: false
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: DateTime.ntpEnabled
enabled: !DateTime.busy
onToggled: value => DateTime.setNtp(value)
}
}
}
SettingsCard {
title: "Timezone"
subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone)
+ ". Type to narrow the list."
SearchField {
id: zoneSearch
width: parent.width
placeholder: "Search timezones"
}
Repeater {
model: root.matchingZones
TextRow {
required property var modelData
required property int index
label: DateTime.cityOf(modelData)
detail: DateTime.regionOf(modelData)
value: modelData === DateTime.timezone ? "Current" : ""
controlWidth: 90
divider: index < root.matchingZones.length - 1
activatable: true
onActivated: DateTime.setTimezone(modelData)
}
}
TextRow {
visible: root.matchingZones.length === 0
label: "No timezone matches that"
detail: "Try a city or a region, such as \"Denver\" or \"Europe\""
divider: false
}
}
SettingsCard {
visible: DateTime.lastError !== ""
title: "The system did not accept that"
subtitle: DateTime.lastError
}
// 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
// field is untouched.
readonly property var matchingZones: {
const needle = zoneSearch.text.trim().toLowerCase();
const all = DateTime.zones;
if (needle === "") {
// The zone in effect goes first. Listing a region alphabetically
// means the current setting is usually off the bottom of the card,
// which makes the list look like it does not know what is set.
const current = DateTime.timezone;
const nearby = all
.filter(zone => zone !== current && DateTime.regionOf(zone) === DateTime.regionOf(current))
.slice(0, 11);
return current === "" ? nearby : [current].concat(nearby);
}
return all.filter(zone => zone.toLowerCase().replace(/_/g, " ").indexOf(needle) >= 0).slice(0, 40);
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
}
@@ -1,55 +1,172 @@
// Desktop & Dock.
//
// The dock timings used to be shown here as text -- "Instant", "250 ms" -- even
// though they were already stored, mutable integers. They are controls now.
// The window-layout card keeps text rows because those really are facts about
// how Panama tiles rather than settings: the adjustable parts of window
// appearance live on the Appearance page, next to the preview that explains
// them.
import QtQuick
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
title: "Desktop & Dock"
lede: "Keep the shell instant, spatial, and out of your way."
Text { text: "Desktop & Dock"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Keep the shell instant, spatial, and out of your way."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsCard {
title: "Dock"
SettingsCard {
title: "Dock"
SettingRow {
label: "Automatically hide the Dock"
detail: "Reveal it at the bottom edge when a workspace is occupied"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.dockAutohide; onToggled: value => DesktopPreferences.dockAutohide = value }
}
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.dockRevealDelayMs === 0 ? "Instant" : `${DesktopPreferences.dockRevealDelayMs} ms` }
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.dockHideDelayMs} ms`; divider: false }
}
ToggleRow { setting: "dockAutohide" }
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
}
SettingsCard {
title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" }
SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" }
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
}
SettingsCard {
title: "Pinned applications"
subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
SettingsCard {
title: "Reset"
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults."
SettingRow {
label: "Desktop preferences"
detail: "Your pinned applications and files are not changed"
divider: false
controlWidth: 122
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
}
DockPinsEditor {
id: pins
width: parent.width
}
}
SettingsCard {
title: "Pin another application"
DockAppPicker {
width: parent.width
pinned: pins.pinned
onPicked: id => pins.add(id)
}
}
SettingsCard {
title: "Window layout"
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
// These were two read-only rows reporting "Tiling" and "Dynamic", which
// described settings rather than facts -- both are ordinary Hyprland
// options that simply had no controls. TextRow's own documentation says
// a setting the user could reasonably change does not belong in it.
ChoiceRow { setting: "windowLayout" }
ToggleRow { setting: "preserveSplit" }
ChoiceRow { setting: "forceSplit" }
ToggleRow { setting: "windowSnapping" }
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance"
divider: false
onTriggered: ShellState.openSettings("appearance")
}
}
// GNOME's Multitasking panel, in Hyprland's terms.
// Only meaningful when the layout above is Master and stack. Hidden
// otherwise, because a card of settings that do nothing under the layout
// you are actually running is worse than not offering the layout at all.
SettingsCard {
visible: DesktopPreferences.get("windowLayout") === "master"
title: "Master and stack"
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
SliderRow { setting: "masterFactor" }
ChoiceRow { setting: "masterOrientation" }
ChoiceRow { setting: "masterNewStatus" }
ToggleRow { setting: "masterNewOnTop"; divider: false }
}
SettingsCard {
title: "Window edges"
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
ToggleRow { setting: "resizeOnBorder" }
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
ToggleRow { setting: "hoverIconOnBorder" }
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
ToggleRow { setting: "snapRespectGaps"; divider: false }
}
// Hyprland's own interruptions. Panama turns all four off, which is a
// defensible default and was not previously a decision anyone could
// reverse without editing looks.lua.
SettingsCard {
title: "Hyprland notices"
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
ToggleRow { setting: "hyprlandLogo" }
ToggleRow { setting: "hyprlandSplash" }
ToggleRow { setting: "hyprlandUpdateNews" }
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
}
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
}
SettingsCard {
title: "Focus"
SliderRow { setting: "focusDurationMinutes"; divider: false }
}
SettingsCard {
title: "Snapshots"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable."
ActionRow {
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No snapshots yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first"
action: "Back up now"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
onTriggered: SettingsBackup.save()
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
ActionRow {
required property var modelData
required property int index
label: modelData.when
detail: modelData.keys + " settings"
action: "Restore"
enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1
onTriggered: SettingsBackup.restore(modelData.name)
}
}
}
SettingsCard {
title: "Reset"
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow {
label: "Restore defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
onTriggered: SystemSettings.restoreDefaults()
}
}
}
@@ -0,0 +1,220 @@
// A miniature of the real desktop that redraws as you change Appearance.
//
// The point is that "outer gaps 24" and "radius 9" mean nothing as numbers. This
// shows two tiled windows at the settings currently in effect: the focused one
// wearing the prism border and its glow, the unfocused one carrying the
// inactive-opacity setting, both inside real gaps at a real corner radius.
//
// Geometry is scaled by the ratio between this preview's width and the actual
// monitor's, so proportions are honest rather than decorative -- a 10px gap on
// a 4500px display genuinely is almost invisible, and the preview says so.
//
// Everything here is driven by bindings on DesktopPreferences, so the preview
// shows what is actually stored and applied. It has no state of its own and
// nothing animates while idle.
import QtQuick
import QtQuick.Effects
import qs.config
import qs.services
Rectangle {
id: root
implicitHeight: 232
radius: Theme.cardRadius
clip: true
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
// Falls back to a sane width if the monitor has not been read yet, so the
// preview is never wrong by a factor of ten on first paint.
readonly property real monitorWidth: SystemSettings.monitorWidth > 0 ? SystemSettings.monitorWidth : 3000
readonly property real scale: width > 0 ? width / root.monitorWidth : 0.25
readonly property int gapsIn: DesktopPreferences.get("gapsIn")
readonly property int gapsOut: DesktopPreferences.get("gapsOut")
readonly property int borderSize: DesktopPreferences.get("borderSize")
readonly property int rounding: DesktopPreferences.get("windowRounding")
readonly property real inactiveOpacity: DesktopPreferences.get("inactiveOpacity")
readonly property bool shadowOn: DesktopPreferences.get("shadowEnabled")
readonly property int shadowRange: DesktopPreferences.get("shadowRange")
readonly property bool glowOn: DesktopPreferences.get("glowEnabled")
readonly property int glowRange: DesktopPreferences.get("glowRange")
// Scaled geometry, floored so a small-but-nonzero setting stays visible
// rather than rounding away to nothing.
function px(value: real): real {
return value <= 0 ? 0 : Math.max(1, value * root.scale);
}
// Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page.
// Follows the colour scheme. A preview that stays dark while the shell
// around it is light does not read as "your desktop" -- it reads as a
// screenshot of someone else's.
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
}
// The bar, so the preview reads as this desktop and not a generic one.
Rectangle {
id: miniBar
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 20
color: Theme.alpha(Theme.bgDark, 0.4)
border.width: 0
Row {
anchors.left: parent.left
anchors.leftMargin: 9
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Repeater {
model: 3
Rectangle {
required property int index
width: index === 0 ? 12 : 5
height: 5
radius: 2.5
border.width: 0
color: index === 0 ? Theme.accent : Theme.alpha(Theme.fg, 0.3)
anchors.verticalCenter: parent.verticalCenter
}
}
}
// The real bar carries a clock here, not a name. Standing in with the
// actual time keeps the preview a picture of this desktop rather than
// a picture of a product.
Text {
anchors.centerIn: parent
text: Qt.formatTime(new Date(), "h:mm")
color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
Row {
id: tiles
anchors.top: miniBar.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: root.px(root.gapsOut)
spacing: root.px(root.gapsIn) * 2
MiniWindow {
width: (tiles.width - tiles.spacing) / 2
height: tiles.height
focused: true
}
MiniWindow {
width: (tiles.width - tiles.spacing) / 2
height: tiles.height
focused: false
}
}
component MiniWindow: Item {
id: win
required property bool focused
// The gradient border is drawn as a filled rounded rect with the window
// body inset on top of it: QML's Rectangle border takes a colour, not a
// gradient, and the prism border is the whole signature here.
Rectangle {
id: frame
anchors.fill: parent
radius: root.px(root.rounding)
border.width: 0
visible: win.focused && root.borderSize > 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.accent }
GradientStop { position: 1.0; color: Theme.accentSecondary }
}
}
Rectangle {
id: body
anchors.fill: parent
anchors.margins: win.focused ? root.px(root.borderSize) : 0
radius: Math.max(0, root.px(root.rounding) - (win.focused ? root.px(root.borderSize) : 0))
color: Theme.alpha(Theme.bgDark, 0.92)
opacity: win.focused ? 1.0 : root.inactiveOpacity
border.width: win.focused ? 0 : 1
border.color: Theme.alpha(Theme.gutter, 0.6)
clip: true
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 13
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 22
anchors.margins: 10
spacing: 5
Repeater {
model: [1.0, 0.74, 0.52]
Rectangle {
required property real modelData
width: parent.width * modelData
height: 4
radius: 2
border.width: 0
color: Theme.alpha(Theme.fg, win.focused ? 0.2 : 0.13)
}
}
}
Text {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: 8
text: win.focused ? "focused" : "unfocused"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
// Shadow and glow both come from MultiEffect. Only the focused window
// gets the glow, matching looks.lua where glow.color_inactive is fully
// transparent.
MultiEffect {
anchors.fill: body
source: body
visible: root.shadowOn || (win.focused && root.glowOn)
z: -1
shadowEnabled: true
shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha(Theme.dark ? "#15161e" : "#6172b0", Theme.dark ? 0.85 : 0.45)
shadowBlur: win.focused && root.glowOn
? Math.min(1.0, root.px(root.glowRange) / 12)
: Math.min(1.0, root.px(root.shadowRange) / 24)
shadowVerticalOffset: win.focused && root.glowOn ? 0 : root.px(4)
shadowHorizontalOffset: 0
}
}
}
@@ -0,0 +1,318 @@
import QtQuick
import qs.config
import qs.widgets
import "../../services/DisplayLayout.js" as DisplayLayout
Item {
id: root
required property var displayService
property string selectedOutput: ""
property var draftLayout: []
property bool interactionEnabled: true
signal selectionRequested(string output)
implicitHeight: content.implicitHeight
readonly property var canvasData: DisplayLayout.canvasRects(
root.draftLayout, canvas.width, canvas.height, 18)
function copied(layout): var {
return (layout || []).map(record => Object.assign({}, record));
}
function resetDraft(): void {
root.draftLayout = root.copied(root.displayService.currentLayout());
}
function setDraftPosition(output: string, x: real, y: real, snapToEdges: bool): bool {
const next = root.copied(root.draftLayout);
const record = next.find(candidate => candidate.name === output);
if (!record)
return false;
record.x = Math.round(x);
record.y = Math.round(y);
root.draftLayout = snapToEdges ? DisplayLayout.snap(next, output, 16) : next;
return true;
}
function nudge(output: string, dx: int, dy: int): bool {
const record = root.draftLayout.find(candidate => candidate.name === output);
return !!record && root.setDraftPosition(output, record.x + dx, record.y + dy, false);
}
function applyDraft(): bool {
return root.displayService.applyLayout(root.copied(root.draftLayout));
}
function makePrimary(output: string): bool {
const next = root.copied(root.draftLayout);
if (!next.some(record => record.name === output))
return false;
for (const record of next)
record.primary = record.name === output;
root.draftLayout = DisplayLayout.normalize(next);
return root.applyDraft();
}
function canvasSnapshot(): var {
return {
bounds: root.canvasData.bounds,
scale: root.canvasData.scale,
rects: root.canvasData.rects.map(record => Object.assign({}, record))
};
}
Component.onCompleted: root.resetDraft()
Connections {
target: root.displayService
ignoreUnknownSignals: true
function onMonitorsChanged(): void {
if (!root.displayService.awaitingConfirmation)
root.resetDraft();
}
}
Column {
id: content
width: parent.width
spacing: 11
Rectangle {
id: canvas
width: parent.width
height: root.width >= 620 ? 232 : 190
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.76)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
clip: true
// A restrained coordinate field makes the topology feel like a
// precision instrument without turning it into a technical graph.
Repeater {
model: 4
Rectangle {
required property int index
y: (index + 1) * canvas.height / 5
width: canvas.width
height: 1
color: Theme.alpha(Theme.fg, 0.025)
}
}
Repeater {
model: root.canvasData.rects
Rectangle {
id: tile
required property var modelData
readonly property bool selected: root.selectedOutput === modelData.name
readonly property var draft: root.draftLayout.find(
record => record.name === modelData.name)
x: modelData.x
y: modelData.y
width: Math.max(64, modelData.width)
height: Math.max(48, modelData.height)
radius: 11
color: tile.selected
? Theme.alpha(Theme.bgHighlight, 0.92)
: Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78)
border.width: tile.selected || activeFocus ? 2 : 1
border.color: activeFocus
? Theme.accentSecondary
: (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14))
opacity: root.interactionEnabled ? 1 : 0.5
activeFocusOnTab: root.interactionEnabled
Accessible.role: Accessible.Button
Accessible.name: "Move " + tile.modelData.name
Accessible.description: tile.draft && tile.draft.primary
? "Primary display. Drag or use the arrow keys to move it."
: "Drag or use the arrow keys to move this display."
Rectangle {
anchors.fill: parent
anchors.margins: 2
radius: parent.radius - 2
visible: tile.selected
opacity: 0.24
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.accent }
GradientStop { position: 1; color: Theme.accentSecondary }
}
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 11
spacing: 2
Text {
width: parent.width
text: tile.modelData.name
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: tile.draft
? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}`
: ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 7
width: primaryText.implicitWidth + 12
height: 20
radius: Theme.pillRadius
visible: tile.draft && tile.draft.primary
color: Theme.alpha(Theme.accent, 0.2)
Text {
id: primaryText
anchors.centerIn: parent
text: "Primary"
color: Theme.accentAlt
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.DemiBold
}
}
HoverHandler {
id: hover
enabled: root.interactionEnabled
cursorShape: Qt.OpenHandCursor
}
TapHandler {
enabled: root.interactionEnabled
onTapped: {
root.selectionRequested(tile.modelData.name);
tile.forceActiveFocus();
}
}
DragHandler {
id: drag
target: null
enabled: root.interactionEnabled
property real initialX: 0
property real initialY: 0
property bool moved: false
onActiveChanged: {
if (active) {
const record = root.draftLayout.find(
candidate => candidate.name === tile.modelData.name);
initialX = record ? record.x : 0;
initialY = record ? record.y : 0;
moved = false;
root.selectionRequested(tile.modelData.name);
tile.forceActiveFocus();
} else if (moved) {
const record = root.draftLayout.find(
candidate => candidate.name === tile.modelData.name);
if (record) {
root.setDraftPosition(tile.modelData.name, record.x, record.y, true);
root.applyDraft();
}
}
}
onTranslationChanged: {
if (!active || root.canvasData.scale <= 0)
return;
moved = true;
root.setDraftPosition(
tile.modelData.name,
initialX + translation.x / root.canvasData.scale,
initialY + translation.y / root.canvasData.scale,
false);
}
}
Keys.onPressed: event => {
if (!root.interactionEnabled)
return;
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
let handled = true;
if (event.key === Qt.Key_Left)
root.nudge(tile.modelData.name, -step, 0);
else if (event.key === Qt.Key_Right)
root.nudge(tile.modelData.name, step, 0);
else if (event.key === Qt.Key_Up)
root.nudge(tile.modelData.name, 0, -step);
else if (event.key === Qt.Key_Down)
root.nudge(tile.modelData.name, 0, step);
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
root.applyDraft();
else if (event.key === Qt.Key_Escape)
root.resetDraft();
else
handled = false;
event.accepted = handled;
}
}
}
}
Row {
width: parent.width
spacing: 8
Text {
width: Math.max(0, parent.width - identifyButton.width
- primaryButton.width - applyButton.width - 24)
anchors.verticalCenter: parent.verticalCenter
text: root.width >= 600
? "Drag to arrange · arrows move 10 px · Shift moves 100 px"
: "Drag or use the arrow keys"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: identifyButton
text: "Identify"
enabled: root.interactionEnabled
onClicked: root.displayService.identify()
}
SettingsButton {
id: primaryButton
text: "Make primary"
enabled: root.interactionEnabled && root.selectedOutput !== ""
&& !root.draftLayout.find(record =>
record.name === root.selectedOutput)?.primary
onClicked: root.makePrimary(root.selectedOutput)
}
SettingsButton {
id: applyButton
text: "Apply"
enabled: root.interactionEnabled
onClicked: root.applyDraft()
}
}
}
}
@@ -0,0 +1,85 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
Variants {
model: Quickshell.screens
PanelWindow {
id: win
property var modelData: null
readonly property string connector: win.modelData?.name ?? "Display"
readonly property int number: Math.max(1,
Displays.monitors.findIndex(monitor => monitor.name === win.connector) + 1)
readonly property string description: Displays.monitorNamed(win.connector)?.description ?? "Connected display"
screen: win.modelData
visible: Displays.identifying
implicitWidth: 260
implicitHeight: 172
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
mask: Region {}
WlrLayershell.namespace: "qs-display-identify"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
Rectangle {
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, 0.96)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.14)
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Column {
anchors.centerIn: parent
width: parent.width - 32
spacing: 4
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: String(win.number)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 68
font.weight: Font.DemiBold
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: win.connector
color: Theme.accentAlt
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: win.description
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
}
@@ -0,0 +1,120 @@
// The resolution list for one display.
//
// Grouped by resolution with refresh rates beside it, rather than a flat list
// of "3840x2160@60.00Hz" strings: this panel reports 35 modes, many of which
// differ only in refresh-rate rounding, and a flat list of those is a wall of
// near-identical text rather than a choice.
import QtQuick
import qs.config
import qs.services
Column {
id: root
property var monitor: null
property bool enabled: true
spacing: 0
readonly property var grouped: {
if (!root.monitor)
return [];
const buckets = {};
const order = [];
for (const mode of root.monitor.modes) {
const key = mode.label;
if (!buckets[key]) {
buckets[key] = { label: key, width: mode.width, height: mode.height, rates: [] };
order.push(key);
}
buckets[key].rates.push(mode);
}
return order.map(key => buckets[key]);
}
Repeater {
model: root.grouped
SettingRow {
id: resolution
required property var modelData
required property int index
readonly property bool isCurrent: root.monitor
&& root.monitor.width === resolution.modelData.width
&& root.monitor.height === resolution.modelData.height
label: resolution.modelData.label
detail: resolution.isCurrent ? "Current resolution" : ""
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
divider: resolution.index < root.grouped.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: resolution.modelData.rates
Rectangle {
id: rate
required property var modelData
readonly property bool selected: resolution.isCurrent
&& Displays.modeIsCurrent(root.monitor, rate.modelData)
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
implicitHeight: 30
radius: 9
opacity: root.enabled ? 1 : 0.45
color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06)
border.width: rate.selected ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: rate.selected
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
}
}
Text {
id: rateCaption
anchors.centerIn: parent
text: rate.modelData.refreshLabel
color: rate.selected ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: rate.selected ? Font.DemiBold : Font.Normal
}
HoverHandler {
id: rateHover
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: root.enabled && !rate.selected
onTapped: Displays.apply(
root.monitor.name,
rate.modelData.mode,
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
root.monitor.transform)
}
}
}
}
}
}
}
@@ -1,97 +1,292 @@
// Displays.
//
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
// gaming display policy that was already here.
//
// Every geometry change goes through an apply-then-confirm countdown. This is
// the one page where a wrong value can leave the screen unreadable or blank,
// and no other control in the app can undo it once that happens. Confirming is
// what writes the choice to the settings store; letting the countdown run
// leaves nothing behind.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
title: "Displays"
lede: SystemSettings.monitorDescription || "Reading the active display…"
Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
property string selectedOutput: ""
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
readonly property string currentMode: root.monitor
? root.monitor.mode
: ""
SettingsCard {
title: SystemSettings.monitorName || "Active display"
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
SettingRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
SettingRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
}
function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
}
SettingsCard {
title: "Gaming display policy"
subtitle: "These values apply immediately and are restored when Panama starts."
SettingRow {
label: "Game-aware HDR"
detail: "Enter HDR only for fullscreen content that requests it"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
}
SettingRow {
label: "Content-aware VRR"
detail: "Enable variable refresh only for game and video content"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
}
SettingRow {
label: "Direct scanout for games"
detail: "Bypass compositing only for windows classified as games"
divider: false
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
}
}
// Probing I2C for DDC-capable monitors takes on the order of a second, so
// it runs when this page is opened rather than at shell startup. Monitors
// do not appear while you are looking at a settings page, so once is enough.
Component.onCompleted: {
root.syncSelectedOutput();
if (!Brightness.scanned)
Brightness.refresh();
}
Connections {
target: Displays
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
}
SettingsCard {
title: "Night Light"
SettingRow {
label: "Warm display colors"
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
}
SettingRow {
label: "Color temperature"
detail: `${NightLight.temperature} K`
divider: false
controlWidth: 230
ValueSlider {
anchors.fill: parent
value: (6500 - NightLight.temperature) / 4000
icon: "weather-clear-night-symbolic"
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
// The confirmation sits above everything, because while it is counting down
// it is the only thing that matters on this page.
header: Component {
Rectangle {
visible: Displays.awaitingConfirmation
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
radius: Theme.cardRadius
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.4)
Row {
id: confirmRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 16
spacing: 14
Column {
width: parent.width - keepButton.width - revertButton.width - 28
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: "Keep this display setting?"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
+ " if you do nothing. If you cannot read this, just wait."
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
Rectangle {
width: parent.width
height: warningText.implicitHeight + 30
radius: Theme.cardRadius
color: Theme.alpha(Theme.warn, 0.085)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.22)
Text {
id: warningText
anchors.fill: parent
anchors.margins: 15
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
color: Theme.mix(Theme.fg, Theme.warn, 0.25)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
SettingsButton {
id: revertButton
anchors.verticalCenter: parent.verticalCenter
text: "Revert now"
onClicked: Displays.revert()
}
SettingsButton {
id: keepButton
anchors.verticalCenter: parent.verticalCenter
text: "Keep"
enabled: Displays.canConfirm
onClicked: Displays.confirm()
}
}
}
}
SettingsCard {
visible: Displays.monitors.length > 1
title: "Arrange displays"
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
DisplayArrangement {
width: parent.width
displayService: Displays
selectedOutput: root.selectedOutput
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
onSelectionRequested: output => root.selectedOutput = output
}
}
SettingsCard {
visible: Displays.monitors.length > 1
title: "Connected display"
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
ChoiceGrid {
width: parent.width
label: "Display"
options: Displays.monitors.map(monitor => ({
value: monitor.name,
label: monitor.description || monitor.name
}))
current: root.monitor ? root.monitor.name : ""
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
onPicked: value => root.selectedOutput = value
}
}
SettingsCard {
title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display")
subtitle: root.monitor
? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale`
: "Reading the active display…"
TextRow {
label: "Color mode"
detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background."
value: root.monitor
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
: "Detecting"
}
TextRow {
label: "Variable refresh"
detail: root.monitor && root.monitor.vrr
? "Active on this output for current fullscreen content"
: "This output is ready when game or video content requests it"
value: root.monitor && root.monitor.vrr ? "Active" : "Standby"
divider: Displays.isOverridden(root.monitor ? root.monitor.name : "")
}
ActionRow {
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
label: "Using a custom display setting"
detail: "Forget it to go back to the shipped resolution and scale"
action: "Forget"
divider: false
onTriggered: Displays.forget(root.monitor.name)
}
}
SettingsCard {
visible: root.monitor !== null
title: "Resolution"
subtitle: "Applied straight away, then reverted automatically unless you confirm."
DisplayModePicker {
width: parent.width
monitor: root.monitor
enabled: !Displays.awaitingConfirmation && !Displays.busy
}
}
SettingsCard {
visible: root.monitor !== null
title: "Scale and rotation"
ChoiceGrid {
width: parent.width
label: "Scale"
detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered."
options: Displays.scalesForMode(root.currentMode)
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
current: root.monitor ? root.monitor.scale : 1
enabled: !Displays.awaitingConfirmation && !Displays.busy
onPicked: value => root.applyWith({ scale: value })
}
ChoiceGrid {
width: parent.width
label: "Rotation"
options: Displays.transforms
current: root.monitor ? root.monitor.transform : 0
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
onPicked: value => root.applyWith({ transform: value })
}
}
SettingsCard {
title: "Night Light"
subtitle: NightLight.active
? "On now, warming the display to reduce blue light."
: "Warms the display in the evening to reduce blue light."
ToggleRow { setting: "nightLightEnabled" }
ToggleRow { setting: "nightLightAutomatic" }
TimeOfDayRow { setting: "nightLightFrom" }
TimeOfDayRow { setting: "nightLightTo" }
SliderRow { setting: "nightLightTemperature"; divider: false }
}
// Panel brightness, over DDC/CI.
//
// This is hardware state rather than a stored preference: the monitor
// remembers it, the bezel buttons change it behind Panama's back, and
// writing it into settings.json would mean restoring a value the panel had
// already moved on from. So there is no schema key here and no SliderRow --
// the rows read and write the display directly.
SettingsCard {
visible: Brightness.available || Brightness.lastError !== ""
title: "Brightness"
subtitle: Brightness.available
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
: Brightness.lastError
Repeater {
model: Brightness.displays
SettingRow {
id: brightnessRow
required property var modelData
required property int index
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
: modelData.value + "%"
divider: brightnessRow.index < Brightness.displays.length - 1
controlWidth: 190
ValueSlider {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
width: parent.width
value: brightnessRow.modelData.value / 100
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
}
}
}
}
SettingsCard {
title: "Gaming display policy"
subtitle: "Applied immediately and restored when the session starts."
ToggleRow { setting: "autoHdr" }
ChoiceRow { setting: "vrrPolicy" }
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
}
SettingsCard {
visible: Displays.lastError !== ""
title: "Display problem"
subtitle: Displays.lastError
}
// Applies a change to one field, keeping the others at what is in effect.
function applyWith(change: var): void {
if (!root.monitor)
return;
const mode = change.mode ?? root.currentMode;
const requestedScale = change.scale ?? root.monitor.scale;
Displays.apply(
root.monitor.name,
mode,
Displays.isScaleClean(mode, requestedScale)
? requestedScale
: Displays.nearestCleanScale(mode, requestedScale),
change.transform ?? root.monitor.transform);
}
}
@@ -0,0 +1,71 @@
// Adds an installed application to the Dock.
//
// Filtered rather than a full list: there are several hundred desktop entries
// on a normal system, and rendering them all into a page that is already
// scrolling is both slow and useless. Typing narrows; nothing shows until you
// do, which also keeps the card short when you are not using it.
import QtQuick
import Quickshell
import qs.config
import qs.modules.clipboard
Column {
id: root
spacing: 0
required property var pinned
signal picked(string id)
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
if (root.pinned.indexOf(entry.id) >= 0)
continue;
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
SearchField {
id: search
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
label: candidate.modelData.name
detail: candidate.modelData.id
divider: candidate.index < root.matches.length - 1
controlWidth: 86
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Pin"
onClicked: {
root.picked(candidate.modelData.id);
search.text = "";
}
}
}
}
}
@@ -0,0 +1,112 @@
// The Dock's pinned applications: reorder, remove, and add.
//
// The list was a sixteen-entry literal in Settings.qml, so changing what sits
// in the Dock meant editing a QML file and reloading the shell. It is a plain
// ordered list of desktop entry ids, stored in the shared settings file, which
// means it is covered by Restore defaults like everything else.
//
// Move up / move down rather than drag-and-drop. Dragging inside a Flickable
// that is itself inside a scrolling page is a genuinely hard interaction to get
// right, and it fails in a way the user reads as the app being broken; two
// buttons are unambiguous and keyboard-reachable.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.widgets
Column {
id: root
spacing: 0
readonly property var pinned: {
const stored = DesktopPreferences.get("dockPinned");
return Array.isArray(stored) ? stored : [];
}
// DesktopEntries populates asynchronously, so this must be read as a
// binding rather than looked up inside one -- byId() called during
// evaluation registers no dependency and answers from an empty list.
readonly property var entriesById: {
const index = {};
for (const entry of DesktopEntries.applications.values)
index[entry.id] = entry;
return index;
}
function nameFor(id: string): string {
const entry = root.entriesById[id];
return entry ? entry.name : id;
}
function commit(next: var): void {
DesktopPreferences.set("dockPinned", next);
}
function move(from: int, to: int): void {
if (to < 0 || to >= root.pinned.length)
return;
const next = root.pinned.slice();
const moved = next.splice(from, 1)[0];
next.splice(to, 0, moved);
root.commit(next);
}
function remove(index: int): void {
const next = root.pinned.slice();
next.splice(index, 1);
root.commit(next);
}
function add(id: string): void {
if (root.pinned.indexOf(id) >= 0)
return;
root.commit(root.pinned.concat([id]));
}
Repeater {
model: root.pinned
SettingRow {
id: pin
required property var modelData
required property int index
label: root.nameFor(pin.modelData)
detail: pin.modelData
divider: pin.index < root.pinned.length - 1
controlWidth: 132
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
SettingsButton {
text: "↑"
enabled: pin.index > 0
onClicked: root.move(pin.index, pin.index - 1)
}
SettingsButton {
text: "↓"
enabled: pin.index < root.pinned.length - 1
onClicked: root.move(pin.index, pin.index + 1)
}
SettingsButton {
text: "Unpin"
onClicked: root.remove(pin.index)
}
}
}
}
SettingRow {
visible: root.pinned.length === 0
label: "Nothing is pinned"
detail: "The Dock will only show running applications"
divider: false
}
}
@@ -0,0 +1,80 @@
// Choosing a font family.
//
// Each candidate is rendered IN the font it names. A list of family names set
// in the current font tells you nothing about what you are choosing, and the
// whole point of picking a typeface is seeing it.
import QtQuick
import qs.config
import qs.modules.clipboard
Column {
id: root
spacing: 0
property var families: []
property string current: ""
property string emptyText: "No fonts found"
signal picked(string family)
// Filtered rather than listing all 139: a scrolling wall of family names
// inside a page that is already scrolling is worse than a search box.
readonly property var matches: {
const needle = filter.text.trim().toLowerCase();
const list = root.families.filter(family =>
needle === "" ? true : family.toLowerCase().indexOf(needle) >= 0);
// Always show what is currently selected, even when it does not match.
if (needle === "" && list.indexOf(root.current) >= 0)
return [root.current].concat(list.filter(f => f !== root.current)).slice(0, 12);
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: "Search installed fonts"
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData === root.current
label: candidate.modelData
detail: candidate.selected ? "Currently in use" : ""
controlWidth: 210
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 200
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
// The sample is drawn in the candidate family, which is the
// entire reason this is a list rather than a text field.
text: "Handgloves 0123"
font.family: candidate.modelData
font.pixelSize: Theme.fontSize + 1
color: candidate.selected ? Theme.accent : Theme.fgDim
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
@@ -0,0 +1,137 @@
import QtQuick
import qs.config
import qs.services
Item {
id: root
required property var check
property bool issue: false
property bool divider: true
property int actionActivationCount: 0
signal actionRequested(var check)
readonly property bool repairWorking: Health.repairingId === root.check.id
readonly property bool repairFailed: root.check.status !== "ok"
&& Health.lastRepair.checkId === root.check.id
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0)
objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
implicitHeight: 62
// The isolated contract uses the same signal path as a pointer or keyboard
// activation instead of calling HealthPage's action handler directly.
function activateAction(): bool {
if (!actionButton.visible || !actionButton.enabled)
return false;
root.actionActivationCount += 1;
actionButton.clicked();
return true;
}
function statusLabel(status: string): string {
if (status === "ok") return "Healthy";
if (status === "warning") return "Needs attention";
if (status === "error") return "Action required";
return "Not set up";
}
function statusColor(status: string): color {
if (status === "ok") return Theme.ok;
if (status === "warning") return Theme.warn;
if (status === "error") return Theme.danger;
return Theme.fgMuted;
}
function displayedStatus(): string {
if (root.repairWorking)
return "Working…";
if (root.repairFailed)
return "Repair failed";
return root.statusLabel(root.check.status);
}
Rectangle {
id: issueMark
visible: root.issue
width: 7
height: 7
radius: 4
anchors.left: parent.left
anchors.leftMargin: 1
anchors.verticalCenter: parent.verticalCenter
color: root.statusColor(root.check.status)
border.width: 1
border.color: Theme.alpha(Theme.bgPanel, 0.9)
}
Column {
anchors.left: parent.left
anchors.leftMargin: root.issue ? 22 : 0
anchors.right: trailing.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.check.title
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.check.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Row {
id: trailing
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
anchors.verticalCenter: parent.verticalCenter
text: root.displayedStatus()
color: root.statusColor(root.check.status)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: actionButton
objectName: `health-row-action:${root.issue ? "issue" : "quiet"}:${root.check.id}`
visible: root.check.action !== undefined
text: root.check.action?.label ?? ""
enabled: visible && !root.repairWorking && !Health.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.actionRequested(root.check)
Keys.onReturnPressed: if (enabled) root.actionRequested(root.check)
Keys.onSpacePressed: if (enabled) root.actionRequested(root.check)
}
}
Rectangle {
visible: root.divider
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.alpha(Theme.fg, 0.07)
}
}
@@ -0,0 +1,458 @@
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "system-health-page"
title: "System Health"
lede: "Checks the parts of the desktop this app owns, and explains what needs attention."
property var pendingConfirmation: null
property string instructionTarget: ""
readonly property var issueChecks: Health.checks.filter(check => check.status === "warning" || check.status === "error")
readonly property var groups: [
{
group: "desktop-foundation",
title: "Desktop foundation",
subtitle: "Compositor, shell, portals, wallpaper, idle policy, and launcher."
},
{
group: "input-media",
title: "Input & media",
subtitle: "Sound, clipboard, capture, OCR, and display controls."
},
{
group: "integrations",
title: "Integrations",
subtitle: "Only configured integrations affect health."
},
{
group: "panama-tools",
title: "Desktop tools",
subtitle: "Tracked links, launcher commands, apps, and inhibitors."
}
]
readonly property var applicationTargets: ({
"integration.nextcloud": "nextcloud",
"integration.rustdesk": "rustdesk",
"integration.kdeconnect": "kdeconnect",
"integration.bluebubbles": "bluebubbles"
})
function statusLabel(status: string): string {
if (status === "ok") return "Healthy";
if (status === "warning") return "Needs attention";
if (status === "error") return "Action required";
return "Not set up";
}
function checksForGroup(group: string): var {
return Health.checks.filter(check => check.group === group
&& (check.status === "ok" || check.status === "unconfigured"));
}
function handleAction(check: var): void {
if (!check || !check.action)
return;
if (check.action.kind === "open") {
if (check.action.target) {
ShellState.openSettings(check.action.target);
return;
}
const application = root.applicationTargets[check.id];
if (application)
SystemSettings.openApplication(application);
return;
}
if (check.action.kind === "instructions") {
root.instructionTarget = check.action.target || "";
return;
}
if (check.action.kind !== "repair")
return;
if (check.action.confirm) {
root.pendingConfirmation = check;
return;
}
Health.repair(check.id, false);
}
function confirmRepair(): void {
const check = root.pendingConfirmation;
root.pendingConfirmation = null;
if (check)
Health.repair(check.id, false);
}
function descendants(item: var, prefix: string): var {
let matches = [];
if (!item)
return matches;
if (String(item.objectName || "").indexOf(prefix) === 0)
matches.push(item);
for (const child of item.children || [])
matches = matches.concat(root.descendants(child, prefix));
return matches;
}
function activateRenderedAction(id: string): bool {
const suffix = `:${id}`;
const rows = root.descendants(root, "health-check-row:").filter(row =>
row.visible && String(row.objectName).endsWith(suffix));
return rows.length === 1 && rows[0].activateAction();
}
function renderedFocusChain(): var {
// Repeater delegates enter Qt's tab chain lazily after their enabled
// binding changes at the end of a scan. Touch each rendered action's
// real next-focus link before traversing from the first hero control.
const renderedActions = root.descendants(root, "health-row-action:").filter(item =>
item.visible && item.enabled && item.activeFocusOnTab);
for (const action of renderedActions)
action.nextItemInFocusChain(true);
const starts = root.descendants(root, "health-copy-report-button").filter(item => item.visible && item.enabled);
if (starts.length !== 1)
return [];
const names = [];
const start = starts[0];
let current = start;
for (let index = 0; index < 128; index++) {
const name = String(current.objectName || "");
if (name !== "" && names.indexOf(name) < 0)
names.push(name);
current = current.nextItemInFocusChain(true);
if (!current || current === start)
break;
}
return names;
}
// Deterministic, read-only fixture seam used by the offscreen contract.
function uiDiagnostics(): var {
const summaries = root.descendants(root, "health-summary");
const rows = root.descendants(root, "health-check-row:").filter(row => row.visible);
const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.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 fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
return {
renderedRows: rows.map(row => {
const objectName = String(row.objectName);
const parts = objectName.split(":");
const statusTexts = root.descendants(row, "health-status-text:").filter(text => text.visible);
return {
objectName: objectName,
id: parts.slice(2).join(":"),
section: parts[1],
statusText: statusTexts.length === 1 ? statusTexts[0].text : ""
};
}),
summaryHeight: summaries.length > 0 ? summaries[0].height : 0,
rowHeights: rows.map(row => row.height),
checking: Health.busy,
checkingText: checkingLabels.length > 0 ? checkingLabels[0].text : "",
focusChain: root.renderedFocusChain(),
activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)),
emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)),
fedoraHandoffs: fedoraHandoffs.map(row => ({
id: String(row.objectName).slice("health-fedora-handoff:".length),
label: row.label,
action: row.action
})),
confirmationVisible: confirmationSheets.length === 1,
confirmationId: confirmationSheets.length === 1
? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length)
: ""
};
}
Component.onCompleted: Health.refresh()
header: Component {
Rectangle {
objectName: `health-confirmation-sheet:${root.pendingConfirmation ? root.pendingConfirmation.id : ""}`
visible: root.pendingConfirmation !== null
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
radius: Theme.cardRadius
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.34)
Row {
id: confirmRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 16
spacing: 14
Column {
width: parent.width - cancelButton.width - repairButton.width - 28
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.pendingConfirmation
? `${root.pendingConfirmation.action.label}?`
: "Restart the shell?"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "The desktop chrome will disappear briefly and return when Quickshell restarts."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
SettingsButton {
id: cancelButton
text: "Cancel"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.pendingConfirmation = null
Keys.onReturnPressed: root.pendingConfirmation = null
Keys.onSpacePressed: root.pendingConfirmation = null
}
SettingsButton {
id: repairButton
text: root.pendingConfirmation ? root.pendingConfirmation.action.label : "Restart the shell"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.confirmRepair()
Keys.onReturnPressed: root.confirmRepair()
Keys.onSpacePressed: root.confirmRepair()
}
}
}
}
HealthSummary {}
SettingsCard {
visible: root.issueChecks.length > 0
title: Health.status === "error" ? "Action required" : "Needs attention"
subtitle: Health.status === "error"
? "Resolve these items first. Healthy systems remain listed below."
: "Nothing here prevents you from using the desktop."
Item {
width: parent.width
implicitHeight: issueRows.implicitHeight
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.top: parent.top
anchors.topMargin: 25
anchors.bottom: parent.bottom
anchors.bottomMargin: 25
width: 1
visible: root.issueChecks.length > 1
color: Theme.alpha(Health.status === "error" ? Theme.danger : Theme.warn, 0.44)
}
Column {
id: issueRows
width: parent.width
Repeater {
id: issueRepeater
model: root.issueChecks
HealthCheckRow {
required property var modelData
required property int index
width: issueRows.width
check: modelData
issue: true
divider: index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
}
}
}
}
SettingsCard {
visible: root.instructionTarget === "ddc-permissions"
title: "External monitor brightness"
subtitle: "The monitor reports DDC/CI support, but this session cannot reach the monitor bus."
Item {
width: parent.width
implicitHeight: Math.max(instructionCopy.implicitHeight, doneButton.implicitHeight) + 8
Text {
id: instructionCopy
anchors.left: parent.left
anchors.right: doneButton.left
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Reload the installed udev rules and trigger the i2c-dev and DRM devices. Sign out and back in if monitor access is still unavailable."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
SettingsButton {
id: doneButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Done"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.instructionTarget = ""
Keys.onReturnPressed: root.instructionTarget = ""
Keys.onSpacePressed: root.instructionTarget = ""
}
}
}
Grid {
id: groupGrid
width: parent.width
columns: width >= 700 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
Repeater {
model: root.groups
SettingsCard {
required property var modelData
readonly property var quietChecks: root.checksForGroup(modelData.group)
width: groupGrid.columns === 2
? (groupGrid.width - groupGrid.columnSpacing) / 2
: groupGrid.width
title: modelData.title
subtitle: modelData.subtitle
Column {
id: groupRows
width: parent.width
Text {
objectName: `health-empty-group:${modelData.group}`
width: parent.width
visible: quietChecks.length === 0
text: "Items needing attention are listed above."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
topPadding: 8
bottomPadding: 8
wrapMode: Text.WordWrap
}
Repeater {
id: groupRepeater
model: quietChecks
HealthCheckRow {
required property var modelData
required property int index
width: groupRows.width
check: modelData
divider: index < groupRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
}
}
}
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
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 {
objectName: "health-fedora-handoff:users"
label: "Users"
detail: "Accounts, passwords, and automatic login"
action: "Open users"
onTriggered: SystemSettings.openGnomePanel("system", "users")
}
ActionRow {
objectName: "health-fedora-handoff:sharing"
label: "Sharing"
detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing"
onTriggered: SystemSettings.openGnomePanel("sharing")
}
ActionRow {
objectName: "health-fedora-handoff:color"
label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners"
action: "Open colour"
onTriggered: SystemSettings.openGnomePanel("color")
}
ActionRow {
objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing"
detail: "Screen time and break reminders"
action: "Open wellbeing"
divider: false
onTriggered: SystemSettings.openGnomePanel("wellbeing")
}
}
}
@@ -0,0 +1,169 @@
import QtQuick
import qs.config
import qs.services
SettingsCard {
id: root
objectName: "health-summary"
implicitHeight: 126
readonly property int observationCount: Health.summary.warnings + Health.summary.errors
readonly property string heroTitle: {
if (Health.diagnosticUnavailable)
return "Health check unavailable";
if (Health.checks.length === 0)
return "Checking the desktop";
if (Health.status === "error")
return "Action required";
if (Health.status === "warning")
return "Needs attention";
return "Healthy";
}
readonly property string heroDetail: {
if (Health.diagnosticUnavailable)
return Health.lastError || "The latest health check could not be completed.";
if (Health.checks.length === 0)
return "Checking the desktop services, tools, and integrations this app owns.";
if (Health.status === "error")
return root.observationCount === 1
? "One part of the desktop needs action."
: `${root.observationCount} parts of the desktop need action.`;
if (Health.status === "warning")
return root.observationCount === 1
? "Your desktop is working. One feature needs a decision."
: `Your desktop is working. ${root.observationCount} features need a decision.`;
return "Desktop services and tools are working normally.";
}
readonly property color statusColor: {
if (Health.diagnosticUnavailable || Health.status === "error")
return Theme.danger;
if (Health.status === "warning")
return Theme.warn;
if (Health.checks.length === 0)
return Theme.fgMuted;
return Theme.ok;
}
function scanTime(): string {
const generatedAt = String(Health.snapshot.generatedAt || "");
if (generatedAt === "")
return "No completed check yet";
const date = new Date(generatedAt);
if (Number.isNaN(date.getTime()))
return "Last check completed";
return `Last checked ${date.toLocaleTimeString(Qt.locale(), Locale.ShortFormat)}`;
}
Item {
width: parent.width
implicitHeight: 96
Column {
anchors.left: parent.left
anchors.right: actions.left
anchors.rightMargin: 22
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
width: parent.width
text: Health.diagnosticUnavailable
? "DIAGNOSTICS"
: root.observationCount > 0
? root.observationCount + (root.observationCount === 1 ? " OBSERVATION" : " OBSERVATIONS")
: "PANAMA DESKTOP"
color: root.statusColor
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 1.15
}
Text {
width: parent.width
text: root.heroTitle
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 24
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.heroDetail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Row {
spacing: 10
Text {
text: root.scanTime()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
objectName: "health-checking-label"
visible: Health.busy
text: "Checking…"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
visible: Health.lastCopyResult !== ""
text: Health.lastCopyResult
color: Health.lastCopyResult === "Report copied." ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Row {
id: actions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: copyButton
objectName: "health-copy-report-button"
visible: !Health.diagnosticUnavailable
text: "Copy report"
enabled: Health.checks.length > 0
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: Health.copyReport()
Keys.onReturnPressed: if (enabled) Health.copyReport()
Keys.onSpacePressed: if (enabled) Health.copyReport()
}
SettingsButton {
id: refreshButton
objectName: "health-refresh-button"
text: Health.diagnosticUnavailable ? "Retry" : "Refresh"
tone: Health.diagnosticUnavailable ? "normal" : "accent"
enabled: !Health.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: Health.refresh()
Keys.onReturnPressed: if (enabled) Health.refresh()
Keys.onSpacePressed: if (enabled) Health.refresh()
}
}
}
}
@@ -0,0 +1,159 @@
import QtQuick
import qs.config
import qs.widgets
Rectangle {
id: root
required property var favorite
required property string sourceName
required property int index
required property bool featured
required property bool canMoveEarlier
required property bool canMoveLater
signal aliasCommitted(string id, string alias)
signal removeRequested(string id)
signal moveRequested(string id, int targetIndex)
implicitHeight: 108
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.7)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: root.radius
opacity: 0.2
}
Column {
id: reorderControls
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
width: 30
spacing: 4
SettingsButton {
width: 30
height: 29
text: "↑"
enabled: root.canMoveEarlier
activeFocusOnTab: enabled
onClicked: root.moveRequested(root.favorite.id, root.index - 1)
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
}
SettingsButton {
width: 30
height: 29
text: "↓"
enabled: root.canMoveLater
activeFocusOnTab: enabled
onClicked: root.moveRequested(root.favorite.id, root.index + 1)
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
}
}
Rectangle {
id: aliasFrame
anchors.left: reorderControls.right
anchors.leftMargin: 10
anchors.right: removeButton.left
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 12
height: 34
radius: 8
color: Theme.alpha(Theme.fg, aliasInput.activeFocus ? 0.075 : 0.045)
border.width: aliasInput.activeFocus ? 2 : 1
border.color: aliasInput.activeFocus
? Theme.alpha(Theme.accent, 0.78)
: Theme.alpha(Theme.fg, 0.065)
TextInput {
id: aliasInput
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
activeFocusOnTab: true
text: String(root.favorite.alias || "")
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
onEditingFinished: root.aliasCommitted(root.favorite.id, text)
Text {
anchors.fill: parent
visible: aliasInput.text === "" && !aliasInput.activeFocus
text: root.sourceName
color: Theme.fgDim
font: aliasInput.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
Text {
anchors.left: aliasFrame.left
anchors.right: removeButton.left
anchors.rightMargin: 12
anchors.top: aliasFrame.bottom
anchors.topMargin: 7
text: root.sourceName
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Rectangle {
anchors.left: aliasFrame.left
anchors.bottom: parent.bottom
anchors.bottomMargin: 10
width: badgeCopy.implicitWidth + 14
height: 21
radius: Theme.pillRadius
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"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
}
}
SettingsButton {
id: removeButton
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Remove"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.removeRequested(root.favorite.id)
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
}
}
@@ -2,160 +2,163 @@ import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
readonly property int openedHour: new Date().getHours()
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
title: `${root.greeting}, Gabriel`
lede: "Your desktop is configured and ready."
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
SettingsCard {
title: SystemSettings.monitorDescription || "Active display"
subtitle: SystemSettings.monitorName || "Detecting your display…"
Text {
text: `${root.greeting}, Gabriel`
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Grid {
id: monitorLayout
Text {
text: "Your Panama desktop is configured and ready."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
width: parent.width
columns: width >= 620 ? 2 : 1
columnSpacing: 28
rowSpacing: 16
SettingsCard {
title: SystemSettings.monitorDescription || "Active display"
subtitle: SystemSettings.monitorName || "Detecting your display…"
Item {
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: 164
Row {
width: parent.width
height: 164
spacing: 28
Rectangle {
width: Math.min(parent.width - 24, 260)
height: width * 0.64
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 5
radius: 12
color: Theme.alpha(Theme.bgDark, 0.94)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.34)
Item {
width: parent.width * 0.47
height: parent.height
Rectangle {
width: Math.min(parent.width - 24, 260)
height: width * 0.64
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 5
radius: 12
color: Theme.alpha(Theme.bgDark, 0.94)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.34)
Rectangle {
anchors.fill: parent
anchors.margins: 10
radius: 7
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
}
Text {
anchors.centerIn: parent
text: SystemSettings.monitorName || "DISPLAY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.letterSpacing: 1.5
}
}
Rectangle {
anchors.fill: parent
anchors.margins: 10
radius: 7
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
}
}
Column {
width: parent.width * 0.47
anchors.verticalCenter: parent.verticalCenter
spacing: 13
Text {
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
color: Theme.fg
anchors.centerIn: parent
text: SystemSettings.monitorName || "DISPLAY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
Text {
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.pixelSize: Theme.fontSizeSmall
font.letterSpacing: 1.5
}
}
}
}
Row {
width: parent.width
spacing: 16
Column {
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: monitorLayout.columns === 2 ? 164 : implicitHeight
spacing: 13
SettingsCard {
width: (parent.width - parent.spacing) / 2
title: "Quiet focus"
subtitle: "Notifications and focused work"
SettingRow {
label: "Do Not Disturb"
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
}
Text {
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
SettingsCard {
width: (parent.width - parent.spacing) / 2
title: "Desktop services"
subtitle: "The essentials are running"
SettingRow {
label: "Sync & remote access"
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
divider: false
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review"
}
Text {
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
Text {
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
SettingsCard {
title: "Weather"
subtitle: "Local conditions in the date menu"
TextRow {
label: "Location"
detail: "Only the search term is sent; the name below is a label kept on this machine"
value: Settings.weatherLocation
}
LocationPicker {
width: parent.width
}
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
Grid {
id: summaryCards
width: parent.width
columns: width >= 720 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Quiet focus"
subtitle: "Notifications and focused work"
SettingRow {
label: "Do Not Disturb"
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
}
}
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Desktop services"
subtitle: "The essentials are running"
TextRow {
label: "Sync & remote access"
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
divider: false
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review"
}
}
}
}
@@ -0,0 +1,478 @@
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
property string lightQuery: searchInput.text.trim().toLowerCase()
readonly property var availableLights: HomeAssistant.catalog.filter(entity => {
if (HomeAssistant.selectedEntities.some(selected => selected.id === entity.id))
return false;
const haystack = (entity.sourceName + " " + entity.id).toLowerCase();
return root.lightQuery === "" || haystack.includes(root.lightQuery);
})
readonly property string availableEmptyText: root.availableLights.length > 0
? ""
: (root.lightQuery !== ""
? "No lights match that search"
: (HomeAssistant.catalog.length === 0
? "No lights discovered"
: "All discovered lights are already selected"))
readonly property var pageDiagnostics: ({
availableLightIds: root.availableLights.map(entity => entity.id),
availableEmptyText: root.availableEmptyText,
homeStatus: root.homeStatus()
})
function homeStatus(): string {
if (HomeAssistant.lastError === "authentication-required")
return "Authentication required";
if (HomeAssistant.lastError === "not-configured")
return "Home Assistant is not configured";
if (HomeAssistant.phase === "ready")
return `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
if (HomeAssistant.phase === "degraded")
return "Last update unavailable · showing saved controls";
if (HomeAssistant.phase === "loading")
return "Connecting to Home Assistant";
return "Home Assistant is unavailable";
}
function saveHomeAssistantConfig(): void {
HomeAssistantConfig.save(
homeUrlInput.text,
homeEntitiesInput.text,
homeTokenInput.text
);
}
Connections {
target: HomeAssistantConfig
function onConfigurationSaved(): void {
homeTokenInput.clear();
homeUrlInput.text = HomeAssistantConfig.url;
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
}
}
SettingsCard {
title: "Home Assistant"
subtitle: root.homeStatus()
SettingRow {
label: "Connection"
detail: HomeAssistantConfig.tokenConfigured
? "A long-lived access token is stored privately"
: "Paste a long-lived access token to connect"
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
}
SettingRow {
label: "Server URL"
detail: "The local or remote address of Home Assistant"
controlWidth: 330
Rectangle {
anchors.fill: parent
radius: Theme.pillRadius
color: Theme.alpha(Theme.fg, 0.07)
border.width: homeUrlInput.activeFocus ? 2 : 1
border.color: homeUrlInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : "transparent"
TextInput {
id: homeUrlInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
activeFocusOnTab: true
text: HomeAssistantConfig.url
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
Text {
anchors.fill: parent
visible: homeUrlInput.text === ""
text: "https://homeassistant.local:8123"
color: Theme.fgMuted
font: homeUrlInput.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
SettingRow {
label: "Access token"
detail: HomeAssistantConfig.tokenConfigured
? "Stored · leave blank to keep it"
: "Create one in your Home Assistant profile"
controlWidth: 330
PasswordField {
id: homeTokenInput
anchors.fill: parent
placeholder: HomeAssistantConfig.tokenConfigured
? "Stored token" : "Long-lived access token"
onAccepted: root.saveHomeAssistantConfig()
}
}
Column {
width: parent.width
spacing: 7
topPadding: 10
bottomPadding: 12
Text {
width: parent.width
text: "Light entities"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
text: "Comma-separated entity IDs. These define the discoverable light catalog."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
height: 72
radius: 10
color: Theme.alpha(Theme.fg, 0.055)
border.width: homeEntitiesInput.activeFocus ? 2 : 1
border.color: homeEntitiesInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
TextEdit {
id: homeEntitiesInput
anchors.fill: parent
anchors.margins: 10
activeFocusOnTab: true
text: HomeAssistantConfig.entities.join(", ")
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: TextEdit.Wrap
clip: true
Text {
anchors.fill: parent
visible: homeEntitiesInput.text === ""
text: "light.living_room, light.kitchen"
color: Theme.fgMuted
font: homeEntitiesInput.font
wrapMode: Text.WordWrap
}
}
}
}
Text {
width: parent.width
visible: HomeAssistantConfig.lastError !== ""
text: HomeAssistantConfig.lastError
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
bottomPadding: 9
}
SettingRow {
label: "Private configuration"
detail: "Saved with owner-only permissions in a private environment file"
controlWidth: 216
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: clearTokenButton
text: "Clear token"
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistantConfig.clearToken()
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
}
SettingsButton {
id: saveHomeConfigButton
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
tone: "accent"
enabled: !HomeAssistantConfig.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : "transparent"
onClicked: root.saveHomeAssistantConfig()
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
}
}
}
SettingRow {
label: "Light catalog"
detail: "Light state is read through the Home Assistant helper."
divider: false
controlWidth: 176
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: refreshButton
text: "Refresh"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistant.refresh()
Keys.onReturnPressed: HomeAssistant.refresh()
Keys.onSpacePressed: HomeAssistant.refresh()
}
SettingsButton {
id: openHomeButton
text: "Open"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistant.open()
Keys.onReturnPressed: HomeAssistant.open()
Keys.onSpacePressed: HomeAssistant.open()
}
}
}
}
SettingsCard {
title: "Control Center lights"
subtitle: HomeAssistant.selectedEntities.length === 0
? "Select the lights that belong on your shelf."
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
Text {
width: parent.width
visible: HomeAssistant.selectedEntities.length === 0
text: "Choose lights below to build your Control Center shelf."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
topPadding: 3
bottomPadding: 13
}
GridView {
id: favoritesGrid
width: parent.width
height: Math.ceil(count / 2) * cellHeight
visible: count > 0
interactive: false
clip: false
cellWidth: width / 2
cellHeight: 116
model: HomeAssistant.selectedEntities
delegate: HomeFavoriteCard {
required property var modelData
width: GridView.view.cellWidth - 6
height: 108
favorite: ({
id: modelData.id,
alias: modelData.name === modelData.sourceName ? "" : modelData.name
})
sourceName: modelData.sourceName
featured: index < 4
canMoveEarlier: index > 0
canMoveLater: index < favoritesGrid.count - 1
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
}
}
Rectangle {
width: parent.width
height: 46
visible: HomePreferences.saveError !== ""
radius: 9
color: Theme.alpha(Theme.warn, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.24)
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: retryButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomePreferences.saveError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: retryButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
onClicked: HomePreferences.retrySave()
Keys.onReturnPressed: HomePreferences.retrySave()
Keys.onSpacePressed: HomePreferences.retrySave()
}
}
}
SettingsCard {
title: "Available lights"
subtitle: "Search the Home Assistant catalog by source name or entity ID."
Rectangle {
width: parent.width
height: 38
radius: 10
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
border.width: searchInput.activeFocus ? 2 : 1
border.color: searchInput.activeFocus
? Theme.alpha(Theme.accent, 0.72)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
Text {
anchors.fill: parent
visible: searchInput.text === "" && !searchInput.activeFocus
text: "Search available lights"
color: Theme.fgMuted
font: searchInput.font
verticalAlignment: Text.AlignVCenter
}
}
}
Column {
width: parent.width
visible: root.availableLights.length > 0
Repeater {
model: root.availableLights
AvailableLightRow {
required property var modelData
width: parent.width
entity: modelData
onAddRequested: id => HomePreferences.add(id)
}
}
}
Text {
width: parent.width
visible: root.availableLights.length === 0
text: root.availableEmptyText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
}
}
SettingsCard {
title: "Phone continuity"
subtitle: "Keep the Messages handoff independent from phone connectivity."
SettingRow {
label: "Messages"
detail: "Opens BlueBubbles"
divider: false
controlWidth: 204
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: openBlueBubblesButton
text: "Open"
enabled: SystemSettings.bluebubblesAvailable
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openApplication("bluebubbles")
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
}
}
}
}
}
@@ -0,0 +1,53 @@
// Choosing where the weather reading is for.
//
// A search box rather than latitude and longitude fields: nobody knows their
// own coordinates, and a control that demands them is one nobody ever uses. The
// coordinates are what actually get stored -- the name is only a label.
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
spacing: 0
SearchField {
id: query
width: parent.width
placeholder: "Search for a town or city"
onTextChanged: Geocoding.search(query.text)
}
Repeater {
model: Geocoding.results
SettingRow {
id: place
required property var modelData
required property int index
label: place.modelData.name
detail: [place.modelData.admin, place.modelData.country].filter(part => !!part).join(", ")
value: place.modelData.latitude.toFixed(2) + ", " + place.modelData.longitude.toFixed(2)
controlWidth: 150
divider: place.index < Geocoding.results.length - 1
activatable: true
onActivated: {
if (Geocoding.choose(place.modelData))
query.text = "";
}
}
}
SettingRow {
width: parent.width
visible: Geocoding.searching || Geocoding.lastError !== ""
label: Geocoding.searching ? "Searching…" : "No result"
detail: Geocoding.searching ? "" : Geocoding.lastError
divider: false
}
}
@@ -0,0 +1,152 @@
import Quickshell
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
property string backgroundMode: DesktopPreferences.get("lockBackgroundMode")
property int blurLevel: DesktopPreferences.get("lockBlurLevel")
property bool showClock: DesktopPreferences.get("lockShowClock")
property bool showDate: DesktopPreferences.get("lockShowDate")
property bool showUser: DesktopPreferences.get("lockShowUser")
property bool fadeOnEmpty: DesktopPreferences.get("lockFadeOnEmpty")
property bool use24Hour: DesktopPreferences.get("use24Hour")
property string wallpaperPath: Wallpaper.active !== ""
? Wallpaper.active
: (Wallpaper.configured !== "" ? Wallpaper.configured : Wallpaper.shippedPath)
readonly property string previewMode: root.backgroundMode
readonly property real blurStrength: Math.max(0, Math.min(1, root.blurLevel / 5))
readonly property bool wallpaperVisible: wallpaper.visible
readonly property bool clockVisible: clockLabel.visible
readonly property bool dateVisible: dateLabel.visible
readonly property bool userVisible: userLabel.visible
readonly property bool passwordVisible: passwordField.visible
width: parent ? parent.width : 620
implicitHeight: 230
radius: Theme.cardRadius
clip: true
color: Theme.bg
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.10)
Image {
id: wallpaper
anchors.fill: parent
visible: root.backgroundMode === "wallpaper"
source: visible && root.wallpaperPath !== "" ? root.wallpaperPath : ""
asynchronous: true
cache: true
fillMode: Image.PreserveAspectCrop
sourceSize.width: 960
sourceSize.height: 540
}
// Screenshot mode stays representative instead of taking a real desktop
// capture inside Settings. The layered window silhouettes communicate the
// selected softness without a live ShaderEffect or GPU repaint loop.
Rectangle {
anchors.fill: parent
visible: root.backgroundMode === "screenshot"
color: Theme.bgDark
Rectangle {
x: parent.width * 0.10
y: parent.height * 0.12
width: parent.width * 0.45
height: parent.height * 0.66
radius: 14 + root.blurStrength * 10
color: Theme.alpha(Theme.bgHighlight, 0.52 - root.blurStrength * 0.18)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.14)
}
Rectangle {
x: parent.width * 0.48
y: parent.height * 0.24
width: parent.width * 0.40
height: parent.height * 0.57
radius: 14 + root.blurStrength * 10
color: Theme.alpha(Theme.bgPanel, 0.64 - root.blurStrength * 0.20)
border.width: 1
border.color: Theme.alpha(Theme.accentSecondary, 0.13)
}
}
Rectangle {
anchors.fill: parent
color: root.backgroundMode === "wallpaper"
? Theme.alpha(Theme.bg, 0.42)
: Theme.alpha(Theme.bg, 0.12 + root.blurStrength * 0.16)
}
Text {
id: clockLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 26
visible: root.showClock
text: Qt.formatDateTime(clock.date, root.use24Hour ? "HH:mm" : "h:mm")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 42
font.weight: Font.Light
}
Text {
id: dateLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: clockLabel.visible ? clockLabel.bottom : parent.top
anchors.topMargin: clockLabel.visible ? 1 : 35
visible: root.showDate
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
id: passwordField
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: root.showUser ? 48 : 30
visible: !root.fadeOnEmpty
width: Math.min(270, parent.width * 0.48)
height: 36
radius: height / 2
color: Theme.alpha(Theme.bgPanel, 0.84)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.78)
Text {
anchors.centerIn: parent
text: "Password"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.italic: true
}
}
Text {
id: userLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 19
visible: root.showUser
text: Quickshell.env("USER") || "User"
color: Theme.alpha(Theme.fg, 0.90)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
}

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