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
277 changed files with 27808 additions and 997 deletions
+9
View File
@@ -13,3 +13,12 @@
# 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
+26 -1
View File
@@ -36,7 +36,32 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | 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
+8 -8
View File
@@ -27,7 +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 Panama Settings writes. See below |
| `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 |
@@ -59,7 +59,7 @@ generated elsewhere:
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
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:
@@ -77,7 +77,7 @@ relationship is:
`prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it.
- **Panama Settings is the editor.** It writes the file *and* applies the change
- **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
@@ -95,7 +95,7 @@ accepts the config in each of those states.
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
the Settings app without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
@@ -118,7 +118,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the
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.
@@ -275,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 |
@@ -288,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
@@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra
dashboard windows.
Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
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
+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" })'
@@ -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
+12 -4
View File
@@ -10,9 +10,9 @@ local prefs = require("prefs")
hl.config({
input = {
kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "",
kb_variant = prefs.get("keyboardVariant", ""),
kb_model = "",
kb_options = "",
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
kb_rules = "",
numlock_by_default = prefs.get("numlockByDefault", true),
@@ -21,10 +21,18 @@ hl.config({
repeat_delay = prefs.get("keyRepeatDelay", 500),
repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
-- 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.
+32 -17
View File
@@ -30,6 +30,9 @@ 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
@@ -77,7 +80,7 @@ bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ────────────────────────────────────────────────────────────────
@@ -192,9 +195,21 @@ bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = t
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
-- 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.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
@@ -252,23 +267,23 @@ bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { descri
-- ── Media and volume ────────────────────────────────────────────────────────
-- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting.
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" })
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" })
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" })
bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" })
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
-- Hardware keys GNOME mapped that have obvious equivalents.
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
+56 -19
View File
@@ -22,17 +22,22 @@ hl.config({
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.
@@ -40,16 +45,22 @@ 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 = prefs.get("windowRounding", 18),
rounding_power = 2,
rounding_power = prefs.get("roundingPower", 2),
active_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 = {
@@ -79,9 +90,13 @@ hl.config({
shadow = {
enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20),
render_power = 3,
sharp = false,
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,
},
@@ -106,16 +121,38 @@ hl.config({
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
@@ -164,8 +201,8 @@ hl.config({
},
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 = "",
+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)
});
}
}
}
@@ -98,7 +98,26 @@ Singleton {
// back to shipped defaults and let the next write replace it.
parsed = {};
}
root.values = (parsed && typeof parsed === "object") ? 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;
}
+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 };
}
}
+725 -15
View File
@@ -128,10 +128,21 @@ Singleton {
{
key: "vrrPolicy", type: "enum", def: 3, group: "display",
label: "Variable refresh rate",
detail: "Content-aware matches the display to what is on screen",
detail: "Matches the display's refresh rate to what is on screen",
// All four the compositor publishes, rather than the two that were
// here. Always-on VRR is a legitimate choice on a panel that
// handles it well, and it was simply unreachable -- as was
// fullscreen-only, which is what someone wanting VRR for video
// rather than games wants.
options: [
{ value: 0, label: "Off" },
{ value: 3, label: "Content-aware" }
{ value: 0, label: "Off",
detail: "The display runs at a fixed refresh rate" },
{ value: 1, label: "Always on",
detail: "Best on panels that handle low refresh rates without flicker" },
{ value: 2, label: "Fullscreen only",
detail: "Any fullscreen window, including video" },
{ value: 3, label: "Fullscreen games",
detail: "Only fullscreen games, which is the safest default" }
],
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
},
@@ -140,8 +151,12 @@ Singleton {
label: "Direct scanout",
detail: "Lets fullscreen content bypass compositing",
options: [
{ value: 0, label: "Off" },
{ value: 2, label: "Automatic" }
{ value: 0, label: "Off",
detail: "Everything goes through the compositor" },
{ value: 1, label: "Always on",
detail: "Forced rather than decided per surface; can drop frames on some drivers" },
{ value: 2, label: "Automatic",
detail: "The compositor decides per surface, which is the safe default" }
],
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
},
@@ -191,6 +206,147 @@ Singleton {
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
},
{
key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Focused window opacity",
detail: "Fade even the focused window; 1.0 is fully opaque",
hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
},
{
key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Fullscreen opacity",
detail: "Applied instead of the focused opacity when a window is fullscreen",
hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
},
{
key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
group: "windows",
label: "Corner shape",
detail: "2 is a circular corner; higher values approach a squircle",
hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
},
// ── Window edges ────────────────────────────────────────────────────
// How the pointer interacts with a window's border, and how windows
// behave near each other. All shipped by looks.lua with no way to
// change any of it.
{
key: "resizeOnBorder", type: "bool", def: true, group: "edges",
label: "Resize by dragging the border",
detail: "Drag a window's edge to resize it, instead of only with the keyboard",
hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
},
{
key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
unit: "px",
group: "edges",
label: "Border grab area",
detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
},
{
key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
label: "Show the resize cursor",
detail: "Change the pointer when it is over a resizable border",
hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
},
{
key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
unit: "px",
group: "edges",
label: "Snap distance between windows",
detail: "How close two floating windows must be before they snap together",
hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
},
{
key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
unit: "px",
group: "edges",
label: "Snap distance to screen edges",
detail: "How close a floating window must be to an edge before it snaps to it",
hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
},
{
key: "snapRespectGaps", type: "bool", def: false, group: "edges",
label: "Snapping respects gaps",
detail: "Snapped windows keep the configured gap instead of touching",
hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
},
// ── Master layout ───────────────────────────────────────────────────
// Only meaningful when the tiling layout is Master and stack. Offering
// that layout with none of its options was an omission: it is the one
// layout whose whole behaviour is in these settings.
{
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
group: "master",
label: "Master area size",
detail: "How much of the screen the master window takes",
hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
},
{
key: "masterOrientation", type: "enum", def: "left", group: "master",
label: "Master area position",
detail: "Which side of the screen the master window occupies",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
{ value: "top", label: "Top" },
{ value: "bottom", label: "Bottom" },
{ value: "center", label: "Centre" }
],
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
},
{
key: "masterNewStatus", type: "enum", def: "slave", group: "master",
label: "New windows become",
detail: "Whether a new window takes the master area or joins the stack",
options: [
{ value: "master", label: "The master window" },
{ value: "slave", label: "Part of the stack" },
{ value: "inherit", label: "Whatever the focused window is" }
],
hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
},
{
key: "masterNewOnTop", type: "bool", def: false, group: "master",
label: "Add new windows at the top",
detail: "New stack windows go above the others rather than below",
hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
},
// ── Hyprland's own notices ──────────────────────────────────────────
// Panama turns all four off on the user's behalf. That is a defensible
// default and was not a decision anyone could reverse without editing
// looks.lua, which is precisely the kind of thing this app exists to
// stop.
{
key: "hyprlandLogo", type: "bool", def: false, group: "notices",
label: "Hyprland wallpaper",
detail: "The stock background Hyprland draws when no wallpaper is set",
hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
},
{
key: "hyprlandSplash", type: "bool", def: false, group: "notices",
label: "Splash text",
detail: "The line of text Hyprland renders over the stock background",
hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
},
{
key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
label: "Update announcements",
detail: "The window Hyprland opens after an update to describe what changed",
hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
},
{
key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
label: "Donation reminders",
detail: "The prompt Hyprland shows twice a year asking for support",
hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
},
// ── Effects ─────────────────────────────────────────────────────────
{
key: "blurEnabled", type: "bool", def: true, group: "effects",
@@ -226,6 +382,19 @@ Singleton {
detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
},
{
key: "shadowSharp", type: "bool", def: false, group: "effects",
label: "Hard-edged shadow",
detail: "A crisp shadow instead of a soft falloff",
hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
},
{
key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
group: "effects",
label: "Shadow falloff",
detail: "How sharply the shadow fades out. Higher is tighter to the window",
hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
},
{
key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow",
@@ -257,6 +426,24 @@ Singleton {
detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
},
{
key: "keyboardVariant", type: "string", def: "", group: "input",
// Same shape as the layout list and for the same reason: this is
// serialised into an hl.config string.
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
label: "Layout variant",
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
},
{
key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
// XKB option names are colon-separated pairs in a comma-separated
// list, e.g. "compose:ralt,caps:escape".
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
label: "Keyboard options",
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
},
{
key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login",
@@ -280,19 +467,35 @@ Singleton {
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
},
{
key: "followMouse", type: "enum", def: 1, group: "input",
label: "Focus follows pointer",
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
key: "followMouse", type: "enum", def: 1, group: "pointer",
label: "Pointer focus",
detail: "What moving the pointer does to which window is focused",
// These labels were wrong, and wrong in the worst way: value 1 was
// shown as "Click to focus" while Hyprland's 1 means the opposite.
// The compositor publishes the authoritative mapping itself --
// `hyprctl descriptions` gives
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
// -- so a desktop labelled "Click to focus" was in fact following
// the pointer, and the way to actually get click-to-focus was to
// choose "Never". Value 3 was missing entirely.
//
// enum-hypr-map-contract now pins every mapped enum against that
// published map, so this cannot drift again.
options: [
{ value: 0, label: "Never" },
{ value: 1, label: "Click to focus" },
{ value: 2, label: "Sloppy focus" }
{ value: 0, label: "Click to focus",
detail: "Moving the pointer never changes focus" },
{ value: 1, label: "Focus follows pointer",
detail: "The window under the pointer takes focus as you move" },
{ value: 2, label: "Pointer detached",
detail: "The pointer highlights windows on its own; clicking moves keyboard focus" },
{ value: 3, label: "Pointer fully separate",
detail: "Clicking does not move keyboard focus at all" }
],
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
},
{
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input",
group: "pointer",
label: "Pointer speed",
detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
@@ -300,7 +503,7 @@ Singleton {
{
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s",
group: "input",
group: "pointer",
label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
// Reported as a float even though it is only ever set to whole
@@ -310,6 +513,209 @@ Singleton {
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
},
// ── Pointer ─────────────────────────────────────────────────────────
//
// Every `readAs` below was read back off the running compositor rather
// than guessed. Two are 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.
{
key: "naturalScroll", type: "bool", def: false, group: "pointer",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers, as on a phone",
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
},
{
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
label: "Acceleration",
detail: "Flat moves the pointer the same distance however fast you move",
options: [
{ value: "flat", label: "Flat" },
{ value: "adaptive", label: "Adaptive" }
],
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
},
{
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "pointer",
label: "Scroll speed",
detail: "Multiplies how far one notch of the wheel scrolls",
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
},
{
key: "leftHanded", type: "bool", def: false, group: "pointer",
label: "Left-handed",
detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
},
{
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
label: "Middle-click paste",
detail: "Paste the primary selection in GTK and native Wayland applications",
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
},
// ── Touchpad ────────────────────────────────────────────────────────
//
// Shown only on machines that have one. These are separate from the
// pointer settings above because libinput keeps them separate: a mouse
// and a touchpad on the same machine can scroll in opposite directions,
// and usually should.
{
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
label: "Tap to click",
detail: "A tap counts as a click without pressing down",
// The Lua config key and the hyprctl option name genuinely differ
// here: hl.config wants input.touchpad.tap_to_click, getoption
// answers to input:touchpad:tap-to-click. Using either spelling for
// both fails -- a hyphen is not a Lua identifier, and the
// underscored name is not a known option to getoption. This is what
// the two separate fields are for.
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
},
{
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers",
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
},
{
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
label: "Disable while typing",
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
},
{
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "touchpad",
label: "Scroll speed",
detail: "Multiplies how far a two-finger scroll travels",
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
},
{
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
label: "Drag lock",
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
// An int with three states rather than a switch, which is why this
// is an enum: reported as `int` by getoption, not `bool`.
options: [
{ value: 0, label: "Off" },
{ value: 1, label: "On" },
{ value: 2, label: "On, until you tap again" }
],
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
},
{
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
label: "Middle-click by pressing both buttons",
detail: "Pressing left and right together acts as a middle click",
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
// ── Multitasking ────────────────────────────────────────────────────
//
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
// described the first two of these as read-only facts ("Layout:
// Tiling"), which was never true -- they are ordinary settings that
// happened not to have controls.
//
// Defaults here are Panama's shipped values from hypr/looks.lua, not
// Hyprland's own, so restoring defaults returns the desktop to how it
// ships rather than to how Hyprland would behave with no config.
{
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
label: "Tiling layout",
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
options: [
{ value: "dwindle", label: "Dwindle" },
{ value: "master", label: "Master and stack" }
],
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
},
{
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
label: "Keep split direction",
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
},
{
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
label: "New windows open",
detail: "Where a new window lands relative to the one that was focused",
options: [
{ value: 0, label: "Where the pointer is" },
{ value: 1, label: "Always left or above" },
{ value: 2, label: "Always right or below" }
],
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
},
{
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
label: "Snap floating windows",
detail: "Floating windows stick to screen edges and to each other as you drag them",
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
},
{
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
label: "Switch back and forth",
detail: "Selecting the workspace you are already on returns you to the previous one",
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
},
{
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
label: "Wrap around at the ends",
detail: "Moving past the last workspace continues from the first",
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
},
{
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
label: "Let applications take focus",
detail: "An application asking for attention is switched to, rather than only highlighted",
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
},
{
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
label: "Pointer changes active display",
detail: "Moving the pointer to another display makes it the active one",
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
},
// ── Accessibility ───────────────────────────────────────────────────
//
// Only what Hyprland can actually deliver. GNOME's 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 (verified
// against evdev.lst), and Hyprland does not implement it. The
// compositor will happily STORE "accessx:enable" as a keyboard option
// and nothing will ever act on it, which is exactly the kind of switch
// this app refuses to ship.
{
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
group: "accessibility",
label: "Magnifier",
detail: "Magnifies the screen around the pointer. 1.0 is off",
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
},
{
key: "magnifierRigid", type: "bool", def: false, group: "accessibility",
label: "Magnifier follows in steps",
detail: "Moves the magnified view in increments rather than gliding with the pointer",
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
},
{
key: "dimInactive", type: "bool", def: false, group: "accessibility",
label: "Dim inactive windows",
detail: "Darkens every window except the focused one, so the active window is unmistakable",
hypr: { path: ["decoration", "dim_inactive"], option: "decoration:dim_inactive", readAs: "bool" }
},
{
key: "dimStrength", type: "real", def: 0.5, min: 0.05, max: 0.9, step: 0.05,
group: "accessibility",
label: "Dim amount",
detail: "How much darker unfocused windows are",
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
},
// ── Night light ─────────────────────────────────────────────────────
{
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
@@ -341,6 +747,66 @@ Singleton {
label: "Wallpaper",
detail: "Shown on every output"
},
{
key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
options: [
{ value: "single", label: "Single" },
{ value: "slideshow", label: "Slideshow" },
{ value: "per-monitor", label: "Per display" }
]
},
{
key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
label: "Slideshow collection", detail: "Backgrounds selected for rotation"
},
{
key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
unit: "min", group: "wallpaper", label: "Change background every",
detail: "Time between slideshow images"
},
{
key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
label: "Shuffle", detail: "Show every selected image before repeating"
},
{
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
},
// ── Lock-screen appearance ─────────────────────────────────────────
// scripts/panama-lock validates these again before generating a state
// config. The tracked hyprlock.conf remains the safe fallback.
{
key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
label: "Background", detail: "What appears behind the lock screen",
options: [
{ value: "screenshot", label: "Blurred desktop" },
{ value: "wallpaper", label: "Current wallpaper" },
{ value: "solid", label: "Solid color" }
]
},
{
key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
group: "lockAppearance", label: "Background blur",
detail: "Softens what is behind the password field"
},
{
key: "lockShowClock", type: "bool", def: true, group: "lockAppearance",
label: "Show clock", detail: "Use the desktop's 12 or 24-hour format"
},
{
key: "lockShowDate", type: "bool", def: true, group: "lockAppearance",
label: "Show date", detail: "Show the weekday and full date"
},
{
key: "lockShowUser", type: "bool", def: true, group: "lockAppearance",
label: "Show user name", detail: "Identify the signed-in account"
},
{
key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance",
label: "Hide password field until typing", detail: "Keep the empty field out of the way"
},
// ── Idle, lock, and sleep ───────────────────────────────────────────
// Written into a generated hypridle config; see scripts/panama-idle.
@@ -369,6 +835,176 @@ Singleton {
detail: "Requires your password when the machine wakes"
},
// ── Night light schedule ────────────────────────────────────────────
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
// is normal here and is what the shipped values do: on at 17:00, off at
// 10:00 the following morning.
{
key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
group: "nightLight",
label: "Turns on at",
detail: "Only used when Night Light follows a schedule"
},
{
key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
group: "nightLight",
label: "Turns off at",
detail: "A time earlier than the start simply means the next morning"
},
// ── Colour scheme ───────────────────────────────────────────────────
// Light is Tokyo Night Day, the official light variant, rather than a
// palette invented to merely not be dark. Both share the same hues at
// different lightness, which is what keeps the Prism identity intact
// across the switch.
//
// services/ColorScheme.qml pushes the choice to GTK and to the
// compositor's border colours, because an application toolbar or a
// window border still wearing the other scheme is more jarring than
// either scheme on its own.
{
key: "colorScheme", type: "enum", def: "dark", group: "appearance",
label: "Appearance",
detail: "Light and dark share one identity, not two themes",
options: [
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" }
]
},
// ── Application themes ─────────────────────────────────────────────
// ColorScheme owns GTK's light/dark theme. These are the two theme
// choices GNOME applications expose independently of that palette:
// their icons and pointer. DesktopStyle only accepts names found in
// the read-only XDG catalog before storing them.
{
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Pointer theme",
detail: "The pointer design used by applications and Hyprland"
},
{
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Application icons",
detail: "The icon set used by GTK applications"
},
// ── Typography ──────────────────────────────────────────────────────
// The single largest thing in this desktop that used to be changeable
// only by editing Theme.qml.
//
// interfaceFont is every piece of text a person reads. iconFont is used
// ONLY to draw glyphs -- workspace pills, the status cluster, search
// icons -- so it must carry Nerd Font glyphs; a plain monospace family
// there replaces every icon in the shell with tofu, which is why the
// picker offers them as a separate, filtered list.
{
key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography",
// A family name, matched by fontconfig. Constrained because it also
// reaches hl.config as a string in hypr/looks.lua.
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
label: "Interface font",
detail: "Used for every piece of text in the shell"
},
{
key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
label: "Icon font",
detail: "Draws the shell's glyphs, so it must be a Nerd Font"
},
{
key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1,
unit: "px", group: "typography",
label: "Interface text size",
detail: "The base size the rest of the shell's type scales from"
},
{
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Application font",
detail: "Used by menus, controls, and labels in applications"
},
{
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Application text size",
detail: "The base text size used by applications"
},
{
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Document font",
detail: "Used for document content when an application follows the system choice"
},
{
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Document text size",
detail: "The default text size for document content"
},
{
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Monospace font",
detail: "Used by terminals, editors, and code fields that follow the system choice"
},
{
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Monospace text size",
detail: "The default text size for terminals and code"
},
{
key: "fontHinting", type: "enum", def: "slight", group: "typography",
label: "Font hinting",
detail: "How strongly text aligns to the pixel grid",
options: [
{ value: "none", label: "None" },
{ value: "slight", label: "Slight" },
{ value: "medium", label: "Medium" },
{ value: "full", label: "Full" }
]
},
{
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
label: "Text smoothing",
detail: "How application text softens its edges",
options: [
{ value: "none", label: "None" },
{ value: "grayscale", label: "Grayscale" },
{ value: "rgba", label: "Subpixel" }
]
},
// ── Application titlebars ──────────────────────────────────────────
// These affect applications that honour GNOME's window preferences.
// Hyprland itself has no server-side titlebar buttons, so minimize is
// deliberately absent rather than presented as a switch that lies.
{
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
label: "Button side",
detail: "Place application titlebar buttons on the left or right",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" }
]
},
{
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
label: "Maximize button",
detail: "Show a maximize button in application titlebars that support it"
},
{
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
label: "Double-click titlebar",
detail: "Choose what a double-click on an application titlebar does",
options: [
{ value: "toggle-maximize", label: "Toggle maximize" },
{ value: "none", label: "Do nothing" }
]
},
// ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and
// pushed to the compositor as well where it has its own notion.
@@ -402,6 +1038,47 @@ Singleton {
detail: "How often Panama updates the current conditions"
},
// ── Which GPU the vitals readout tracks ─────────────────────────────
// A sysfs path rather than a card number, because the number is neither
// stable across machines nor meaningful. Constrained to the one shape
// that can be read for utilisation; VitalsWidget hides itself when the
// path is unreadable, so a stale value degrades to no readout rather
// than a wrong one.
{
key: "gpuBusyPath", type: "string",
def: "/sys/class/drm/card1/device/gpu_busy_percent",
group: "vitals", internal: true,
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
label: "Graphics device",
detail: "Which GPU the graphics readout in the bar measures"
},
// ── Weather location ────────────────────────────────────────────────
// Coordinates rather than a place name, because that is what Open-Meteo
// takes and it needs no API key. weatherLocation is only the label shown
// in the UI; it is never sent anywhere, so it can say whatever makes the
// reading recognisable.
{
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
group: "weather", internal: true,
label: "Latitude",
detail: "Set by choosing a location"
},
{
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
group: "weather", internal: true,
label: "Longitude",
detail: "Set by choosing a location"
},
{
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
internal: true,
// Display only -- never sent to the weather service.
pattern: "^[^\\n]{1,64}$",
label: "Weather location",
detail: "The place the weather reading is for"
},
// ── Vitals refresh ──────────────────────────────────────────────────
{
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
@@ -476,13 +1153,18 @@ Singleton {
// A "json" value: the ordered list of desktop entry ids pinned to the
// dock. Kept in the shared store so that reordering the dock is covered
// by Restore defaults like everything else, rather than living in its
// own file. The shipped order is the GNOME dash it replaced.
// own file. The shipped order is the GNOME dash it replaced, with one
// deliberate substitution: Panama Settings takes the first slot rather
// than GNOME Settings. Panama now covers what GNOME Settings did for
// this desktop and delegates the remainder to it by name, so pinning
// the thing it delegates TO put the fallback in front of the real one.
// GNOME Settings stays installed and searchable in the launcher.
{
key: "dockPinned", type: "json", group: "dock",
label: "Pinned applications",
detail: "Applications that stay in the Dock whether or not they are running",
def: [
"org.gnome.Settings", "kitty", "org.gnome.Nautilus",
"panama-settings", "kitty", "org.gnome.Nautilus",
"com.bitwarden.desktop", "org.gnome.Software", "helium",
"org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk",
@@ -508,6 +1190,34 @@ Singleton {
detail: "Shortcuts you have moved from their shipped chord"
},
// ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
// hypr/monitors.lua on top of the shipped values. Colour management and
// bit depth are deliberately not here: those carry a documented
// screencopy tradeoff that a settings page cannot explain at the moment
// you would be changing it.
{
key: "displays", type: "json", def: ({}), group: "display",
internal: true,
label: "Display configuration",
detail: "Resolution, scale, rotation, position, and primary display"
},
// ── Per-application notification rules ──────────────────────────────
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
//
// Absent means "no rule", which is not the same as a rule that allows
// everything: a new application must be able to notify without needing
// an entry written for it first. services/Notifs.qml treats a missing
// entry as permissive and Do Not Disturb remains an override on top,
// rather than being duplicated per application.
{
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
internal: true,
label: "Application notification rules",
detail: "Per-application notification and lock-screen visibility preferences"
},
// ── Internal ────────────────────────────────────────────────────────
{
key: "lastPage", type: "string", def: "home", group: "internal",
+6 -6
View File
@@ -22,12 +22,12 @@ Singleton {
// ── 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 weatherLocation: DesktopPreferences.get("weatherLocation")
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
@@ -41,13 +41,13 @@ Singleton {
// 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.get("nightLightTemperature")
readonly property real nightLightFrom: 17.0
readonly property real nightLightTo: 10.0
readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ───────────────────────────────────────────────────────
+49 -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
@@ -90,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
@@ -107,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,
+1
View File
@@ -4,3 +4,4 @@ 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,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 => {
@@ -24,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 {
@@ -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)
}
}
}
}
@@ -4,11 +4,20 @@ import qs.config
import qs.services
SettingsPage {
title: "About Panama"
id: root
title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
Component.onCompleted: {
if (!MachineInfo.scanned)
MachineInfo.refresh();
if (GraphicsDevices.devices.length === 0)
GraphicsDevices.refresh();
}
SettingsCard {
title: "Panama Desktop"
title: "Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
TextRow {
@@ -31,6 +40,59 @@ SettingsPage {
}
}
// 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
}
}
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"
@@ -25,32 +25,61 @@ SettingsPage {
SettingsCard {
title: "Text"
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected."
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: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
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: "Contrast"
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read."
title: "Magnifier"
subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
SliderRow { setting: "inactiveOpacity"; divider: false }
SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
ToggleRow { setting: "magnifierRigid"; divider: false }
}
SettingsCard {
title: "System accessibility"
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack."
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: "Opens in GNOME Settings"
detail: "For the parts GNOME's own stack still owns"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("universal-access")
@@ -17,8 +17,10 @@ import qs.services
SettingsPage {
id: root
property string expandedPicker: ""
title: "Appearance"
lede: "Drag anything below. The preview above is your real geometry, to scale."
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
header: Component {
Column {
@@ -46,10 +48,26 @@ SettingsPage {
title: "Background"
subtitle: Wallpaper.lastError !== ""
? Wallpaper.lastError
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
: "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 {
@@ -64,6 +82,205 @@ SettingsPage {
}
}
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."
@@ -72,7 +289,10 @@ SettingsPage {
SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" }
SliderRow { setting: "inactiveOpacity"; divider: false }
SliderRow { setting: "roundingPower" }
SliderRow { setting: "inactiveOpacity" }
SliderRow { setting: "activeOpacity" }
SliderRow { setting: "fullscreenOpacity"; divider: false }
}
SettingsCard {
@@ -84,6 +304,8 @@ SettingsPage {
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 }
@@ -103,14 +325,27 @@ SettingsPage {
ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: false }
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)
}
}
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
}
}
@@ -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
}
}
@@ -12,6 +12,7 @@ SettingsPage {
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"] },
@@ -142,9 +143,64 @@ SettingsPage {
}
}
// 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: "These desktop entries live in your user configuration. Select a row to toggle it."
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
@@ -175,7 +231,7 @@ SettingsPage {
SettingsCard {
title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
subtitle: "These are started from the Hyprland configuration. They are read-only here."
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
@@ -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,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)
}
}
@@ -1,92 +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
SettingsPage {
id: root
title: "Network & Devices"
lede: "Connect graphically—no terminal workflow required."
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
SettingsCard {
title: "WiFi"
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
title: "Wired"
visible: Connectivity.wiredDevice !== null
TextRow {
label: "Ethernet"
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
divider: false
}
}
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."
SettingRow {
label: "WiFi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
label: "Wi-Fi"
detail: Connectivity.wifiEnabled ? "On" : "Off"
controlWidth: 48
divider: Connectivity.wifiEnabled
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
checked: Connectivity.wifiEnabled
enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value
}
}
WifiList {
WifiPanel {
width: parent.width
device: root.wifiDevice
active: true
maxHeight: 240
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)
}
}
ActionRow {
label: "Advanced network settings"
detail: "VPN, wired profiles, DNS, and connection details"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("network")
// 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"
visible: Connectivity.adapter !== null
subtitle: "Discovery runs while this page is open."
SettingRow {
label: "Bluetooth"
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
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: root.bluetoothAdapter?.enabled ?? false
enabled: root.bluetoothAdapter !== null
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
enabled: Connectivity.adapter !== null
onToggled: value => {
if (root.bluetoothAdapter)
root.bluetoothAdapter.enabled = value;
if (Connectivity.adapter)
Connectivity.adapter.enabled = value;
}
}
}
BluetoothList {
BluetoothPanel {
width: parent.width
active: true
maxHeight: 220
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: "Advanced Bluetooth settings"
detail: "Device details and system-level options"
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
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("bluetooth")
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
}
@@ -47,18 +47,16 @@ SettingsPage {
SettingsCard {
title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
TextRow {
label: "Layout"
detail: "Dwindle with preserved split direction"
value: "Tiling"
}
TextRow {
label: "Workspace movement"
detail: "Alt+H / Alt+L, add Shift to move a window"
value: "Dynamic"
}
// 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"
@@ -68,6 +66,56 @@ SettingsPage {
}
}
// 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"
@@ -111,10 +159,10 @@ SettingsPage {
SettingsCard {
title: "Reset"
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
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 Panama defaults"
label: "Restore defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
@@ -50,10 +50,13 @@ Rectangle {
// 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: "#2b3050" }
GradientStop { position: 1.0; color: "#241f33" }
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.
@@ -86,9 +89,12 @@ Rectangle {
}
}
// 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: "Panama"
text: Qt.formatTime(new Date(), "h:mm")
color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily
font.pixelSize: 9
@@ -203,7 +209,7 @@ Rectangle {
shadowEnabled: true
shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha("#15161e", 0.85)
: 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)
@@ -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,81 +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
SettingsPage {
id: root
title: "Displays"
lede: SystemSettings.monitorDescription || "Reading the active display…"
SettingsCard {
title: SystemSettings.monitorName || "Active display"
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
TextRow { 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 }
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
: ""
function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
}
// 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(); }
}
// 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
}
}
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 {
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) }
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
}
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) }
}
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
}
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) }
}
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"
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
}
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))
}
}
}
}
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
}
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,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()
}
}
}
}
@@ -9,93 +9,61 @@ Rectangle {
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)
readonly property bool dragging: dragHandler.active
implicitHeight: 108
radius: Theme.cardRadius
color: root.dragging
? Theme.mix(Theme.bgDark, Theme.accent, 0.09)
: Theme.alpha(Theme.bgDark, 0.7)
border.width: root.dragging ? 2 : 1
border.color: root.dragging
? Theme.alpha(Theme.accent, 0.82)
: Theme.alpha(Theme.fg, 0.07)
z: root.dragging ? 10 : 0
transform: Translate {
x: root.dragging ? dragHandler.translation.x : 0
y: root.dragging ? dragHandler.translation.y : 0
}
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: root.dragging ? 0.82 : 0.2
opacity: 0.2
}
Rectangle {
id: dragHandle
Column {
id: reorderControls
anchors.left: parent.left
anchors.leftMargin: 11
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 42
radius: 9
activeFocusOnTab: true
color: root.dragging || activeFocus
? Theme.alpha(Theme.accent, 0.14)
: (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045))
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06)
spacing: 4
Text {
anchors.centerIn: parent
text: "⠿"
color: root.dragging ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 16
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)
}
MouseArea {
id: handleMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.SizeAllCursor
}
DragHandler {
id: dragHandler
target: null
onActiveChanged: {
if (!active)
root.commitDrag();
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
root.moveRequested(root.favorite.id, Math.max(0, root.index - 1));
event.accepted = true;
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
const grid = root.GridView.view;
const lastIndex = grid ? grid.count - 1 : root.index;
root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1));
event.accepted = true;
}
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: dragHandle.right
anchors.left: reorderControls.right
anchors.leftMargin: 10
anchors.right: removeButton.left
anchors.rightMargin: 12
@@ -188,16 +156,4 @@ Rectangle {
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
}
function commitDrag(): void {
const grid = root.GridView.view;
if (!grid || grid.count <= 0)
return;
const centerX = root.x + dragHandler.translation.x + root.width / 2;
const centerY = root.y + dragHandler.translation.y + root.height / 2;
const modelCount = grid.count;
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column));
}
}
@@ -9,7 +9,7 @@ SettingsPage {
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
title: `${root.greeting}, Gabriel`
lede: "Your Panama desktop is configured and ready."
lede: "Your desktop is configured and ready."
SettingsCard {
title: SystemSettings.monitorDescription || "Active display"
@@ -103,16 +103,20 @@ SettingsPage {
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 }
}
SettingsCard {
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid {
id: summaryCards
@@ -43,13 +43,203 @@ SettingsPage {
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: "Panama reads light state through the Home Assistant helper."
detail: "Light state is read through the Home Assistant helper."
divider: false
controlWidth: 176
@@ -122,6 +312,8 @@ SettingsPage {
})
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)
@@ -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
}
}
@@ -0,0 +1,62 @@
// Mouse & Touchpad.
//
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
// where it was before. These all reach Hyprland's input section, which until now
// could only be changed by editing hypr/input.lua by hand.
//
// The touchpad card renders only when a touchpad is attached. On a desktop it
// would be worse than useless -- every switch on it appears to work, because the
// preference is stored and the compositor accepts an option for a device class
// with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Mouse & Touchpad"
lede: InputDevices.hasTouchpad
? "Pointer behaviour for your mouse and touchpad."
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
SettingsCard {
title: "Mouse"
subtitle: "Applied to every pointing device that is not a touchpad."
SliderRow { setting: "pointerSensitivity" }
ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "leftHanded" }
ToggleRow {
setting: "middleClickPaste"
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
divider: false
}
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Touchpad"
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
ToggleRow { setting: "touchpadTapToClick" }
ToggleRow { setting: "touchpadNaturalScroll" }
ToggleRow { setting: "touchpadDisableWhileTyping" }
SliderRow { setting: "touchpadScrollFactor" }
ChoiceRow { setting: "touchpadDragLock" }
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
SliderRow { setting: "cursorSize"; divider: false }
}
}
@@ -24,7 +24,7 @@ SettingsPage {
TextRow {
label: "Notification history"
detail: "Live notifications retained by Panama"
detail: "Live notifications retained by the shell"
value: `${Notifs.history.length} items`
}
@@ -50,10 +50,89 @@ SettingsPage {
SliderRow { setting: "maxVisibleToasts"; divider: false }
}
SettingsCard {
title: "Application rules"
subtitle: "Apps appear here after they send a notification."
TextRow {
visible: Notifs.applications.length === 0
label: "No applications remembered yet"
detail: "Application controls will appear after the first notification arrives."
divider: false
}
Repeater {
model: Notifs.applications
Column {
required property var modelData
readonly property var app: modelData
width: parent.width
SettingRow {
label: app.name
detail: app.id
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).enabled
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
}
}
SettingRow {
label: "Show on lock screen"
detail: "Allow this app's notifications on the lock screen"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).showOnLockScreen
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
}
}
SettingRow {
label: "Show content on lock screen"
detail: "Show message details when this app is visible there"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).showContentOnLockScreen
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
}
}
}
}
}
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
label: "Keep the screen awake"
detail: Caffeine.enabled
? "The display will not blank or lock while this is on"
: "Idle timings on Power & Lock apply normally"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Caffeine.enabled
onToggled: value => Caffeine.enabled = value
}
}
SettingRow {
id: durationRow
@@ -0,0 +1,122 @@
// Online Accounts.
//
// GNOME's panel of the same name, except for one step. The accounts are GNOME
// Online Accounts objects on D-Bus, the daemon already runs here, and listing,
// per-service toggles and removal are all done natively on this page.
//
// Signing in to an OAuth provider is handed to GNOME's panel, because the
// credentials are OAuth tokens and the code that obtains them ships without a
// scriptable binding. That is stated on the page rather than hidden behind a
// button that looks native, because a hand-off the user does not expect reads
// as a bug.
//
// Accounts needing attention are surfaced first. GOA knows when a token has
// expired and nothing outside its own panel says so, which is how an account
// quietly stops syncing for weeks.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Online Accounts"
lede: OnlineAccounts.attentionCount > 0
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
? " account needs you to sign in again."
: " accounts need you to sign in again.")
: "Accounts your mail, calendar, contacts, and files come from."
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh()
SettingsCard {
visible: !OnlineAccounts.available
title: "Accounts unavailable"
subtitle: OnlineAccounts.lastError
}
SettingsCard {
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
title: "No accounts yet"
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
ActionRow {
label: "Add an account"
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
action: "Add account"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
Repeater {
model: OnlineAccounts.accounts
SettingsCard {
id: accountCard
required property var modelData
title: accountCard.modelData.identity || accountCard.modelData.providerName
subtitle: accountCard.modelData.needsAttention
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
: accountCard.modelData.providerName
// Only when it is true, because it is the one thing on this page
// that needs acting on.
ActionRow {
visible: accountCard.modelData.needsAttention
label: "Sign in again"
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
action: "Sign in"
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
Repeater {
model: accountCard.modelData.services
SettingRow {
id: serviceRow
required property var modelData
required property int index
label: serviceRow.modelData.label
detail: serviceRow.modelData.enabled
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
: "Hidden from applications"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: serviceRow.modelData.enabled
onToggled: value => OnlineAccounts.setService(
accountCard.modelData.path, serviceRow.modelData.key, value)
}
}
}
ActionRow {
label: "Remove this account"
detail: "Signs out and removes it from every application that was using it"
action: "Remove"
divider: false
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
}
}
}
SettingsCard {
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
title: "Add another account"
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
ActionRow {
label: "Add an account"
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
action: "Add account"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
}
@@ -0,0 +1,106 @@
// A password entry with a reveal toggle.
//
// Deliberately not the clipboard popover's SearchField with different 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. Masked by
// default, revealable while held, because the reason people want to see it is
// to check a character they just typed.
import QtQuick
import qs.config
Rectangle {
id: root
property alias text: input.text
property string placeholder: "Password"
property bool revealed: false
signal accepted
implicitHeight: 32
radius: Theme.pillRadius
color: Theme.alpha(Theme.fg, 0.07)
// Not left at 0 so the focus ring has something to animate. See the note in
// modules/clipboard/SearchField.qml.
border.width: 1
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
Behavior on border.color {
ColorAnimation { duration: Theme.durFast }
}
function grab(): void {
input.forceActiveFocus();
}
function clear(): void {
input.text = "";
root.revealed = false;
}
TextInput {
id: input
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: revealButton.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
echoMode: root.revealed ? TextInput.Normal : TextInput.Password
passwordCharacter: "•"
clip: true
onAccepted: root.accepted()
Text {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
visible: input.text === ""
text: root.placeholder
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
}
Rectangle {
id: revealButton
anchors.right: parent.right
anchors.rightMargin: 5
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 24
radius: 7
color: revealHover.hovered ? Theme.alpha(Theme.fg, 0.12) : "transparent"
border.width: 0
visible: input.text !== ""
Text {
anchors.centerIn: parent
// Nerd Font eye / eye-slash. fontMono is used for icon glyphs only.
text: root.revealed ? "\u{F070}" : "\u{F06E}"
font.family: Theme.fontMono
font.pixelSize: 12
color: root.revealed ? Theme.accent : Theme.fgMuted
}
HoverHandler {
id: revealHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.revealed = !root.revealed
}
}
}
@@ -14,14 +14,48 @@ import qs.services
SettingsPage {
id: root
// Probing the power daemon is a D-Bus round trip, so it happens when the
// page opens rather than at shell startup.
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
title: "Power & Lock"
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
// The same profiles GNOME's Power panel offers. Not a stored preference --
// the daemon owns it, it survives Panama restarts, and anything else on the
// system can change it, so a copy here would drift.
SettingsCard {
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
title: "Power profile"
subtitle: PowerProfiles.degraded !== ""
? "Performance is limited right now: " + PowerProfiles.degraded
: (PowerProfiles.available
? "Applies to the whole system and persists across sessions."
: PowerProfiles.lastError)
Repeater {
model: PowerProfiles.profiles
SettingRow {
id: profileRow
required property var modelData
required property int index
label: PowerProfiles.label(profileRow.modelData)
detail: PowerProfiles.detail(profileRow.modelData)
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
divider: profileRow.index < PowerProfiles.profiles.length - 1
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
onActivated: PowerProfiles.set(profileRow.modelData)
}
}
}
SettingsCard {
title: "Idle behaviour"
subtitle: IdleLock.managed
? "Panama is managing hypridle. Changes take effect immediately."
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable."
? "Idle timings are managed here. Changes take effect immediately."
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
@@ -41,10 +75,10 @@ SettingsPage {
SettingsCard {
title: "Management"
subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place."
subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
SettingRow {
label: "Let Panama manage idle timings"
label: "Manage idle timings here"
detail: IdleLock.serviceState === "active"
? "hypridle is running"
: "hypridle is " + IdleLock.serviceState
@@ -0,0 +1,166 @@
// Privacy & Security.
//
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
// history, trash, and device security. Panama covers the parts it genuinely
// owns and is explicit about the parts it does not.
//
// The file-history and trash settings are the notable omission, and the reason
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
// which is not running in a Hyprland session. Offering switches for them would
// store a preference, change nothing, and give no sign of it -- the exact
// failure this codebase keeps designing against. So they are delegated by name
// rather than reimplemented as controls that lie.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Privacy & Security"
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
? "Screen lock, device access, and a machine whose security settings all check out."
: "Screen lock, which applications can see you, and how this machine is protected."
Component.onCompleted: {
if (!DeviceSecurity.scanned)
DeviceSecurity.refresh();
if (!Keyring.scanned)
Keyring.refresh();
}
SettingsCard {
title: "Screen lock"
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
}
// The login keyring, which nothing else surfaces.
//
// It is unlocked at sign-in by PAM, so this card normally just confirms
// that. It earns its place on the rare occasion it is not: a locked keyring
// breaks saved passwords everywhere at once, and does it without ever
// saying the word "keyring" -- you get a mail account that will not
// authenticate and a git push that cannot find its key.
SettingsCard {
visible: Keyring.scanned
title: "Saved passwords"
subtitle: !Keyring.available
? "No secret service is answering, so saved passwords are unavailable."
: Keyring.locked
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
: "The login keyring is unlocked, as it is after every normal sign-in."
// Two rows rather than one with a conditional button: a locked keyring
// needs an action, an unlocked one is a statement of fact, and ActionRow
// and TextRow already say exactly those two things.
ActionRow {
visible: Keyring.available && Keyring.locked
label: "Login keyring"
detail: "Unlock to restore access to stored passwords and keys"
action: Keyring.unlocking ? "Waiting…" : "Unlock"
enabled: !Keyring.unlocking
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
onTriggered: Keyring.unlock()
}
TextRow {
visible: !(Keyring.available && Keyring.locked)
label: "Login keyring"
detail: Keyring.available
? "Unlocked at sign-in by PAM, the same way GNOME does it"
: "No secret service is answering on this session"
value: Keyring.available ? "Unlocked" : "Unavailable"
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
}
// Only shown when it is true, because it is a diagnostic rather than a
// setting: it means the daemon holding your secrets is not the one PAM
// started, so whatever unlocked it will not survive a restart.
SettingRow {
visible: Keyring.replacementDaemon
label: "Keyring service"
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
value: "Replaced"
divider: Keyring.lastError !== ""
}
SettingRow {
visible: Keyring.lastError !== ""
label: "Keyring problem"
detail: Keyring.lastError
divider: false
}
}
SettingsCard {
title: "Camera & microphone"
subtitle: PrivacyState.anyActive
? "In use right now — the bar shows an indicator whenever this is true."
: "Nothing is using your camera or microphone."
TextRow {
label: "Camera"
detail: PrivacyState.cameraActive
? "In use by " + (PrivacyState.cameraApp || "an application")
: "Not in use"
value: PrivacyState.cameraActive ? "Active" : "Idle"
}
TextRow {
label: "Microphone"
detail: PrivacyState.microphoneActive
? "In use by " + (PrivacyState.microphoneApp || "an application")
: "Not in use"
value: PrivacyState.microphoneActive ? "Active" : "Idle"
}
TextRow {
label: "Screen sharing"
detail: PrivacyState.screenSharingActive
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
: "Not being shared"
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
divider: false
}
}
SettingsCard {
title: "Device security"
subtitle: DeviceSecurity.attentionCount === 0
? "Everything below is in its recommended state."
: DeviceSecurity.attentionCount + " item"
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
Repeater {
model: DeviceSecurity.facts
TextRow {
id: factRow
required property var modelData
required property int index
label: factRow.modelData.label
detail: factRow.modelData.detail
value: factRow.modelData.value
divider: factRow.index < DeviceSecurity.facts.length - 1
}
}
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
ActionRow {
label: "File history & trash"
detail: "Opens GNOME Settings, which owns these"
action: "Open privacy"
divider: false
onTriggered: SystemSettings.openGnomePanel("privacy")
}
}
}
@@ -1,9 +1,36 @@
# Panama Settings
# Settings
The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
such rather than half-reimplemented.
## System Health
The stable internal `services` route renders **System Health**. It is reachable
from the Settings sidebar and its live 54px footer, the degraded-only bar
indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans
reserve no bar space and produce no notification.
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
explicit **Copy Report**, and bounded `notify-send` only when an external repair
fails. For a concise terminal view, run:
```bash
~/.config/quickshell/scripts/panama-doctor --summary
```
The helper diagnoses Panama-owned desktop services, dependencies, links, and
configured integrations. It does not read secret values, clipboard or
notification contents, calendar events, SSIDs, addresses, or arbitrary command
output. Its repair interface is an authored allow-list: it never installs a
package, runs `sudo`, deletes user data, or repairs a service Panama does not
own. A repair remains degraded until a fresh scan observes recovery.
The final card is the ownership boundary. Network configuration and the exact
Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME
Settings because Fedora's system services own those areas.
## Adding a setting
One schema entry. That is the whole job.
@@ -32,6 +59,47 @@ If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
file.
## Setting ownership
Every preference has **one primary page**, derived from its schema `group` and
the route in `services/SettingsSearch.qml`. Search results always open that
owner. A control may appear on a second page only when the same adaptation is
part of another established mental model; otherwise use a labelled handoff to
the owner instead of duplicating it.
### Intentional mirrors
| Setting | Primary page | Mirror | Why the mirror earns its place |
|---|---|---|---|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
Lock-screen visuals belong only to **Appearance**: background source, blur,
clock, date, user name, and password-field presentation. **Power** owns when
the session locks, while **Privacy** keeps only the established timing mirrors
above. Visual controls must not be copied onto either page.
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
Those values form one safety transaction: every connected output
is applied, verified, confirmed, or restored together. Other pages may link to
Displays, but must never expose a second geometry control or persist a partial
layout.
Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
Window border colour follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
accent role owned by the visual theme (and, eventually, an accent picker).
`ColorScheme.qml` must never write the focused border, so changing schemes
cannot erase a user-selected accent.
## The rows
| Component | For |
@@ -83,11 +151,12 @@ slot**, because that is the row's default property, so only the right-hand edge
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
target.
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives the Shell ID from config *content*, not path, so
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
single distinct `.qml` file are safe; copying the whole directory is not.
**A content-identical Quickshell entry can share the live shell's ID.**
Quickshell derives the Shell ID from config *content*, not path. Runtime
harnesses therefore create a distinct semantic entry file, address that exact
file with `qs -p`, and discover its PID from the exact Config path in
`qs list --all`. They terminate only that recorded PID with `kill`; never use
`qs kill` from a copied configuration.
## Where state lives
@@ -97,6 +166,7 @@ single distinct `.qml` file are safe; copying the whole directory is not.
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
| `$XDG_STATE_HOME/panama/hyprlock.conf` | Generated lock-screen appearance |
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
@@ -0,0 +1,75 @@
// Region & Language.
//
// GNOME keeps language and formats under System; the setting itself is the
// machine's locale, which localectl owns. Panama does not store a copy of it --
// there is exactly one system locale and localectl is where it lives, so a
// preference here would be a second source of truth that drifts the moment
// anything else changes it.
//
// Keyboard layout is deliberately not repeated here even though GNOME groups it
// with region. It is a compositor setting that applies instantly, it lives on
// the Keyboard page with the rest of the typing settings, and showing it twice
// invites the two views to disagree.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Region & Language"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: "The language and regional formats this machine uses."
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
SettingsCard {
title: "Language"
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
TextRow {
label: "Current language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
}
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => SystemLocale.set(value)
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "Language problem"
subtitle: SystemLocale.lastError
}
SettingsCard {
title: "Formats"
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
ActionRow {
label: "Clock and date display"
detail: "How the desktop itself shows the time"
action: "Open appearance"
onTriggered: ShellState.openSettings("appearance")
}
ActionRow {
label: "Regional formats"
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
action: "Open system"
divider: false
onTriggered: SystemSettings.openGnomePanel("system", "region")
}
}
}
@@ -18,7 +18,7 @@ SettingsPage {
SettingsCard {
title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions."
SettingRow {
icon: "󰗊"
@@ -52,7 +52,7 @@ SettingsPage {
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result."
TextRow {
label: "Text recognition"
@@ -0,0 +1,94 @@
// A searchable list of choices, for settings with far too many values to put in
// a dropdown and no useful preview to show.
//
// SearchPicker {
// items: Locale.locales // [{ value, label, detail }]
// current: Locale.current
// placeholder: "Search languages"
// onPicked: value => Locale.set(value)
// }
//
// FontPicker is the same idea specialised: it draws each candidate in its own
// family, which is the whole reason it is a list rather than a text field. This
// one is for choices whose only useful preview is their name.
import QtQuick
import qs.config
// SearchField lives with the clipboard module, which is where it was first
// needed; FontPicker imports it from the same place.
import qs.modules.clipboard
Column {
id: root
// [{ value, label, detail }]
property var items: []
property string current: ""
property string emptyText: "Nothing to choose from"
property string placeholder: "Search"
signal picked(string value)
spacing: 0
// Capped and filtered rather than listing everything: 327 locales 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.items.filter(item =>
needle === ""
? true
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
// The current choice stays visible while browsing, so it is always
// clear what would be replaced.
const selected = list.find(item => item.value === root.current);
if (needle === "" && selected !== undefined)
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: root.placeholder
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData.value === root.current
label: candidate.modelData.label
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
controlWidth: 120
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData.value)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: candidate.selected
text: "✓"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 2
color: Theme.accent
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
@@ -1,133 +0,0 @@
import QtQuick
import qs.config
import qs.services
SettingsPage {
title: "Startup & Services"
lede: "A clear view of the background tools that make the desktop feel complete."
function status(active: bool): string {
return active ? "Running" : "Stopped";
}
Item {
width: parent.width
implicitHeight: refresh.implicitHeight
SettingsButton {
id: refresh
anchors.right: parent.right
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
enabled: !SystemSettings.busy
onClicked: SystemSettings.refresh()
}
}
SettingsCard {
title: "Your services"
SettingRow {
label: "Nextcloud"
detail: "File synchronization and tray status"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.nextcloudActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("nextcloud")
}
}
}
SettingRow {
label: "RustDesk"
detail: "Remote access through the enabled system service"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.rustdeskActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("rustdesk")
}
}
}
SettingRow {
label: "KDE Connect"
detail: "Phone pairing, clipboard, files, and remote controls"
divider: false
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.kdeconnectActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("kdeconnect")
}
}
}
}
SettingsCard {
title: "Desktop foundation"
TextRow {
label: "Hyprpaper"
detail: "Wallpaper service"
value: status(SystemSettings.hyprpaperActive)
}
TextRow {
label: "Hypridle"
detail: "Idle and lock policy"
value: status(SystemSettings.hypridleActive)
}
TextRow {
label: "Vicinae"
detail: "Spotlight-style launcher daemon"
value: status(SystemSettings.vicinaeActive)
divider: false
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
ActionRow {
label: "Network, Bluetooth, printers, users, and accounts"
detail: "GNOME Settings remains searchable from the launcher too"
divider: false
action: "Open network"
onTriggered: SystemSettings.openGnomePanel("network")
}
}
}
@@ -28,11 +28,31 @@ Item {
// scrolls -- the Appearance page pins its live preview here.
property Component header: null
Loader {
id: pinnedHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
active: root.header !== null
sourceComponent: root.header
z: 1
}
Flickable {
anchors.fill: parent
id: pageScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
anchors.bottom: parent.bottom
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
clip: true
contentWidth: width
contentHeight: layout.implicitHeight + 64
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
boundsBehavior: Flickable.StopAtBounds
Column {
@@ -40,15 +60,9 @@ Item {
width: parent.width - 68
x: 34
y: 30
y: pinnedHeader.implicitHeight > 0 ? 0 : 30
spacing: 16
Loader {
width: parent.width
active: root.header !== null
sourceComponent: root.header
}
Text {
width: parent.width
visible: root.title !== ""
@@ -11,6 +11,19 @@ Rectangle {
&& pageLoader.item.objectName === "home-phone-page"
? pageLoader.item.pageDiagnostics
: ({})
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item
&& pageLoader.item.objectName === "system-health-page"
? pageLoader.item.uiDiagnostics()
: ({})
function requestHealthAction(id: string): bool {
if (pageLoader.status !== Loader.Ready
|| !pageLoader.item
|| pageLoader.item.objectName !== "system-health-page")
return false;
return pageLoader.item.activateRenderedAction(id);
}
color: Theme.bg
radius: 18
@@ -40,7 +53,7 @@ Rectangle {
anchors.left: parent.left
anchors.leftMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Panama Settings"
text: "Settings"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
@@ -99,11 +112,15 @@ Rectangle {
case "notifications": return notificationsPage;
case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage;
case "mouse": return mousePage;
case "privacy": return privacyPage;
case "region": return regionPage;
case "accounts": return onlineAccountsPage;
case "accessibility": return accessibilityPage;
case "power": return powerPage;
case "datetime": return dateTimePage;
case "applications": return applicationsPage;
case "services": return servicesPage;
case "services": return healthPage;
case "about": return aboutPage;
default: return homePage;
}
@@ -153,7 +170,11 @@ Rectangle {
Component { id: notificationsPage; NotificationsPage {} }
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
Component { id: shortcutsPage; ShortcutsPage {} }
Component { id: servicesPage; ServicesPage {} }
Component { id: mousePage; MousePage {} }
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
Component { id: healthPage; HealthPage {} }
Component { id: aboutPage; AboutPage {} }
Shortcut {
@@ -31,13 +31,17 @@ Rectangle {
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
{ page: "services", label: "System Health", icon: "\u{F0493}" },
{ page: "about", label: "About", icon: "\u{F02FD}" }
]
width: 272
@@ -56,15 +60,6 @@ Rectangle {
height: implicitHeight
spacing: 12
Text {
text: "PANAMA"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
font.letterSpacing: 2.2
}
Rectangle {
width: parent.width
height: 36
@@ -297,8 +292,34 @@ Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 54
color: Theme.alpha(Theme.bg, 0.35)
border.width: 0
activeFocusOnTab: true
color: footerTap.hovered || activeFocus
? Theme.alpha(Theme.fg, 0.07)
: Theme.alpha(Theme.bg, 0.35)
border.width: activeFocus ? 2 : 0
border.color: Theme.accent
function footerText(): string {
if (Health.checks.length === 0)
return Health.diagnosticUnavailable ? "Health check unavailable" : "Checking the desktop";
if (Health.status === "error")
return "Desktop needs attention";
if (Health.status === "warning") {
const count = Health.summary.warnings + Health.summary.errors;
return count + (count === 1 ? " health observation" : " health observations");
}
return "Desktop is healthy";
}
function footerColor(): color {
if (Health.checks.length === 0)
return Health.diagnosticUnavailable ? Theme.danger : Theme.fgMuted;
if (Health.status === "error")
return Theme.danger;
if (Health.status === "warning")
return Theme.warn;
return Theme.ok;
}
Rectangle {
width: 7
@@ -307,17 +328,28 @@ Rectangle {
anchors.left: parent.left
anchors.leftMargin: 19
anchors.verticalCenter: parent.verticalCenter
color: Theme.ok
color: healthFooter.footerColor()
}
Text {
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Panama desktop is healthy"
text: healthFooter.footerText()
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
TapHandler {
id: footerTap
onTapped: root.pageRequested("services")
}
Keys.onReturnPressed: root.pageRequested("services")
Keys.onSpacePressed: root.pageRequested("services")
}
}
@@ -8,7 +8,7 @@ FloatingWindow {
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
title: "Panama Settings"
title: "Settings"
visible: ShellState.settingsOpen
implicitWidth: 1120
implicitHeight: 760
@@ -21,6 +21,25 @@ SettingsPage {
// when nothing is being captured. Held here rather than per row so that
// starting a new capture cancels any other.
property string capturingChord: ""
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
function xkbOptions(): var {
return root.storedXkbOptions
.split(",")
.map(option => option.trim())
.filter(option => option !== "");
}
function currentXkbOption(prefix: string): string {
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
}
function setXkbOption(prefix: string, option: string): void {
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
if (option !== "")
options.push(option);
SystemSettings.commitPreference("keyboardOptions", options.join(","));
}
title: "Input & Shortcuts"
lede: "The Forge mental model, carried forward into native tiling."
@@ -28,19 +47,65 @@ SettingsPage {
SettingsCard {
title: "Keyboard"
// These were read-only text, on the grounds that a layout change needed
// a compositor reload. It does not: setting input:kb_variant through
// hl.config re-keymaps attached keyboards immediately -- verified by
// watching active_keymap on a real keyboard change and change back. So
// they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
ChoiceGrid {
width: parent.width
label: "Caps Lock"
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
current: root.currentXkbOption("caps:")
options: [
{ value: "", label: "Standard" },
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
{ value: "caps:escape", label: "Escape" },
{ value: "caps:ctrl_modifier", label: "Control" }
]
onPicked: value => root.setXkbOption("caps:", value)
}
ChoiceGrid {
width: parent.width
label: "Compose key"
detail: "Type accented characters and symbols with memorable key sequences"
current: root.currentXkbOption("compose:")
options: [
{ value: "", label: "Off" },
{ value: "compose:ralt", label: "Right Alt" },
{ value: "compose:rwin", label: "Right Super" },
{ value: "compose:menu", label: "Menu" }
]
onPicked: value => root.setXkbOption("compose:", value)
}
ChoiceGrid {
width: parent.width
label: "Layout switching"
detail: "Used when Keyboard layout contains more than one comma-separated layout"
current: root.currentXkbOption("grp:")
options: [
{ value: "", label: "Off" },
{ value: "grp:win_space_toggle", label: "Super + Space" },
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
{ value: "grp:caps_toggle", label: "Caps Lock" }
]
onPicked: value => root.setXkbOption("grp:", value)
}
// Presets preserve every option outside their own category. The raw
// value remains visible for less common xkeyboard-config features.
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" }
ToggleRow { setting: "numlockByDefault"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "pointerSensitivity" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard {
title: "Hardware input"
@@ -150,7 +215,7 @@ SettingsPage {
SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
ActionRow {
label: "Restore every shipped shortcut"
@@ -0,0 +1,40 @@
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
import qs.services
Column {
id: root
property bool output: true
readonly property var nodes: AudioDevices.nodes(root.output)
readonly property var current: AudioDevices.current(root.output)
width: parent ? parent.width : 620
spacing: 8
Repeater {
model: root.nodes
SoundDeviceRow {
required property var modelData
width: root.width
node: modelData
output: root.output
selected: modelData === root.current
}
}
Text {
width: parent.width
visible: root.nodes.length === 0
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
topPadding: 18
bottomPadding: 18
}
}
@@ -0,0 +1,168 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
import qs.widgets
import qs.services
import qs.modules.quicksettings
Rectangle {
id: root
required property var node
property bool output: true
property bool selected: false
implicitHeight: root.output || !root.selected ? 88 : 105
radius: Theme.cardRadius
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
border.width: 1
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
PwObjectTracker {
objects: root.node ? [root.node] : []
}
PwNodePeakMonitor {
id: inputPeak
node: root.node
enabled: !root.output && root.selected
}
readonly property real volume: root.node?.audio?.volume ?? 0
readonly property bool muted: root.node?.audio?.muted ?? false
function iconName(): string {
if (!root.output)
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
if (root.muted || root.volume <= 0.001)
return "audio-volume-muted-symbolic";
if (root.volume < 0.34)
return "audio-volume-low-symbolic";
if (root.volume < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
Row {
id: heading
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.topMargin: 8
height: 28
spacing: 10
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 20
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-symbolic"
tint: root.selected ? Theme.accent : Theme.fg
}
Column {
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
width: parent.width
text: AudioDevices.label(root.node)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: root.selected ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
text: root.node.nickname ?? ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
SettingsButton {
id: useButton
anchors.verticalCenter: parent.verticalCenter
width: root.selected ? 78 : 64
text: root.selected ? "Default" : "Use"
enabled: !root.selected
onClicked: AudioDevices.select(root.output, root.node)
}
}
IconButton {
id: muteButton
anchors.left: parent.left
anchors.leftMargin: 8
anchors.top: heading.bottom
anchors.topMargin: 7
size: 30
iconSize: 17
icon: root.iconName()
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
onClicked: {
if (root.node?.audio)
root.node.audio.muted = !root.node.audio.muted;
}
}
ValueSlider {
id: volumeSlider
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 => {
if (!root.node?.audio)
return;
root.node.audio.muted = false;
root.node.audio.volume = 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
}
Rectangle {
anchors.left: volumeSlider.left
anchors.right: volumeText.right
anchors.top: muteButton.bottom
anchors.topMargin: 5
height: 5
radius: height / 2
visible: !root.output && root.selected
color: Theme.alpha(Theme.fg, 0.10)
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
radius: parent.radius
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
}
}
}
@@ -1,8 +1,6 @@
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
import qs.services
import qs.modules.quicksettings
SettingsPage {
title: "Sound"
@@ -10,43 +8,67 @@ SettingsPage {
SettingsCard {
title: "Output"
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
subtitle: AudioDevices.current(true)?.description ?? "No output device"
AudioSlider {
width: parent.width
node: Pipewire.defaultAudioSink
output: true
}
Rectangle {
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
AudioDeviceList {
SoundDeviceList {
width: parent.width
output: true
maxHeight: 190
}
AudioBalance {
width: parent.width
node: AudioDevices.current(true)
}
}
SettingsCard {
title: "Input"
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
subtitle: AudioDevices.current(false)?.description ?? "No input device"
AudioSlider {
width: parent.width
node: Pipewire.defaultAudioSource
output: false
}
Rectangle {
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
AudioDeviceList {
SoundDeviceList {
width: parent.width
output: false
maxHeight: 160
}
}
SettingsCard {
title: "Applications"
subtitle: "Control each application currently playing through PipeWire."
ApplicationMixer {
width: parent.width
}
}
SettingsCard {
title: "Sound feedback"
subtitle: "Use the same event preferences as GTK and GNOME applications."
SettingRow {
label: "Event sounds"
detail: "Play alerts and interface event sounds"
controlWidth: 42
SettingsToggle {
anchors.fill: parent
checked: SoundFeedback.eventSounds
enabled: !SoundFeedback.busy
onToggled: checked => SoundFeedback.setEventSounds(checked)
}
}
SettingRow {
label: "Input feedback"
detail: "Play sounds for supported typing and input events"
controlWidth: 42
divider: false
SettingsToggle {
anchors.fill: parent
checked: SoundFeedback.inputFeedback
enabled: !SoundFeedback.busy
onToggled: checked => SoundFeedback.setInputFeedback(checked)
}
}
}
@@ -54,8 +76,8 @@ SettingsPage {
title: "Advanced sound"
ActionRow {
label: "Application volumes and profiles"
detail: "Open Fedora's complete sound panel"
label: "Device profiles"
detail: "Open Fedora's complete device profile panel"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("sound")
@@ -0,0 +1,109 @@
// A free-text setting, bound to a schema key by name.
//
// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
//
// For the settings whose value is a short string with too many legal values to
// enumerate -- XKB layouts and options, chiefly. Label, explanation, and
// validation all come from PreferenceSchema, so a row cannot drift from the
// setting it edits.
//
// Rejected input is shown as rejected rather than silently dropped or quietly
// sanitised. Several of these strings are serialised into an hl.config payload,
// where stripping an unexpected character would turn a typo into a different
// working setting -- so the schema's pattern decides, the field turns red when
// it fails, and nothing is committed until it passes.
//
// Committed on Enter or when focus leaves, not per keystroke: half a layout
// name is a valid string that means something else, and each commit is a round
// trip to the compositor.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
property string placeholder: ""
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "")
// Empty is always allowed to be typed through, even where the pattern
// forbids it, so a field can be cleared on the way to a new value.
readonly property bool valid: input.text === ""
|| !root.spec?.pattern
|| new RegExp(root.spec.pattern).test(input.text)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: 210
function commit(): void {
if (!root.valid || input.text === root.stored)
return;
SystemSettings.commitPreference(root.setting, input.text);
}
function revert(): void {
input.text = root.stored;
}
// Follows the store when the value changes elsewhere -- a reset, a restored
// backup -- but never while the field has focus, which would overwrite what
// is being typed.
onStoredChanged: if (!input.activeFocus) input.text = root.stored
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: !root.valid
? Theme.danger
: (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
verticalAlignment: TextInput.AlignVCenter
clip: true
text: root.stored
color: root.valid ? Theme.fg : Theme.danger
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
selectByMouse: true
selectionColor: Theme.alpha(Theme.accent, 0.35)
onAccepted: root.commit()
// Commit, then show what is actually stored. If the compositor
// refused the value, the field snaps back to the value in effect
// rather than displaying a setting that was never applied; if it
// accepted, onStoredChanged brings the field back up to date the
// moment the write lands.
onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); }
Keys.onEscapePressed: {
root.revert();
input.focus = false;
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: input.text === "" && !input.activeFocus
text: root.placeholder
color: Theme.fgMuted
font: input.font
}
}
}
}
@@ -0,0 +1,31 @@
// A time of day, stored as a decimal hour.
//
// SliderRow would render 17.5 as "17.50", which is not how anyone reads a
// clock. This is the same control with the readout formatted as a time, so the
// night light schedule says "5:30 PM" rather than a number you have to convert.
//
// Honours the 24-hour clock preference, because a user who has asked for 18:30
// everywhere else should not be shown 6:30 PM here.
import QtQuick
import qs.config
import qs.services
SliderRow {
id: root
// SliderRow renders `unit` after the number; a time needs the whole readout
// replaced, so the formatting is done here instead.
function display(value: real): string {
const hour = Math.floor(value);
const minute = Math.round((value - hour) * 60);
const padded = String(minute).padStart(2, "0");
if (Settings.use24Hour)
return `${String(hour).padStart(2, "0")}:${padded}`;
const suffix = hour < 12 ? "AM" : "PM";
const twelve = hour % 12 === 0 ? 12 : hour % 12;
return `${twelve}:${padded} ${suffix}`;
}
}
@@ -0,0 +1,103 @@
import QtQuick
import qs.config
import qs.services
import qs.widgets
Column {
id: root
property string mode: DesktopPreferences.get("wallpaperMode")
property var outputs: Wallpaper.outputNames()
property string selectedOutput: root.outputs.length > 0 ? root.outputs[0] : ""
property var setModeAction: function(mode) { Wallpaper.setMode(mode); }
property var setIntervalAction: function(minutes) { Wallpaper.setIntervalMinutes(minutes); }
property var setShuffleAction: function(enabled) { Wallpaper.setShuffle(enabled); }
width: parent ? parent.width : 620
spacing: 4
ChoiceGrid {
width: parent.width
label: "Wallpaper mode"
detail: "Use one image, rotate a collection, or choose per display"
options: PreferenceSchema.spec("wallpaperMode").options
current: root.mode
onPicked: value => root.setModeAction(value)
}
ChoiceGrid {
visible: root.mode === "per-monitor"
width: parent.width
label: "Display"
detail: "Choose which display the thumbnail grid assigns"
options: root.outputs.map(output => ({ value: output, label: output }))
current: root.selectedOutput
onPicked: value => root.selectedOutput = value
}
SettingRow {
visible: root.mode === "slideshow"
label: "Change background every"
detail: "Time between slideshow images"
controlWidth: 280
Item {
anchors.fill: parent
ValueSlider {
anchors.left: parent.left
anchors.right: intervalText.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
value: (DesktopPreferences.get("wallpaperIntervalMinutes") - 5) / 1435
onMoved: ratio => {
const raw = 5 + ratio * 1435;
root.setIntervalAction(Math.max(5, Math.min(1440, Math.round(raw / 5) * 5)));
}
}
Text {
id: intervalText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 62
text: DesktopPreferences.get("wallpaperIntervalMinutes") + " min"
color: Theme.fgDim
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
}
}
}
SettingRow {
visible: root.mode === "slideshow"
label: "Shuffle"
detail: "Show every selected image before repeating"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: DesktopPreferences.get("wallpaperShuffle") === true
onToggled: enabled => root.setShuffleAction(enabled)
}
}
Text {
width: parent.width
visible: root.mode === "slideshow"
&& (DesktopPreferences.get("wallpaperSlideshowPaths") ?? []).length === 0
text: "Select two or more images below to begin a slideshow."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
topPadding: 5
bottomPadding: 8
}
}

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