Author SHA1 Message Date
Gabriel Brown 3b01f1e020 Let the Dock choose an edge, choose its screens, and be dragged into order
Three things that were parked, and the reasons they were parked turned out to be
the useful part of doing them.

The Dock can sit on the left or the right as well as the bottom. Everything that
assumed the bottom edge is now asked which edge it is on: the anchors, the axis
that gets an implicit size, the sliver of input region that survives hiding, the
direction the body slides away in, and which side a tooltip opens towards. The
body was a Row and is a Grid, because one declaration then serves both
orientations -- Row and Column would each need their own children, and the
cross-axis anchors that centre items in a Row are the wrong axis in a Column.

Bottom is unchanged in every particular, and the settings default to it, so a
hot reload in the middle of this work left the running dock exactly where it
was.

One bug worth recording because static review would never have found it: a dock
spans the edge it lives on, which means anchoring BOTH ends of that edge. The
first side dock anchored top and left only, was free to collapse to its implicit
height, and came out one pixel tall. It parsed, it loaded, and it rendered
nothing. The contract measures the geometry rather than reading the source for
that reason, and was verified by putting the single-ended anchor back.

Per-screen is a list of names where empty means every screen, because a list is
what goes stale when a display is unplugged and "all" should not be spelled as
one. Turning off the last screen collapses to "all" rather than leaving no dock
anywhere and no obvious way back.

Pins can be dragged by a grip. The objection this file recorded for a long time
was real -- dragging inside a Flickable inside a scrolling page fails in a way
that reads as breakage -- and the answer is preventStealing on the grip, so the
page cannot claim a gesture that started there. The arrow buttons stay: they are
the keyboard-reachable path and a grip is not. The order is held locally during
the drag and written once on release, rather than rewriting settings.json for
every slot crossed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 14:10:22 -04:00
Gabriel Brown 23141673a2 Hang the Control Center where every other popover hangs
Five surfaces sat 12 pixels under the bar -- the date menu, the activity panel,
notification toasts, signal glass and the clipboard, all using barGap * 2. The
Control Center sat at 2, through a constant of its own, which left the widest
surface in the shell hanging ten pixels higher than the date menu beside it.

That constant arrived with the original Control Center and carried no reason,
while barGap directly above it explains itself. The clipboard even cites
QuickSettings in a comment for how it derived its own margin, and still landed
on 12. It reads as an early value nothing else converged on rather than a
decision, which is why it is going rather than being documented and kept.

The contract that guarded it pinned the literal, and that same file already
records where pinning a literal led: it once asserted the buggy margin
expression, so the code and the test agreed and a 38-pixel gap was invisible to
both. Replacing one number with another would have repeated it. It now reads the
top margin out of the Control Center and out of the date menu and requires them
to match, so drift in either direction fails -- verified by moving each one in
turn and watching it break.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:41:12 -04:00
Gabriel Brown 495fb9b41b Give each online account its provider's icon
Four accounts distinguished only by a line of small grey text are four rows that
have to be read rather than recognised. GOA already knows what each one is.

It hands back a serialised GThemedIcon -- ". GThemedIcon goa-account-google
goa-account goa …" -- a preference-ordered fallback chain. The helper passes the
whole chain on rather than resolving it, because which of those names exists is
a property of the icon theme in use and not something a python script talking to
D-Bus should be deciding.

The page walks the chain and takes the first name the active theme actually has.
Both simpler readings were wrong and looked right: taking the first name blindly
assumes it resolves, and taking the last as a fallback assumes the most generic
name is the most likely to exist. On Adwaita the tails of these very chains --
"mail", "goa-symbolic" -- do not exist at all, so a miss would have drawn
nothing. Checked by asking Quickshell.iconPath directly, which returns empty for
a name the theme lacks; the fallback is avatar-default-symbolic, which is
present.

SettingsCard grew an optional icon for this. It is empty by default and the
header lays out exactly as before when unset, so no other card moves.

Last-sync is not here because there is nothing to show: GOA exposes no
sync-related property at all, on any of these accounts. Better to say so than to
invent a timestamp from when the page last refreshed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:28:05 -04:00
Gabriel Brown 3b657e0a56 Make the resize keys do what the fingers expect
Audited against the real Forge configuration rather than its defaults: the
bindings are still in dconf, and the extension is still installed, so both the
keys and the code behind them could be read directly.

The port was faithful. Forge's resize() grows the window for a positive amount
in every direction -- the edge only decides which side moves -- so
"increase" meant grow and "decrease" meant shrink, and Y/O/I/U mapped to Wider
and Taller exactly as they should have.

What did not survive is direction. Forge resized one named EDGE: Y pulled the
left edge leftward, O pushed the right edge rightward, and the window opened
from the side you asked for. Hyprland resizes along an axis and lets the layout
pick the border, so eight distinct behaviours collapse onto four and the
direction is not expressible at all in dwindle. Correct on paper, wrong under
the hands: the keys that used to pull a window open from one side now push it
from the other, which reads as the pair being swapped.

So the sizes are inverted from Forge's naming on purpose. Y and O shrink, B and
M grow, I and U shorten, N and Comma lengthen. Faithfulness to a mapping nobody
can feel is worth less than keys that behave the way their owner reaches for
them, and the reason is written where the next person will find it.

Also fixes the Home Assistant accessories dropdown, which looked glitchy for a
reason that was not performance. The resting card hid the instant the section
opened -- a Column skips invisible children, so everything above snapped up
while the expanded grid was still sliding open underneath. It now collapses on
the same curve and duration as the section replacing it. This was the only
control with a resting and an expanded form that swap, which is why it was the
only one that looked wrong.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:10:17 -04:00
Gabriel Brown c148bae4ac Let Ethernet be switched off and on
Wi-Fi had a switch and wired did not, which left no way to take the cable down
without reaching for nmcli -- and this machine has two interfaces on one subnet,
so turning one off is a genuinely useful thing to be able to do.

Both halves are needed to turn it off. Disconnecting alone survives the session
but not a carrier event or a reboot, because NetworkManager brings an
autoconnecting device straight back; the switch sets the device's autoconnect
alongside it. The device property rather than the connection profile, so a
toggle here does not quietly rewrite a saved connection somebody expects to come
up at boot.

The first version built a trap door. It decided whether the switch was usable
from hasLink, which reads false while a device is merely disconnected even
though NetworkManager still reports the carrier as on -- so turning Ethernet off
made the switch disable itself, blame the cable, and offer no way back. A wired
device that exists can always be asked to come up; if there is really no cable
the attempt fails and says so, which is the honest failure. The word "off" is
used where the old text guessed "no cable", because nothing available here can
tell those apart.

Verified as a round trip against the real device, including that off stays off
through eight seconds rather than reconnecting a moment later, and that the
mechanism tested is the one the code uses -- the first test drove the connection
profile while the code drives the device, which are different things.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 12:13:49 -04:00
Gabriel Brown 4cbab01ae2 Show what has actually been installed
Automatic updates leave no other trace. The Flatpak that sat here as "1 update
available" installed itself at 00:14 this morning and nothing on the machine
would have said so.

Both sources are asked in their own machine-readable form and merged on time, so
the answer reads as one history rather than two lists to interleave by eye.

Two parsing traps worth recording next to the code. flatpak's --json prints
timestamps as "Aug 20 08:07:46" with no year in them, so the year is inferred
and a date that would land in the future is read as last year's. And dnf5's
start_time is epoch UTC while its own history table prints that same value as
though it were local -- checked against rpm, and the local rendering here is the
correct one.

The contract asserts entries are newest first, that none is dated in the future,
and that both sources parse; it was verified to fail by breaking the year
inference so every flatpak entry landed tomorrow.

Loaded on demand rather than with the page, because it reads both full
transaction logs.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 11:30:31 -04:00
Gabriel Brown de45f205ad Carry settings between machines by allow-list, not by stripping
panama-settings-backup already snapshots this machine so it can be put back
exactly as it was, arrangement and all. This is the other thing: an export meant
to travel, carrying the preferences that describe taste rather than hardware.

The export is an allow-list read from the preference schema rather than a
deny-list of things to remove. A key added later that happens to hold a token
cannot leak into a file somebody emails to themselves; being wrong in this
direction loses a setting, being wrong the other way publishes a secret. It
earned that immediately -- this machine's store holds an orphaned shadowOffset
from a setting that no longer exists anywhere in the source, and it was left
behind without anyone having to know about it.

Three settings stay: the display arrangement, which is keyed by output names
that mean nothing elsewhere; the last page opened, which is session noise; and
schemaVersion, which belongs to the store rather than to a person. Import is a
merge, so settings a file does not mention are left alone, and it is idempotent.

Two bugs made and caught here, in opposite directions. Validation missed 36
settings because "real" was spelled "float" and enums fell through entirely, so
an out-of-range or nonsense value would have been written straight into the
store. Correcting that then broke numeric enums -- vrrPolicy is an enum of 0..3
and the options were read with a regex that only matched quoted values, so those
settings had no known choices, were declared unverifiable and were refused:
valid settings dropped silently in transit.

The contract could not see the second one. It checked only that bad values are
refused, and when numeric enums were unreadable they never reached the bundle at
all, so every "did it arrive" assertion was satisfied by their absence. It now
requires the export to carry what it should as well as withhold what it should
not, and was verified to fail in both directions.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 11:02:42 -04:00
Gabriel Brown 52e2a83a78 Add an SSH Keys page, and refuse the one control that would lie
The page shows which keys exist, what the agent is holding, and the hosts this
machine has met, with a two-press forget for a host whose key has changed.

Nothing here reads private key material. Fingerprints and comments come from the
.pub file, and "does this key need a passphrase" is answered by asking
ssh-keygen to derive the public half with an empty one -- it succeeds for an
unencrypted key and fails for an encrypted one, and either way the only thing it
can emit is public. The contract checks that against the payload that actually
reaches the page rather than against the source, because what the code intends
and what it ships are different claims.

Unloading a key from the agent is refused, with its reason. On this desktop
`ssh-add -d` prints "Identity removed" and the key is still offered a second
later: gnome-keyring's agent lists every key it finds in ~/.ssh, so a removed
one comes straight back off disk. That was measured rather than assumed -- a
plain ssh-agent removes durably, this one does not -- and a button reporting
success while changing nothing is worse than no button. The page says so and
names the thing that does work: move the file out of ~/.ssh.

SSH_AUTH_SOCK is not set in a normal shell here, so a naive check reports "no
agent" while one is plainly running. The helper falls back to the keyring
socket, and an agent started by hand still wins. That gap is the same one that
made reaching these servers awkward in the first place.

Generating a key is deliberately absent. A passphrase cannot reach ssh-keygen
without going somewhere it should not -- -N puts it in argv, which every process
on the machine can read -- and driving the prompt over a pty did not work.
Offering to generate an unencrypted key instead would be a downgrade dressed as
a feature, so the page does not offer to generate at all.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 10:17:33 -04:00
Gabriel Brown 79b3d5cb85 Close the sweep's last blind spot, and stop shortcuts silently colliding
gapsIn and gapsOut were the only two compositor settings the write sweep had
never verified: Hyprland answers for them in CSS shorthand, "5 5 5 5", and the
sweep had no way to compare that. The preference behind each is a single int
that Hyprland expands to four sides, so a uniform reading compares exactly. A
non-uniform one is not something the preference can express, and is skipped
rather than collapsed to a number it never wrote. 63 of 63 verified live now,
none skipped.

Wallpaper thumbnails are cached. The report that five of them sat at "Loading…"
was a screenshot taken 1.1 seconds after the page opened -- decoding one of
these at tile size takes between 1.2 and 2.6 seconds and about ten start at
once, which the code already said. Measuring it did turn up something real
though: without a cache, scrolling back up pays that decode again for every
tile. The tradeoff is a wallpaper replaced in place showing a stale thumbnail
until restart, which is worth it for a directory of files that are added rather
than edited.

A chord already in use is now named rather than taken: "Super+Q is already
Terminal". Two actions on one chord means whichever Hyprland reads last wins,
which is not a thing to find out later by pressing it. Rebinding a shortcut to
the chord it already holds is correctly not a conflict.

Also: Open Appearance lands on the Windows tab now that the page has tabs,
Storage points at reclaimable container space, and a dock row shows its desktop
id only when two pinned applications share a name -- it is developer text, and
repeating it under fifteen recognisable names made the list harder to scan.

Written down because it cost the shell: QML has no default parameter values, and
`function openSettings(page: string, section: string = "")` fails the entire
configuration rather than the one function -- so the bar and dock went with it,
and 43 contracts failed at once pointing at the same line. qmllint --bare passes
that, which is why the usual check before touching the running shell did not
catch it. openSettingsSection exists as a separate function for that reason.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:56:02 -04:00
Gabriel Brown 1b845c0126 Rebuild Home around what this desktop already knows
Home led with a diagram of the attached monitor -- DP-2, 4500 x 3000, 1.13x
scale, XRGB2101010 -- which is Displays-page data, was the largest thing on the
screen, and has never been needed there. Under it sat a permanently open search
field for a weather location that is set about once a year, and then roughly
half a page of nothing.

It now opens with the two or three things somebody would open Home to do:
starting or ending a focus session, Do Not Disturb, and what is running. The Do
Not Disturb switch is disabled and says why while a mode is holding it, so it
cannot appear to be a control that is being ignored.

Findings sit above a reassurance line, the same shape the Firewall and
Containers pages use. Each finding names the page that can resolve it, because a
home page reporting a problem it cannot help with is only an alarm. On this
machine that is one finding today -- PostgreSQL and Redis reachable from the
network -- above "27 health checks pass, 522 GB free, snapshots ran at 02:00".
"Do next" appears only when there is something in it.

Weather stays, as the greeting's second line rather than a card with a search
box open, and its picker is collapsed behind the current location.

FocusModes was missing `import qs.config`, so DesktopPreferences was undefined
and the modes list came back undefined with it -- shipped two commits ago with
nothing noticing, because until Home referenced the service no isolated shell
had ever instantiated it. Focus modes would have quietly had no modes. Pulling
these services together on one page is what surfaced it.

LocationPicker gained a picked signal, the same way DisplayModePicker did, so a
container can put the search away without reaching into it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:15:01 -04:00
Gabriel Brown f53ca16392 Make the focus exception list real, or it was a page telling a lie
The mode data model shipped with an allow list and nothing that read it. The
summary would say "2 apps may interrupt" while notification delivery never
consulted the list and no editor could set it. That is the dead row this work
has spent its time removing, introduced by the work itself.

The banner gate consults the mode in force now, and the list can be edited from
the applications that have actually sent a notification -- an exception for
something that never notifies is not a choice worth offering.

Exceptions belong to a mode. allowedApps is empty whenever no mode is active, so
a Do Not Disturb switched on by hand stays absolute and nothing can leak into
it. That scoping is asserted, not just written.

Verifying this took three attempts, and the second was a real defect in the
guard rather than in the code. The contract grep for FocusModes.allows matched
the comment that explains it, so the check passed with the enforcement deleted.
It matches the gate expression now. A guard a comment can satisfy is not a
guard, and this is the third time prose has satisfied one here.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 02:23:30 -04:00
Gabriel Brown 6dc606b872 Give focus modes conditions rather than alarms, and let Gaming hand over
A mode is on because something is true right now: a game is running, a window is
fullscreen on a given display, a workspace is focused, the clock is inside a
window. That is asked again rather than fired once, and it is the whole reason
schedules could be included here without the usual failure modes. A machine
asleep at 23:30, rebooted at 02:00, or opened at 08:00 into a window that has
already passed all reach the right answer by being asked again; an alarm gets
all three wrong.

The midnight-crossing rule is the part worth being careful about: a window
belongs to the day it STARTS on, so a Friday-only 23:30-07:00 covers Saturday
morning and must not cover Saturday night. That arithmetic was tested as pure
logic before anything was built on it, including every malformed input failing
closed -- silencing someone because a time string was wrong is the worst way
this could fail.

This does not take over the manual timed session. FocusSession already owns
that, with its capsule, shortcut, Quick Settings entry and contracts, so modes
defer entirely while one runs. Two writers of Do Not Disturb would each restore
whatever the other happened to leave behind.

Gaming hands over rather than being duplicated. The hook was silencing
notifications itself, which would have made exactly those two owners -- and
Gaming.active only polls while its settings page is open, so a mode could not
have seen a game reliably in any case. The hook reports the game over IPC now
and the mode decides what that means, the Gaming page points at it, and
gamingSilenceNotifications is retired from the schema, since a setting nothing
reads is the dead row this work keeps removing.

Sleep ships disabled. A desktop that starts silencing someone on first boot has
overstepped, whatever the default hour.

Three contracts moved with it. gaming-contract asserted the hook uses setDnd,
which was right before and wrong now; the shell-side assertions that setDnd and
dndState exist stay, because a toggle would flip an already-silent machine back
on. The new contract is proven to fail by breaking the midnight rule and by
letting modes run alongside a manual session.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 02:06:50 -04:00
Gabriel Brown 32fab59d24 Let a sound device be heard, and the Dock's icons be sized
Nine outputs named after their chipsets cannot be told apart by reading, so each
one gets a Test button that plays a short sample out of that device. Targeted by
node name rather than by making it the default first, because finding out which
is which should not move where everything else is playing.

That belongs in its own service rather than in AudioDevices. sound-page-contract
forbids Process, pactl and wpctl in the files that own device state, and it is
right to: shelling out there races the PipeWire service that owns those same
objects. Playback is a different thing -- pw-play opens its own stream and
mutates no device, so there is nothing to race -- but the rule's letter covered
it, and weakening a guard to fit a new case is how guards stop meaning anything.
SoundTest exists so AudioDevices stays native bindings only.

Worth recording next to the call: pw-play falls back to the default output for a
target it cannot find, rather than failing. A stale node name would play from
the wrong device and look exactly like a successful test, which is why the name
is taken straight from the live node.

The Dock's icon size was a constant in Theme. It goes through the preference
schema like everything else, so validation, search, the generated docs and the
write sweep all pick it up without being told about it separately -- and two
contracts duly failed until docs/settings.md and the per-page commands were
regenerated.

Dock position is deliberately not here. It is not a setting but a rework: the
dock is anchored bottom, and the reveal strip, tooltip placement, intellihide
and the qs-dock rule in hypr/rules.lua all assume that. Doing it properly means
changing compositor rules on a machine somebody uses daily, which is not
something to start as a side effect of adding a slider.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 00:03:21 -04:00
Gabriel Brown f6b970da21 Turn the rows that only reported things into controls
Autostart entries showed "Enabled" or "Disabled" as plain text. The row did
toggle on click the whole time, so this is an affordance rather than a missing
capability -- but a control that reads as static text is one nobody knows they
have. It is a switch now, with removal alongside it behind a confirmation:
disabling writes Hidden=true and can be undone, deleting the file cannot.

remove-autostart is confined to files the autostart directory owns. It resolves
the path and compares the parent, so a name like "../../.bashrc" cannot escape,
and it refuses symlinks rather than following them -- deleting through one would
remove whatever it points at, which is somewhere else and not ours. Each refusal
was tested against a fixture directory, including a symlink aimed at
/etc/hostname, which survived.

Sharing says who is signed in from another machine: user, origin and since when.
An empty list on this machine proves nothing, so the parser was checked against
sample `who` output -- it picks out remote sessions and leaves out local seats
and the :0 display, which would otherwise report the person at the keyboard as a
remote login.

Media sharing was "Available" and nothing else: rygel installed, rygel.service
disabled, no way to change that from here. It is a switch now, and it says what
it does before you touch it rather than afterwards -- turning it on publishes
media folders to every device on the network with no password in front of them.

Per-application camera and microphone permissions come from the portal's
permission store, which is where an application that asked through the portal
has its answer recorded. The page states the limit plainly instead of implying a
protection that does not exist: a program installed outside the portal opens the
device directly and nothing here stands in its way. Anything that is not an
explicit "yes" is treated as withheld, because guessing generously about a
camera is the wrong way to be wrong.

The first version of the write silently did nothing -- SetPermission takes an
array of strings and was being handed one string -- and the test did not notice,
because it discarded the helper's output and only checked that state was
unchanged afterwards, which was trivially true. The contract now requires the
value to move, and was proven to fail by putting that exact bug back.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 23:10:48 -04:00
Gabriel Brown 7e1c85b094 Group the long pages by what you are trying to do
Notifications repeated both lock-screen switch labels for every application, so
twenty apps meant sixty rows of the same two sentences and the page could not be
scanned at all. Each app is one row now, carrying what its switches add up to --
"On, lock screen shows the sender only", "On, hidden on the lock screen",
"Notifications off" -- with the switches behind it, one app open at a time. The
identifier only appears while an app is open, which is the only time it
disambiguates anything, and the content switch dims when the app cannot reach
the lock screen at all, because there it means nothing.

Shortcuts were already grouped; the problem was that "Windows" caught focus,
movement, splitting, resizing and window state alike and held 43 of the 93
binds. A section that long is a list, not a grouping. They are separated by
intent now -- Focus, Move & split, Size, Window state -- and the split was
checked against the binds this machine actually has rather than trusted from the
keywords. Order matters in two places worth naming: "Next window splits down" is
about splitting rather than focus, and "Focus session" is quiet mode bound to a
workspace rather than window focus, so both are settled before the general
checks.

Refresh rate gets its own row. That need was created by collapsing the
resolution list: the rates for a resolution were only ever reachable by opening
it, so changing nothing but the rate meant going through the mode you already
had. It appears only when the current resolution offers more than one.

Default-application rows carry a chevron, having previously opened a chooser
while looking completely inert.

The notification contract asserted the literal Notifs.appRule(app.id).enabled,
which moved when the rows collapsed. The rule is still read through a binding on
Notifs.appRule, so a rule changed elsewhere still reaches the row -- the
assertion now requires that, rather than requiring one particular spelling of
it.

The power profile rows were left alone. A three-way choice in three rows looks
wasteful until you notice each row explains what the profile does, and that page
has empty space to spare; a segmented control would trade information for space
that is not scarce.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:54:51 -04:00
Gabriel Brown 0c364f38e6 Stop sending people to GNOME for pages this app already has
Panama absorbed Users, Sharing, Printers and Online Accounts one page at a time.
Each time, the row pointing at GNOME's equivalent stayed exactly where it was --
so an app whose stated purpose is to make GNOME Settings unnecessary shipped four
doors back to it, two of them inside a card headed "these areas remain owned by
Fedora and GNOME's mature system panels".

Nothing failed. Every row worked as written. They were simply no longer true, and
no test could notice, because none of them knew what Panama had come to own in
the meantime. gnome-handoff-contract reads the sidebar for the pages that exist
and the pages for the panels they hand off, and fails on any overlap -- derived
from both sides rather than a hand-kept list, so absorbing the next page cannot
leave a stale door behind. Adding an online account is allow-listed with its
reason: it genuinely requires GOA's own dialog.

health-ui-contract asserted those handoffs were present, which is how they
survived. The assertion is inverted rather than deleted, so reintroducing one
fails loudly.

The Home Assistant "Light entities" box is gone. It was a multi-line list of
comma-separated Zigbee entity IDs, and the light catalog does not come from it --
the helper discovers that live. It is a one-time migration seed for the Control
Center selection, so saving now passes the stored value back untouched: setting a
URL or a token cannot disturb it. Deleting the control naively would have written
an empty list over it.

Sharing showed two "Port" rows for RDP, same label and value, one read-only and
one editable, separated by a switch. The read-only leftover is gone. The SSH port
stays read-only because sshd's port is not ours to write.

About reported "488G free of 1.9T" where Storage said "523 GB free of 2.0 TB" --
the same drive, binary against decimal. About uses decimal now, matching how
drives are sold. Memory and swap stay in GiB, which is how RAM is sold.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:58 -04:00
Gabriel Brown 1aa1324083 Lead Appearance with light and dark, and stop pages listing whole datasets
Appearance was six cards deep and Light/Dark was the third of them, below the
wallpaper grid and the entire lock screen -- so the control reached most often
was the last one you got to. It is five tabs now, Theme first. The mock showed
four; the page turned out to have eleven cards, so Titlebars and Windows became
Windows, and Clock and vitals became Shell, rather than pretending four would
hold them.

Region, Date & Time and Displays each rendered a complete dataset as rows: every
installed locale, the whole tz database, every mode the monitor advertises. The
chooser was never the problem -- SearchPicker already existed and worked. It was
simply rendered always-expanded, so the one line saying what is currently set sat
under hundreds that were not. PickerRow collapses each behind its current value
and closes again once something is picked.

The avatar never appeared to change because accountsservice writes every picture
to the same path, leaving the URL byte-identical while Qt served its cached
image. cache:false was already set and could not have helped: an unchanged source
is never re-read at all. avatarUrl now carries a revision fragment, bumped only
when a write actually succeeds. Pictures are cropped before they are set, in the
picture's own pixel coordinates so the result does not depend on the size it
happened to be displayed at, and written out at 512x512 through GdkPixbuf --
already a dependency here, so nothing new is required.

Snapshots listed nothing. The timeline and its Delete buttons existed the whole
time, behind a row labelled "Browse...", a word that promises a file browser. The
three most recent points are shown inline now, with the rest one press away.

qmldir-registration-contract exists because an unregistered component is not a
quiet problem: Quickshell fails the entire configuration on it, so the settings
window dies and the bar and dock go with it. That happened twice while writing
this, both times on a machine somebody was using. It is pure file inspection, so
it runs before a change ever reaches the running shell.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:41 -04:00
Gabriel Brown ac5e6e2130 Add a Containers page, grouped by project and led by what is exposed
Every container on this machine is created by rootless podman-compose and
labelled with the project it belongs to, so the grouping is read from the
labels rather than invented. State then decides prominence within that
grouping -- running containers get rows, stopped ones collapse to a line --
which is why neither axis had to be chosen over the other.

Acting on a stack uses plain podman over the labelled set, never
`podman-compose down`, which would remove containers this shell did not
create. The compose file is the source of truth for what exists and belongs
to the repository. Nothing here needs privilege.

The findings on top are the crossing the Firewall page reports, seen from the
side that can close it: the firewall knows only that something is listening,
while this page knows which container, which compose file, and which token is
missing from it. So `bind-local` prepends a loopback address and leaves the
line byte-for-byte -- variables, quoting and style intact -- then re-parses and
rolls back unless exactly those ports moved. It refuses anything ambiguous
rather than guessing. Rewriting the mapping to the port podman reports today
would have deleted the ${POSTGRES_PORT} indirection that makes it
configurable at all.

Unused volumes are read from podman's own dangling filter. The first version
used MountCount, which is a runtime lock counter and not a usage signal: it
reads zero for a volume a running container has mounted this second, so
"remove unused volumes" offered to delete the live Command Center database.
The cross-check against `podman system df` is what exposed it. The contract
reintroduces that bug deliberately and fails if the guard does not catch it,
because a guard nobody has seen fail proves nothing.

Every mutation in the contract runs against a stubbed podman. Nothing in the
suite starts, stops or removes a real container, image or volume.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 20:49:04 -04:00
Gabriel Brown 536958430f Fix the polkit unit's ignored rate limit
systemd logged "Unknown key 'StartLimitIntervalSec'" on every start:
rate limiting belongs in [Unit], not [Service], so both keys were
ignored and the restart limit they were meant to impose did not exist.
An agent that failed repeatedly would have flapped rather than stopping.

Found while investigating reported prompt failures, which turned out not
to be a defect: the trace showed both authentications succeeding on the
first attempt. The failures came from testing -- prompts raised by
background pkexec runs and then cancelled seconds later, while someone
was trying to type into them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:54:16 -04:00
Gabriel Brown fd99569666 Add a Firewall page, led by what is actually reachable
Listing zones and services is what firewall-cmd already does. The
question it does not answer needs both halves at once: a port is
reachable only when something is LISTENING on a network address AND the
firewall permits it.

On this machine that crossing is the whole story. The rules look
unremarkable -- one zone, three services, a port range -- and what they
mean is that PostgreSQL and Redis, published by rootless containers on
every interface, are reachable by anyone on the network. Neither half
says that alone, which is exactly how a tidy rules list coexists with an
open database. Nothing was misconfigured: Fedora's default zone met
podman's default publish behaviour.

Ephemeral client sockets are excluded. A browser's outbound UDP port is
indistinguishable from a service in ss, and listing twenty of them
buried the two rows that mattered.

Closing the port range names what it would cut off, by service, before
doing it, and removing ssh says so when someone is connected over it.
Rich rules are shown and never edited: a syntax is not a setting, but
hiding it would misrepresent the configuration.

The contract needed a recorded firewall, and the reason is worth
keeping. The rule this page exists for cannot be tested against this
machine -- its zone permits everything above 1024, so "listening" and
"listening and permitted" give identical answers, and a blocked listener
needs a port below 1024, which needs root. With the crossing deleted,
the contract passed. It now runs against a fixture where two listeners
are blocked, and catches it.

Also here: polkit response files are written 0600 rather than at the
default mask, the agent sweeps requests left by an instance that did not
exit cleanly, and the write sweep waits for its harness to be ready
instead of reporting the startup race as settings that failed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:46:16 -04:00
Gabriel Brown a412e3d894 Fix the compositor write contract properly, not by waiting longer
Widening its waits earlier treated the symptom. It still failed about
one run in three, on an idle machine, taking eighteen seconds to do so
-- which was the clue: nothing was in flight to wait for.

SystemSettings verifies each write before storing it and serializes
overlapping ones, so the restore arriving while the previous batch was
still settling was being dropped outright. Waiting longer for a write
that was never queued cannot help. It is issued up to three times now,
and the run where a retry fires visibly takes eight seconds instead of
two.

Retrying cannot hide a broken write path: with the write stubbed out the
contract still reports exactly which policy failed to reach the
compositor.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:05:29 -04:00
Gabriel Brown 4cbe3b882a Add a Gaming page, and let the desktop react to games
Live first, because unlike every other page here this one has a live
dimension: card temperature, power draw, whether Game Mode actually
engaged. It polls only while it is open, since a settings page nobody is
looking at has no business waking the CPU.

The part that makes it Panama's page rather than a gamemode config
editor is the hook. gamemode runs a script when a game asks for it and
another when the game exits, so the power profile switches to
performance and notifications go quiet for exactly the duration of a
game -- and afterwards both go back to what they WERE, not to a default.
A Do Not Disturb someone set by hand survives a game; a power profile
someone chose is restored rather than replaced. Verified against real
gamemode activation, not merely by calling the hook.

Two things the page reports rather than hides. Game Mode's headline
trick is switching the CPU governor to performance, and this machine
already runs performance, so it says so instead of implying it helps.
And Proton builds are listed but never chosen: Steam picks the runtime
per game, and a control here would claim an authority this page does not
have.

The hook first called a notifications function that did not exist, and
the one that did was a TOGGLE -- the wrong primitive entirely, since
toggling at game start would unsilence notifications that were already
silent. The shell gained an explicit setter and reader.

search-routing-contract kept its own hand-written list of every page,
which made adding one fail as "not a known page" -- a sixth place to
register a page and a sixth chance to forget. It now derives the mapping
from the shell, which already knows it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:55:10 -04:00
Gabriel Brown edc504af2e Drop the self-hosted integration batch, and say why
The machine runs essentially every popular self-hosted service, and on
looking at them the batch was revised once and then dropped. Almost all
of it is infrastructure for other services rather than anything a
desktop consumes -- whisper there feeds Bazarr subtitles, not dictation
here -- the desktop has the better GPU anyway, and the one genuinely
desktop-shaped idea is already solved by an installed application.

Recorded rather than deleted so it is not proposed again, with the
lesson that matters: a service existing is not a reason to integrate it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:38:19 -04:00
Gabriel Brown a15f019c17 Offer automatic package downloads, now that the machinery is installed
The page had a row for the case where dnf-automatic is absent and
nothing for the case where it is present, so installing it made the
setting disappear rather than appear.

The switch enables downloading only, which is the shipped default and
the right one to keep: a machine that installs packages unattended can
reboot into a kernel nobody chose. Downloading ahead of time is what
makes the install quick when someone does choose it, and the row says
exactly that rather than implying updates apply themselves.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:24:30 -04:00
Gabriel Brown 116510caa8 Draw the authentication prompt ourselves
hyprpolkitagent's dialog is compiled into its binary -- no config, no
stylesheet, nothing to theme -- and it was the one window on this
desktop that looked like it belonged to something else.

The split between the two halves is the security design, not an
implementation detail. A small agent process owns the D-Bus side: it
registers with polkitd, receives the request, and hands the shell the
action, the message, who may answer, and a one-time cookie. It never
sees a password. The shell draws the prompt and, on submit, spawns the
setuid polkit-agent-helper-1 itself and writes the password to that
helper's stdin; the helper runs the PAM conversation and reports to
polkitd directly. The password exists in the shell and in the helper's
stdin and nowhere else -- never on a command line, never over D-Bus,
never through IPC arguments.

The prompt takes exclusive keyboard focus, because a password field that
lets keystrokes reach the window behind it is a keylogger with extra
steps. The request travels as a file created 0600 with O_EXCL inside a
0700 runtime directory: a cookie is not a password, but it is a
capability, and capabilities do not belong in a process listing either.

Three things cost real time. polkitd calls back on the same connection
that registered, so exporting the object on the session bus while
registering from the system bus failed every request as "Not authorized"
with no error anywhere. XDG_SESSION_ID is absent in a systemd user unit,
which runs under [email protected] and belongs to no login session, so the
session comes from logind's Display property instead. And PyGObject does
not accept the @ placeholder in variant format strings.

hyprpolkitagent stays installed as the fallback, only one agent is
started, and the comment beside the autostart says how to get the stock
prompt back. Verified end to end, including a real password accepted and
three cancellations refused.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:08:52 -04:00
Gabriel Brown b10f8e2593 Stop the compositor write contract failing under load
It passed alone in two seconds and failed in a full suite run at six,
which is the worst way for a test to be wrong: the code was fine and the
report said otherwise.

Each of its waits polls until the compositor reflects a write, and four
seconds was enough on an idle machine and not enough on a busy one. They
are fifteen now. A longer bound cannot mask a dead write, because a
write that never lands never matches -- verified by stubbing the write
path out and watching it still report exactly which policy failed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:33:12 -04:00
Gabriel Brown 8f0fe23377 Add Software Update, across packages, applications and firmware
Three sources that fail independently, so they are counted and applied
separately: a flatpak mirror being down says nothing about whether a
kernel security fix is waiting. Blending them into one number would hide
exactly the case that matters.

Checking costs about nine seconds, which is too long to spend every time
a page opens, so the page opens on the last result and says when it was
taken. A first visit with nothing cached goes and finds out rather than
showing a confident "up to date" it has no basis for.

Installing packages takes a snapshot first, named after what is about to
happen, so Snapshots shows "before 32 package updates" rather than a
timestamp. Best effort: a machine without snapper still updates, because
an update that refuses to run when a nicety fails would be worse than
one without a restore point.

Automatic updates cover applications only, through a Panama-owned user
timer running daily with a randomized delay. Packages still ask, and
dnf-automatic is reported as absent rather than offered, because
installing software is not a settings action.

Health gained a check, and that is where the bug was: it first returned
status "degraded", which is not in the doctor's vocabulary of ok,
warning, error and unconfigured. It was counted as nothing at all while
the summary still said healthy -- the same silent no-op this codebase
keeps relearning. A contract now asserts every status a check can return
is one the doctor counts, and the doctor's own contract knows about the
new check rather than failing on its arrival.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:25:11 -04:00
Gabriel Brown 89cf0f8c29 Plan the settings work beyond GNOME parity
Parity is done except for Color, which the platform blocks. This is the
other direction: what a settings application should own on a machine
whose owner runs the servers.

Every item was checked against this machine before being written down,
including the one that was dropped -- a VPN page, because the WireGuard
tunnel lives on the router and neither wg nor tailscale is installed
here, so it would have had nothing to drive.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:06:39 -04:00
Gabriel Brown e290a262f3 Make notifications legible, and follow windows that ask for focus
The notification background was a 6% foreground tint over a transparent
window, which meant it had nothing behind it at all: the card sat
directly on whatever was on screen and the text competed with it.
Notifications are the one surface someone reads without having chosen to
look at it, so it now uses a real popover surface -- a little lighter
than one you opened deliberately, because a toast arrives unbidden and
full popover weight reads as a dialog demanding an answer.

Windows that ask for attention are now switched to rather than merely
highlighted, so a link opening on another workspace takes you there.
That setting already existed and was simply off; it is a preference
rather than a hardcoded behaviour, so it can be turned back off on the
Desktop & Dock page.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:05:37 -04:00
Gabriel Brown 6997dd535f Add high contrast, and make remote desktop configurable
Two of the three panels still handed to GNOME, having actually checked
each rather than repeating that they were not worth owning.

Universal Access turned out to be mostly ours already: the magnifier,
pointer size, text scale, motion and dimming were all present. High
contrast was the real gap. It reaches GTK4 applications through the
desktop portal, which republishes GNOME's accessibility setting as
org.freedesktop.appearance contrast -- so no high-contrast theme is
involved, and none is installed here. Verified end to end: committing
the preference drove gsettings and the portal reported contrast 1.

Sticky, slow and bounce keys stay absent. There is no Wayland or
Hyprland implementation, and the compositor would store the XKB option
while nothing ever acted on it.

Remote desktop gained port, view-only, and clearing stored credentials.
SETTING credentials opens a terminal running grdctl, which prompts for
the password itself. That is not a hand-off for lack of effort: grdctl
takes the password on a terminal and core-dumps without one, and the
only alternative -- passing it as an argument -- would publish it
through /proc to every process on this machine. Typed into grdctl
directly it never passes through Panama, and a contract now fails if it
ever appears on a command line.

Color stays with GNOME, and not for lack of effort either. colord runs
here with seven profiles and zero devices registered, because the
daemons that register displays do not run under this session, and
Hyprland exposes no ICC, gamma, or color-management option at all. A
Color page could import a profile, attach it to nothing, and change
nothing -- the same failure refused for rollback and printer drivers.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 16:42:20 -04:00
Gabriel Brown 1f40f8e136 Add a snapshot manager, and start covering home
The machine already had snapper running hourly on btrfs, so the tool was
never missing. What was missing is that snapper's only configuration
covered / -- and /home is a separate subvolume with no configuration at
all. Six hundred and forty-three snapshots existed and not one of them
contained a document. Anyone reaching for file history would have found
their system and none of their files.

/home now has a configuration on the same hourly timeline, with
deliberately conservative retention: Steam's 1.2 TB lives on that
subvolume and churns on every game update, so keeping five hourly and
seven daily bounds what those updates can pin.

Per volume, because on this machine "one is covered and the important
one is not" was the news, and a timeline opening on system snapshots
would have buried it. Inside a volume the timeline is the familiar view:
points in time, newest first, each openable as a folder tree to take a
file out of.

Restoring sets the current version aside as .before-restore-N rather
than overwriting it. A restore that destroys the thing you were about to
compare against is how someone loses the work they were trying to save.

Rollback is deliberately absent. snapper's rollback changes the btrfs
default subvolume, and this system's fstab pins subvol= explicitly,
which overrides it -- so a rollback would report success and change
nothing after a reboot. A recovery feature that silently does nothing is
worse than not having one, and making it work means editing fstab and
the bootloader, whose failure cannot be repaired from inside the
desktop.

Per-snapshot size is reported as not measured, because measuring it
needs btrfs quotas that cost performance on every write. Free space is
shown instead, which is the number that decides anything.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 16:11:50 -04:00
Gabriel Brown e9d567aa72 Own printers, driverless only
The fourth panel this desktop handed to GNOME Settings, and the last one
worth owning.

Driverless only, deliberately. Adding a printer that describes its own
capabilities over IPP is supported; choosing a PPD or fetching a vendor
driver is not, and the page says so rather than pretending. That
restraint is the whole design: a wrong driver produces a printer that
accepts jobs, reports success, and prints nothing, which is the worst
failure this page could ship because it looks like it worked. A printer
old enough to need a PPD stays a job for the system printer tool.

Printers and the queue are separate cards because they answer separate
questions. Which printers exist is one; where a document went is the
other, and it is the one that actually brings someone here -- so the
queue is a single list across every printer.

Device URIs are validated by scheme before reaching CUPS, whose backends
run as root. file: and pipe: do not lead to a printer and are refused
here rather than further down.

This machine has no printer, so the page was built against a temporary
CUPS queue that was created, exercised through the service, and removed;
the service was confirmed to observe the removal rather than merely
perform it. Discovery and the driverless add path are verified by their
refusals rather than against hardware.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 15:07:17 -04:00
Gabriel Brown 914d58f52b Record which GNOME panels are owned and which are not
Which panels we own is a question that otherwise gets answered one panel
at a time, and forgotten between them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 13:17:53 -04:00
Gabriel Brown a23b42841a Own user accounts and sharing
Two of the panels this desktop still handed to GNOME Settings.

Users manages the account through accountsservice -- the same daemon
GNOME's panel drives, so a name or picture set here is what the login
screen and lock screen read. Name, picture, account type, password,
automatic login, and adding or removing other accounts. Every change is
authorized by polkit through the agent this session already runs; a
dismissed prompt is a normal outcome and says so.

A new password is read from the helper's stdin, hashed by openssl
reading its own stdin, and handed over D-Bus from inside that process.
It is never an argument: argv is world-readable through /proc, so a
password passed that way is published to every process on the machine.
Removing an account takes two presses and says it destroys their files;
the last administrator cannot be removed or demoted, because a machine
nobody can administer is not a state to offer.

Sharing reports what is actually true, including "the software for this
is not installed" -- the honest answer for Samba here, and the case the
panel it replaces shows as a switch that does nothing. Password sign-in
is reported from sshd's configuration rather than assumed: claiming
"keys only" when the file is silent would state a security property that
cannot be backed up.

The Control Center now draws the account's real picture and name. A
generic glyph sat there while a real avatar was already set, which made
the desktop look like it did not know whose it was.

Also here: the KDE Connect contract no longer requires a phone to be
awake. kdeconnectd drops its device objects for a phone it has not seen
recently while the pairing survives in its config, so demanding one
failed whenever the phone was off.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 13:05:13 -04:00
Gabriel Brown e0c0e53ae0 Say what the sweep helper is, when run on its own
A suite runner walking the tests directory executes it with no arguments
and gets an IndexError, which reads as a failing test rather than a
helper being used wrongly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 12:22:38 -04:00
Gabriel Brown 180308135a Verify every setting actually does something
The audit that followed "some things in the settings app don't work".

Sixty-five settings reach the compositor and five of them were checked
against it. The rest were covered only by tests that read source text,
which is exactly where a dead setting hides: the write no-ops, nothing
fails, nothing logs, and the row simply does nothing.

The sweep drives each setting through SystemSettings.commitPreference --
the entry point a settings row uses -- flips it to a value it does not
hold, reads it back from the live compositor, and puts it straight back
before touching the next one. Settings Panama stores itself get the same
treatment against the store, since a value that fails to persist is the
same dead row from the outside.

Result: 61 of 63 compositor settings verified against the running
compositor, and 51 stored settings round-tripped. No failures. The
breakage was confined to the Applications page, which is fixed.

Proven able to fail before being trusted: with commitPreference stubbed
to return true without applying, 61 settings are reported; with the
store stubbed to return nothing, 51 are. A one-second settle window
keeps a slow read from being reported as a dead write, which it briefly
was.

Also here: control-center-contract asserted the literal margin
expression that made the panel hang 38 pixels below the bar, so the
contract and the code agreed and the bug was invisible to both. And
settings-page-registry-contract is deleted -- settings-nav-contract
already checked those files and more. It would have caught the Storage
page omission if I had run the suite instead of a hand-picked subset.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 12:17:27 -04:00
Gabriel Brown b743f44c5b Make changing a default application actually take effect
Three bugs, all silent, all in the same feature.

The write always worked. What failed was the refresh after it. That
refresh is called from the mutation's own onExited handler and was
guarded on `busy`, a binding over both processes -- and a binding hands
back its cached value until the change notification feeding it has been
delivered, which inside that handler has not happened yet. So `busy`
read true, refresh returned immediately, and the page kept showing the
old application with no error anywhere. Guards now read the Process
objects directly, where the value is current, and a refresh is no longer
blocked by the mutation that asked for it.

The service also kept its own list of which roles it would accept. It
stayed at seven when the helper and the page grew documents, text and
archives, so choosing a PDF viewer set an error and changed nothing.
It is derived from the snapshot now.

And category matching never worked. DesktopEntries returns a QML list,
for which Array.isArray is false, so the code stringified it into
"Network,WebBrowser" and split on ";" alone -- one token matching no
category. Browsers still appeared because their generic name contains
"web browser" and the terms fallback carried the role by itself.
Archives matched nothing at all, so that row could only ever offer the
application it already had.

The harness that should have caught the first bug passed while it was
live: it set each role to the value it already had and asserted no error
appeared, and the bug produces no error. It now changes a role to a
genuinely different application, requires the service to observe the new
value, and changes it back -- with the contract restoring the original
from the outside however the run ends.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:59:53 -04:00
Gabriel Brown 2528edfddc Stop every popover double-counting the bar height
The control center fix applies to five more surfaces: the date menu,
the clipboard panel, the activity panel, notification toasts, and the
signal glass all opened 48 pixels under the bar while their own
expression asked for 12.

wlr-layer-shell has three behaviors and only the middle one is subtle.
A positive exclusiveZone reserves space; a negative one ignores what
others reserved; zero reserves nothing but RESPECTS what others
reserved. Every popover here uses zero, so the compositor had already
placed them below the bar before their own margin applied, and adding
Theme.barHeight counted the bar twice.

It is not a crash or a warning -- the surface simply opens lower than
written -- so a contract now holds the rule mechanically: a surface
with exclusiveZone 0 may not name Theme.barHeight in a margin. It also
checks the premise it rests on, and fails if the bar ever stops
reserving its own height rather than quietly checking the wrong thing.

Measured after: date menu and clipboard at 12px, control center at 2px,
each matching what its code asks for.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:31:53 -04:00
Gabriel Brown dbd1472e6b Read busctl as JSON so device names keep their characters
A phone named "Gib's iPhone" with a typographic apostrophe was shown as
"Gib\342\200\231s iPhone".

busctl's default text output escapes every non-ASCII byte in octal, and
escapes it into the output rather than into a quoted string a
shell-style parser can undo, so shlex handed back the escape sequences
as literal characters and they went straight to the page. Apostrophes
were only the visible case: accents, emoji, quotes and backslashes were
all affected, and a name containing a quote could have split a field.

Property and method reads now use --json=short, which returns real
UTF-8, and the parsers read a document rather than splitting words.
That removes the class rather than unescaping octal by hand.

The fixtures were the reason this stayed invisible: every test fed the
text form and passed against output the helper is no longer asking for.
They now carry what busctl actually emits in the mode used, plus a case
for a non-ASCII name and one asserting the old text form is refused
rather than parsed wrongly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:28:36 -04:00
Gabriel Brown eeb49c5aff Hang the control center under the bar instead of below it
The panel opened 38 pixels beneath the bar while its own comment said
two. margins.top added Theme.barHeight to the gap, but an exclusiveZone
of 0 means "reserve nothing, respect what others reserved" -- so the
surface already began below the bar's 36px zone and the bar height was
counted twice.

The margin is now the gap alone, which also means the panel follows the
bar: if the bar ever stops reserving space, this closes up against the
top edge rather than hanging under nothing.

Measured before and after against hyprctl layers: bar bottom at y=36,
panel top from y=74 to y=38.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:23:05 -04:00
Gabriel Brown 8e93f08977 Show what the keyring holds, without showing what it holds
Managing a stored credential meant installing Seahorse. The keyring
rows on Privacy could say whether it was locked and nothing about what
was in it.

Four rules, each pinned by a contract, because each is a way this could
leak the thing it exists to protect:

Listing never reads values. Enumerating reports labels and attributes;
it does not ask the keyring to hand over what it is protecting.

A secret never reaches a command line. /proc makes argv readable by
every process on this machine, so a password passed as an argument is
published to all of them. The helper reads the value in process and
writes it to wl-copy on stdin.

A secret never reaches an error message, a log, or a QML property. An
exception raised while holding a password does not get to choose what
text is printed, so the clipboard tool's stderr is discarded rather
than echoed.

Forgetting one is irreversible, so the first press asks and the second
does it, and the confirming button is the only one wearing danger.

The list is collapsed until asked for: opening Privacy should not
enumerate someone's passwords as a side effect. A copied value clears
itself about a minute later, but only if the clipboard still holds it --
the guard compares a SHA-256, so the waiting process never has the
password.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:14:32 -04:00
Gabriel Brown 99433c0e8e Add a Storage page
Nothing showed what was using the drive, and removable media was handled
by a tray helper with no surface in Settings at all.

One scroll rather than tabs: space above, the device below. Every other
settings page is a scrolling card stack, and a tab would not be
deep-linkable from the launcher command or from search.

Three things the page has to get right, each now pinned by a contract,
because each is a way it could quietly lie. / and /home are one btrfs
filesystem sharing one pool of free space, and a page that copies df
shows double the free space that exists. zram is a block device and is
not storage; counting it as a drive overstates this machine by 8 GB.
Unmount and eject refuse anything not on a removable drive, because the
UI is what asks and a UI can be wrong.

The cheap read -- layout, usage, health -- runs when the page opens, at
around 90ms. Measuring what is filling the drive means walking every
file, so it happens on request and says so rather than showing an empty
list that reads as "nothing here".

Partitioning and formatting are deliberately absent. A settings pane is
the wrong place to put erasing a disk two clicks deep; the page opens
GNOME Disks for that.

Adding the page found a fourth hard-coded page list in ShellState. A
page missing from it does not error -- openSettings() falls back to
"home", so the launcher opens the wrong page and logs nothing. A
registry contract now holds the three lists together.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 10:52:10 -04:00
Gabriel Brown a68e4f6dcd Make every settings page reachable from the launcher
Settings had a search index and the launcher had script commands, and
neither knew about the other: finding a setting meant opening Settings
first and searching there. One command per page is now generated from
the same sources the in-app search reads -- the sidebar page list, the
group routing, and the schema labels -- so a setting searchable inside
Settings is searchable from the launcher without a second list to
maintain.

Generating it surfaced a gap in the in-app search as well. Six pages had
no vocabulary at all, because their contents come from the system rather
than our schema, so searching "volume" found nothing in Settings either.
They now carry entries, which improves both surfaces.

The product prefix is dropped from every hand-written command title. The
generated ones are qualified "Settings: <page>" instead: a bare page
label collides with the feature of the same name, and two commands
sharing a title are indistinguishable in a launcher.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:47:07 -04:00
Gabriel Brown 2b3b762793 Install what the default-application roles are curated to
Seeding is deliberately conservative: a role whose preferred applications
are all missing is left alone rather than forced. That is right, and it
is also silent -- so a curated handler nobody installs presents as the
machine quietly going back to deciding defaults by installation order,
which is the problem seeding exists to fix. None of Loupe, Papers,
Decibels, Nautilus, mpv or sushi was declared anywhere.

Preview works, on org.gnome.NautilusPreviewer2 rather than the interface
its bus name suggests, but it opened tiled -- shoving the file manager
aside for something meant to be an overlay -- so it gets a float and
center rule sized to leave the file underneath visible.

xdg-utils and desktop-file-utils were undeclared too; seeding from
link-dotfiles started calling them, and the dependency contract said so.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:35:12 -04:00
Gabriel Brown 91306ce810 Give libadwaita applications the desktop's accent
Files, Papers, Loupe and every other libadwaita application read their
accent from the Settings portal, so they rendered in GNOME blue no
matter which accent this desktop was set to -- correct on our own
surfaces, wrong on half the screen.

xdg-desktop-portal-gtk cannot serve org.freedesktop.appearance
accent-color at all; the string does not appear in the 1.15.3 binary.
The gnome backend serves it, so Settings now routes to gnome with gtk
still listed behind it -- the frontend merges Settings backends in
order, so color-scheme keeps resolving if the gnome backend is ever
unavailable.

GNOME's accent-color is a fixed enum of nine names rather than a color,
so each of our eight accents carries its nearest member. Nearest by hue
rather than by name: rose maps to red, because it is the red role in
this palette.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:31:50 -04:00
Gabriel Brown 1b94af1163 Plan the desktop integration work
Six areas an OS is expected to own that this desktop still delegates:
preview, GTK accent cohesion, storage, launcher search, secrets, and
backups. Ordered so each is useful the day it lands.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:19:40 -04:00
Gabriel Brown b4ce148caf Give each default-application role a whole family of types
Every role carried a single representative type, so setting "Images"
changed image/png and left image/jpeg wherever it landed. That is how
this desktop ended up opening PDFs in GIMP, PNGs in a pixel-art editor
and MP3s in a video transcoder: nobody chose any of it, applications
registered themselves for everything they could read, and the roles
governed one type each.

Roles now own families and write every type when set, the settings page
exposes the documents, text and archives roles it never offered, and a
new seed command curates a fresh machine during setup while always
keeping a choice the user has already made.

The shipped editor entry launches kitty explicitly. The stock
nvim.desktop sets Terminal=true, which defers to whatever the system
considers default rather than the terminal this desktop themes.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:17:33 -04:00
Gabriel Brown 2fcaada7e8 Snapshot automatically before restoring defaults
Restoring defaults clears every preference and the Home accessory store,
and nothing in the app can undo it. Snapshots existed but were entirely
manual, so the one irreversible action Panama offers was also the one
with no safety net.

It now snapshots first. Not fatal if that fails: someone who asked to
reset gets their reset, and a snapshot that could not be written is
reported rather than allowed to block what they asked for.

The wiring is inverted deliberately. SettingsBackup already references
SystemSettings, so referencing it back would make two singletons depend
on each other, which is an initialisation-order problem waiting to
happen. Instead SystemSettings exposes a seam defaulting to a no-op and
SettingsBackup installs itself into it at startup -- the same shape as
the seams the reset path already uses for test isolation.

The contract asserts the snapshot is FIRST in the call sequence, not
merely present. A snapshot taken after the stores were cleared would
faithfully record the wiped state as the user's own, which is worse than
no snapshot: it looks like a safety net and is a copy of the damage.
Verified against both mutations -- removing the snapshot, and moving it
after the wipe.

One trap, hit for the third time today: QML allows only one
Component.onCompleted per object, and SettingsBackup already had one.
Adding a second does not fail locally -- it poisons the entire services
module, so every singleton reports "Type X unavailable" and the real
error is the last line of a forty-line cascade.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 08:37:43 -04:00
Gabriel Brown 588dec4adc Generate the settings reference from the schema
Every other form of documentation here has drifted at least once today:
search routing that pointed at a page not containing the setting, a
contract that pinned the bug the same commit fixed, and a comment in
shell.qml that failed to stop me making the exact mistake it described.
Prose describing 127 settings would drift the day after it was written.

So docs/settings.md is generated, and a contract fails the moment the
committed copy stops matching the schema. The document cannot be wrong
for longer than it takes to run the suite.

It reads the schema by parsing rather than importing, since there is no
QML interpreter here and requiring a compositor to build documentation
would be worse. That parser is the risk, so it FAILS LOUDLY: if it stops
recognising the file it exits non-zero with the reason and writes
nothing, because a partial reference is worse than a stale one -- stale
is caught by --check, partial reads as complete. Verified: with the
entry pattern broken it reports "only 0 entries parsed" and leaves the
committed file untouched.

The contract also proves --check actually compares content, by appending
a line and confirming it fails, rather than trusting a command that
returns success to mean anything.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 08:37:43 -04:00
Gabriel Brown d96863b687 Convert British spellings to American across the repo
colour -> color, behaviour -> behavior, centre -> center, favourite ->
favorite, and about twenty other pairs, applied consistently across
comments, docs, error/UI copy, and a handful of QML identifiers that
used the British spelling as their actual name: SystemSettings'
serialiseValue/serialiseTable/normaliseGradient, Displays'
normaliseModes, Wallpaper's normalisePolicy, SettingsBackup's
serialiseHomeState, DateTime's ntpSynchronised property, Clipboard's
_normalise helper, and ShortcutCapture's cancelled signal (with its
onCancelled handler in ShortcutsPage.qml). Every call site and the two
tests that assert on the literal source text (settings-ownership and
settings-backup-live contracts) were updated in lockstep.

Left untouched: config/dot/espanso/match/packages/misspell-en/ is a
vendored third-party autocorrect dictionary -- its entries are typo
corrections, not our prose, and rewriting them would fight the
package's own purpose (and any future re-sync from upstream).

The already-American `favorites` property (Home page pinned
accessories) was never actually misspelled -- only nearby comments and
error strings said "favourites" -- so no data migration was needed
there.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-19 08:07:55 -04:00
Gabriel Brown 8156fc47ca Fix a false Nextcloud health warning
Two compounding bugs: the check looked for an autostart entry named
"nextcloud.desktop", but current nextcloud-client packages ship it
Title-cased as "Nextcloud.desktop" -- a case-sensitive filesystem
never matched, so it always reported "autostart is not configured"
even with autostart genuinely on. Made the lookup case-insensitive so
a future package rename doesn't reintroduce this.

Second, even past that, it ran systemctl against "nextcloud.service" --
a unit that doesn't exist in either scope, because the client is a
plain autostarted process with no systemd unit behind it at all (same
reasoning as the RustDesk autostart.lua comment, different shape: no
service to query rather than the wrong scope). Added a process_check
helper alongside the existing service_check and switched Nextcloud to
it; confirmed live it now reports "Process is running."

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-19 07:53:04 -04:00
Gabriel Brown 9bc771bcca Fix a false RustDesk health warning
The check queried `systemctl --user is-active rustdesk.service`, but
RustDesk ships an enabled *system* service (root-owned, spawns the
session --server/--tray on its own -- see autostart.lua's comment on
why Panama doesn't start it a second time). No user-scope unit by
that name exists, so the check always reported inactive regardless of
whether RustDesk was actually running. Dropped --user; confirmed live
that a plain `systemctl is-active` correctly reads the system unit
without needing root, and the doctor now reports "Service is active."

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-19 07:41:27 -04:00
Gabriel Brown fe74a82a46 Route the Background portal to xdg-desktop-portal-gnome
Nothing served org.freedesktop.impl.portal.Background under Hyprland:
gnome.portal implements it but is UseIn=gnome, same situation the
Secret mapping below already solves for gnome-keyring. Every Flatpak
app's own "run/start on login" toggle goes through this portal, so it
silently failed for all of them -- Bitwarden logged the failure on
every launch.

Verified live rather than assumed: xdg-desktop-portal-gnome starts as
a standalone backend process with no dependency on GNOME Shell or
gnome-session, and after adding the mapping and restarting the portal,
Bitwarden's own autostart request succeeded end to end
(response=Background { background: true, autostart: true }) where it
previously errored with "portal frontend ... was not found".

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 22:32:53 -04:00
Gabriel Brown ac231eeb54 Stop double-launching Nextcloud and Bitwarden at session start
Both already have a ~/.config/autostart/*.desktop entry, and systemd's
own xdg-autostart generator turns that into a graphical-session.target
unit that fires on its own -- confirmed live via `systemctl --user
list-units 'app-*@autostart.service'`. autostart.lua was also launching
both explicitly, on the (apparently outdated) assumption that nothing
else would. For Bitwarden this was actively harmful: each `flatpak run`
gets its own sandbox instance, so the duplicate launch left two
processes fighting over the app's single-instance lock, with neither
reliably owning a usable window -- this is what "can't open Bitwarden"
traced back to, alongside a live document-portal fuse mount that had
silently died (fixed by restarting xdg-document-portal.service; not a
config issue, so nothing to commit there).

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:35:31 -04:00
Gabriel Brown 8cf03d4529 Remove dead shell components and fix stale docs
NotificationCenter.qml, CalendarPopup.qml, and RecordingIndicator.qml
were never instantiated anywhere -- shell.qml builds NotificationList,
DateMenu, and CaptureOverlay in their place. Verified with a repo-wide
grep before deleting; updated the two stale comments in Notifs.qml
that still pointed at NotificationCenter.

DESKTOP-PARITY.md still described Wi-Fi QR sharing as deliberately
omitted, though it was since built. The System Health colour-profile
handoff pointed at GNOME's colour panel as if it worked, but the
daemon that actually loads an ICC profile doesn't run in this session,
so it silently does nothing -- reworded the card to say so. Displays
carried two verify-timer attempt counters that were incremented but
never read anywhere.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:32 -04:00
Gabriel Brown 2a716dac9e Fix correctness bugs across the helper scripts
panama-osd read the wrong brightnessctl field, showing the hardware
max instead of a percentage on any backlight device. panama-doctor
called three sibling scripts by bare name with nothing on PATH,
making three health checks permanently and falsely report broken; its
repair actions also reused the short probe timeout, so a slow-but-
successful restart was reported as failed. panama-wifi-qr left the
cleartext passphrase temp file behind on its failure path (the RETURN
trap doesn't fire on exit), and its nmcli parsing broke on connection
names containing a colon or backslash -- verified against a real
NetworkManager profile.

panama-power-profile's set command always returned success regardless
of whether the write actually took. panama-keyring's daemon-origin
check picked whichever gnome-keyring-daemon process happened to
enumerate first in /proc, defeating the exact dual-daemon scenario it
exists to detect; it now resolves the PID that actually owns the
Secret Service D-Bus name. gnf aborted before running a firmware
update whenever the metadata was already current (a non-error exit
under set -e), and its flatpak update lacked the -y its own docs
promise.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:28 -04:00
Gabriel Brown 719ef2f38e Fix a keybind collision and wire two orphaned prefs
SUPER+SHIFT+P was bound to both the colour picker and a window-resize
action; moved the resize bind to SUPER+SHIFT+Comma, next to its
existing N alias, and updated the README's keymap table to match.

workspaceBackAndForth and allowWorkspaceCycles were declared in the
preference schema but read nowhere, so they had no effect regardless
of what a user set them to; wired both into keybinds.lua's workspace
config, which is where they actually apply.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:19 -04:00
Gabriel Brown 9ba224d776 Finish threading the accent colour through the whole desktop
The recent accent-colour setting only reached part of the desktop.
looks.lua still hardcoded the focused-window border and glow to blue,
so any hyprctl reload -- or every fresh session, for about a second --
reverted a chosen accent; it now reads accentName the same way it
already read colorScheme. The lock screen and terminal stayed blue
regardless of the chosen accent despite the setting's own description
claiming otherwise; panama-lock and panama-theme-apps now resolve and
apply the real accent.

AccentPicker built its swatch model from Theme.accents directly
instead of the schema's own options list, so the two could drift
silently; switched it to read the schema. Its hit target only covered
the swatch, not the name label added specifically for colour-vision
accessibility -- extended to the whole row. Settings search had no
route for the "appearance" group, so searching for the accent or
colour scheme landed on Home.

ColorScheme's hex-to-Hyprland helper assumed 6-digit colours and would
silently corrupt a future translucent one; fixed it to read from the
end of the string instead of the start. An accent-only change no
longer reruns the full colour-scheme pipeline. The gradient it builds
for the focused border now goes through SystemSettings' existing
serialiser instead of a second, under-escaped copy of the same logic.

The settings-ownership contract test enforced the old rule that
ColorScheme must never touch the focused border; updated it to verify
the real, intended rule instead of contradicting the code.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:16 -04:00
Gabriel Brown 8b59b78d9f Settle process-signal races across the services layer
A Process's exited and streamFinished signals aren't guaranteed to
fire in order, and several services decided an outcome on whichever
fired first: KdeConnect could report a successful file transfer as
failed if exited landed before the real stdout payload; Clipboard
could present a failed history query as an empty-but-healthy one;
Brightness could strand the last queued write of a drag; SoundFeedback
and SystemLocale could drop or misapply a rapid second toggle/click
because re-arming an already-running Process is a no-op. All five now
wait for both signals and let the authoritative one decide, matching
the pattern HomeAssistantConfig.qml already used correctly.

Health's "copy report" never enabled stdin, so it copied nothing
while claiming success. Capture announced every recording as saved
regardless of the recorder's actual exit code. Connectivity never
restarted Bluetooth discovery when the adapter was enabled from an
already-open page. CalendarAgenda left the UI in "loading" forever if
its helper died at startup, and the helper itself could crash
unguarded instead of reporting unavailable. Geocoding silently
dropped a query typed while the previous one was still in flight.
Notifs leaked tracked-but-undisplayed notifications under Do Not
Disturb, and dismissAll() skipped them.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:07 -04:00
Gabriel Brown e6b4d3c1a1 Stop lists and sliders from losing input under the user
WifiList and the notification toasts built their model from a plain
computed array, so any background property tick (a scan result, an
unrelated notification arriving) reassigned the whole array and the
Repeater destroyed and recreated every delegate -- including one with
an open, focused password field or an in-progress reply. Switched
both to a ScriptModel, which diffs by identity instead of resetting.

ValueSlider had its pointer-to-value mapping offset by 16px (the
hit-area margin was applied with the wrong sign), so 0% was
unreachable and every click landed to the right of where it was
placed -- affects every slider in the shell. SliderRow used -1 as a
sentinel for "nothing pending," which collides with legitimate
negative preference values like pointer sensitivity.

Dock intellihide read the globally focused workspace instead of each
monitor's own, so an empty workspace on one screen could hide the
dock on another; ActivityPanel rebuilt every row once a second during
a recording because the elapsed-time read lived in the model
construction instead of each row's own binding.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:22:58 -04:00
Gabriel Brown 70d8d32ee2 Fix the install pipeline and an idle-lock startup race
The initial package list was quoted into a single bogus dnf argument
and every dnf error was discarded, so a fresh install silently skipped
most of it. Two package lists were never wired into the pipeline at
all, and change-settings ran before install-packages, so the vicinae
theme step was permanently skipped. Fixed the ordering, the quoting,
and stopped swallowing errors.

Separately, hypridle could start with its WAYLAND_DISPLAY condition
unmet if it raced the env-publish call, silently never starting --
and it's the only listener for the logind Lock signal. Made the start
wait on the environment synchronously. panama-idle also wrote its
generated config to a fixed temp path with no locking, so concurrent
applies could interleave into a corrupt file; switched to mktemp plus
an atomic mv.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:22:52 -04:00
Gabriel Brown c37ca3baee Make the accent colour choosable
Phase 4, first slice. Theme.qml hardcoded the Prism pair, so the one
thing that carries every state meaning in the desktop -- focused, active,
on -- was the one thing nobody could change. 74 files read Theme.accent,
so making it a setting moves all of them at once.

Named accents rather than a colour picker, which is the design decision
worth defending. One hex cannot serve both schemes: a colour legible on
the Moon background is usually illegible on the Day one, and a picker
that lets someone build an unreadable desktop is not a feature. So each
name carries a curated pair per scheme, and every one of the sixteen
resulting colours measures at least 3:1 against the ground it sits on --
checked, not assumed. It is also GNOME's model, which is the parity
being chased.

The focused window border comes with it, and only because the ownership
rule made that safe. ColorScheme owns the inactive border as a
scheme-relative contrast role; the focused Prism border is the accent
role owned by the theme. Writing it from the accent would have been
reckless before that boundary existed, since a scheme change would have
erased the user's choice. Both borders are now pushed together, because
each accent carries separate light and dark pairs, so switching schemes
must restate the focused border too.

The gradient is written as a Lua table, not a string. The string form
carries only one stop, and passing two as a string is accepted and
silently keeps the previous value.

Swatches are drawn as the gradient they produce rather than as flat
dots, because the gradient is what is being chosen. Each carries its
name permanently rather than in a tooltip: telling swatches apart by
colour is precisely what someone with a colour vision deficiency cannot
do, which is also why the palette is named in the first place.

Verified end to end by switching to rose and watching the compositor
report eeff757f/eec099ff, then reverting.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 17:02:18 -04:00
Gabriel Brown bd5d030d91 Put power profiles and the colour scheme in the Control Center
The panel is for "change it now" decisions, and two of the most obvious
were reachable only through Settings. Night Light was already here;
these were the genuine gaps.

The colour scheme is a grid toggle beside it. It writes the preference
and stops -- ColorScheme propagates the change to GTK, the portal, the
terminals, the launcher, btop, tmux and the lock screen, and nothing in
the panel needs to know that list. Light is the lit state because dark
is what Panama ships, and "active" reads as the non-default everywhere
else in this grid.

Power profiles are a summary row that expands into a list, matching how
the audio device lists behave. A row per profile rather than a cycling
button: there are three, and cycling passes through one you did not want
on a machine where the change is immediate and audible. The row hides
entirely where no power-profiles daemon is running, and the panel
re-reads the active profile on open, because the daemon owns it and
anything on the system can change it.

Worth recording, because it invalidates something I believed earlier in
this session: the live shell runs `quickshell --daemonize` and does NOT
hot-reload. Editing a file under config/dot/quickshell changes nothing
until the shell is restarted. The PID changes I had taken for hot
reloads were tests killing and restarting it. Both of these controls
were written, verified in a harness, and completely absent from the
running panel until the shell was restarted.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 16:25:59 -04:00
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
421 changed files with 39599 additions and 2112 deletions
+7 -1
View File
@@ -14,5 +14,11 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
# Generated from the colour scheme; machine state, not configuration. # Generated from the color scheme; machine state, not configuration.
/config/dot/kitty/current-theme.conf /config/dot/kitty/current-theme.conf
# Generated from the color scheme; machine state, not configuration.
/config/dot/gtk-3.0/settings.ini
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf
+1 -1
View File
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
| Piece | What it is | | Piece | What it is |
|---|---| |---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README | | `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, 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/vicinae/` | Raycast-style launcher, themed |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) | | `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start | | `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
+5 -2
View File
@@ -152,11 +152,14 @@ case "$cmd" in
# 2.4) Run flatpak updates (user then system) # 2.4) Run flatpak updates (user then system)
flatpak update -y flatpak update -y
sudo flatpak update sudo flatpak update -y
# 2.5) Optional firmware via fwupd # 2.5) Optional firmware via fwupd
if $firmware; then if $firmware; then
sudo fwupdmgr refresh # fwupdmgr exits non-zero when metadata is already current -- that is
# not an error, but under 'set -e' it would abort the script before
# 'fwupdmgr update' ever runs.
sudo fwupdmgr refresh || true
sudo fwupdmgr update sudo fwupdmgr update
fi fi
@@ -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 colors as kitty/themes/tokyonight-day.conf so the terminal
# and what runs inside it cannot disagree.
#
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
# 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 color
# scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
# 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] [Settings]
# These values must match `gsettings get org.gnome.desktop.interface ...`. # 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 # this file becomes authoritative for GTK3 — which is why it previously named
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything # themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
# appearing broken. # appearing broken.
gtk-theme-name=adw-gtk3-dark gtk-theme-name=@GTK_THEME@
gtk-icon-theme-name=Adwaita gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11 gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24 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-style=GTK_TOOLBAR_ICONS
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR 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 color
# scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
# 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
+32 -7
View File
@@ -15,15 +15,15 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Hidden applications | GNOME App Hider list in Vicinae provider config | Live | | Hidden applications | GNOME App Hider list in Vicinae provider config | Live |
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live | | Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live | | Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live | | Minimize | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
| Dock | Quickshell dock with GNOME favourites and intellihide | Live | | Dock | Quickshell dock with GNOME favorites and intellihide | Live |
| Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live | | Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live |
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live | | Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live |
| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live | | Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live |
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live | | Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live | | Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live | | Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live | | Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favorites, and KDE Connect phone continuity | Live |
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live | | Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor | | Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live | | AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
@@ -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 | | Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live | | Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | 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; labeled GNOME hardware/account handoffs | Live |
| System health and recovery | Settings → System Health, `Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
## 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 **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, color
profiles, and digital wellbeing to their exact GNOME Settings panels rather
than presenting inert Hyprland controls.
## GNOME extension migration ## GNOME extension migration
@@ -52,9 +77,9 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Caffeine | Replaced by a real logind inhibitor in quick settings | | Caffeine | Replaced by a real logind inhibitor in quick settings |
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur | | Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker | | Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface | | Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable | | GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup | | Home Assistant | Replaced by secure favorites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port | | Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
| App Hider | Hidden app list is now applied in Vicinae | | App Hider | Hidden app list is now applied in Vicinae |
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test | | Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
@@ -86,4 +111,4 @@ menu for a minority, which is worse than omitting it. Relevant references:
- <https://github.com/hyprwm/Hyprland/discussions/1358> - <https://github.com/hyprwm/Hyprland/discussions/1358>
A future focused-window menu could expose compositor actions such as float, A future focused-window menu could expose compositor actions such as float,
move, fullscreen, and close. It should not be labelled as an application menu. move, fullscreen, and close. It should not be labeled as an application menu.
+21 -21
View File
@@ -27,10 +27,10 @@ Don't "fix" them.
| File | Contents | | File | Contents |
|---|---| |---|---|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope | | `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 | | `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision | | `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout | | `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME | | `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
| `rules.lua` | Window rules, gaming rules, layer rules for the shell | | `rules.lua` | Window rules, gaming rules, layer rules for the shell |
| `keybinds.lua` | The full keymap | | `keybinds.lua` | The full keymap |
@@ -59,7 +59,7 @@ generated elsewhere:
`quickshell/scripts/panama-idle` regenerates the hypridle config from the `quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory 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; 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. controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with: 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 `prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all. no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it. - **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. live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to To add an adjustable setting: add an entry to
@@ -87,7 +87,7 @@ persistence, validation, reset, and the live write are all derived from that
entry. entry.
`prefs.lua` never raises. A missing, empty, truncated, malformed, or `prefs.lua` never raises. A missing, empty, truncated, malformed, or
wrong-typed settings file costs you your customisations and nothing else; wrong-typed settings file costs you your customizations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still `tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states. accepts the config in each of those states.
@@ -95,7 +95,7 @@ accepts the config in each of those states.
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that 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 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 ```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) 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 Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and 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. Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so `tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently. this cannot regress silently.
@@ -163,7 +163,7 @@ four places, and nowhere else —
dock, popovers), running blue on the left to orchid on the right and fading dock, popovers), running blue on the left to orchid on the right and fading
out before the corners. See `quickshell/widgets/PrismEdge.qml`. out before the corners. See `quickshell/widgets/PrismEdge.qml`.
2. The focused window's border — `general.col.active_border`, blue→orchid at 2. The focused window's border — `general.col.active_border`, blue→orchid at
115°. Unfocused windows get no colour at all, because the gradient only 115°. Unfocused windows get no color at all, because the gradient only
means something if one window on screen is wearing it. means something if one window on screen is wearing it.
3. The active workspace pill in the bar. 3. The active workspace pill in the bar.
4. Slider fills. 4. Slider fills.
@@ -179,9 +179,9 @@ which is the opposite of the intent.
The Nerd Font is still used, but only to draw **icon glyphs** — it is the The Nerd Font is still used, but only to draw **icon glyphs** — it is the
pragmatic alternative to freedesktop symbolic icons, which ship with a pragmatic alternative to freedesktop symbolic icons, which ship with a
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolour. Where a hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolor. Where a
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml` themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
paints a palette colour through the icon's alpha. paints a palette color through the icon's alpha.
Anything whose digits change in place — the clock, the vitals percentages, the Anything whose digits change in place — the clock, the vitals percentages, the
recording timer, the selection readout — sets `font.features: recording timer, the selection readout — sets `font.features:
@@ -247,7 +247,7 @@ The mental model is unchanged from Forge:
| `SUPER + SHIFT + H/J/K/L` | Move window | | `SUPER + SHIFT + H/J/K/L` | Move window |
| `SUPER + CTRL + H/J/K/L` | Swap window | | `SUPER + CTRL + H/J/K/L` | Swap window |
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower | | `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter | | `SUPER + SHIFT + I/U` · `,/N` | Taller · shorter |
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split | | `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
| `SUPER + Q` | Close | | `SUPER + Q` | Close |
| `SUPER + U` | Fullscreen | | `SUPER + U` | Fullscreen |
@@ -256,7 +256,7 @@ The mental model is unchanged from Forge:
| `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down | | `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down |
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows | | `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
| `SUPER + SHIFT + grave` | Last window | | `SUPER + SHIFT + grave` | Last window |
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimise to scratchpad | | `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimize to scratchpad |
### Workspaces (dynamic, like GNOME) ### Workspaces (dynamic, like GNOME)
| Key | Action | | Key | Action |
@@ -270,25 +270,25 @@ The mental model is unchanged from Forge:
### Launcher and shell ### Launcher and shell
| Key | Action | | Key | Action |
|---|---| |---|---|
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite | | `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favorite |
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken | | `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
| `SUPER + V` | Clipboard history | | `SUPER + V` | Clipboard history |
| `SUPER + .` | Emoji picker | | `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings | | `SUPER + S` | Quick settings |
| `SUPER + I` | Panama Settings | | `SUPER + I` | Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session | | `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre | | `SUPER + B` | Notification center |
| `SUPER + grave` | Workspace overview | | `SUPER + grave` | Workspace overview |
| `Print` | Screenshot / record picker | | `Print` | Screenshot / record picker |
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately | | `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection | | `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
| `SUPER + SHIFT + P` | Colour picker | | `SUPER + SHIFT + P` | Color picker |
| `CTRL + ALT + L` | Lock (SUPER+L is "focus right") | | `CTRL + ALT + L` | Lock (SUPER+L is "focus right") |
| `CTRL + ALT + Delete` | Power menu | | `CTRL + ALT + Delete` | Power menu |
### Apps ### Apps
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator · `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. Settings remains searchable in Vicinae for hardware and account panels.
## Calendar and notifications ## Calendar and notifications
@@ -325,8 +325,8 @@ Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
one place. Home and Phone continue the same surface rather than opening extra one place. Home and Phone continue the same surface rather than opening extra
dashboard windows. dashboard windows.
Home shows the first four selected favourites at rest and every selected light Home shows the first four selected favorites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites, when expanded. Use **Settings → Home & Phone** to choose favorites,
set Panama-only aliases, and arrange their order. Dragging a brightness control set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous power toggle leaves Home Assistant responsible for restoring its previous
@@ -334,7 +334,7 @@ level.
Credentials stay private in the gitignored `config/bash/env` file, with the Credentials stay private in the gitignored `config/bash/env` file, with the
existing GNOME extension and Secret Service setup retained as a compatibility existing GNOME extension and Secret Service setup retained as a compatibility
fallback. Favourites, aliases, and order live in Quickshell state. No shell fallback. Favorites, aliases, and order live in Quickshell state. No shell
restart is required after changing credentials; close and reopen Control Center restart is required after changing credentials; close and reopen Control Center
to refresh. If Home Assistant is offline, the last known values stay visible to refresh. If Home Assistant is offline, the last known values stay visible
with a stale-state label and Retry action. with a stale-state label and Retry action.
@@ -361,7 +361,7 @@ These are the places a 1:1 port was impossible, and what was done instead:
- **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an - **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an
axis and lets the layout pick the edge. The eight Forge keys collapse to four axis and lets the layout pick the edge. The eight Forge keys collapse to four
behaviours, keeping the horizontal/vertical and grow/shrink pairing. behaviors, keeping the horizontal/vertical and grow/shrink pairing.
- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a - **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a
modifier misfires when you're quick with SUPER combos. modifier misfires when you're quick with SUPER combos.
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap. - **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap.
+30 -13
View File
@@ -23,8 +23,21 @@ hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland") hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
hl.exec_cmd("systemctl --user start hyprland-session.target") hl.exec_cmd("systemctl --user start hyprland-session.target")
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. -- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service") -- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
-- commands without waiting for them to finish, so the dbus-update call
-- above racing this one is not safe to assume complete -- a lost race
-- leaves the Condition unmet and the unit silently never starts (exit 0,
-- no error). hypridle is the only listener for the logind Lock signal,
-- so that failure mode is "lock-session goes to nobody". Re-import
-- synchronously in the same shell invocation first so the Condition
-- always sees it, regardless of how the dbus-update call above scheduled.
-- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled
-- into its binary and cannot be themed. Only one agent may register per
-- session, so they must not both start. hyprpolkitagent stays INSTALLED as
-- the fallback: `systemctl --user start hyprpolkitagent` restores the stock
-- prompt if Panama's ever fails to come up.
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
-- The shell: bar, dock, overview, quick settings, notifications, capture. -- The shell: bar, dock, overview, quick settings, notifications, capture.
-- No systemd unit ships with quickshell, so it runs as a compositor child. -- No systemd unit ships with quickshell, so it runs as a compositor child.
@@ -39,18 +52,22 @@ hl.on("hyprland.start", function()
-- Keyring unlock, for Nextcloud and Bitwarden credential storage. -- Keyring unlock, for Nextcloud and Bitwarden credential storage.
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11") hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
-- Tray applications carried over from ~/.config/autostart. These need the -- Nextcloud and Bitwarden are NOT started here. Both ship a
-- Quickshell tray (an SNI host) to be up, hence starting after it. -- ~/.config/autostart/*.desktop entry, and systemd's own
-- systemd-xdg-autostart-generator turns every such entry into a
-- `PartOf=graphical-session.target` unit (`[email protected]`,
-- `[email protected]`) that fires once the uwsm
-- session brings up graphical-session.target -- confirmed live via
-- `systemctl --user list-units 'app-*@autostart.service'`. An explicit
-- second launch here used to duplicate that: for Bitwarden specifically,
-- each `flatpak run` gets its own sandbox instance, so the two starts
-- didn't just race, they left two competing processes fighting over the
-- app's single-instance lock, with neither reliably owning a usable
-- window. Trust the generator instead of re-launching.
-- --
-- Commands are copied verbatim from the GNOME .desktop files rather than -- RustDesk is deliberately absent too: it ships an enabled *system*
-- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and -- service (`rustdesk --service`) that spawns --server and --tray for the
-- its autostart entry launches a specific script with --autostart. -- session on its own. Starting it here as well would give you two trays.
--
-- RustDesk is deliberately absent: it ships an enabled *system* service
-- (`rustdesk --service`) that spawns --server and --tray for the session on
-- its own. Starting it here as well would give you two trays.
hl.exec_cmd("nextcloud --background")
hl.exec_cmd("flatpak run --command=bitwarden.sh com.bitwarden.desktop --autostart")
end) end)
hl.on("hyprland.shutdown", function() hl.on("hyprland.shutdown", function()
+5 -3
View File
@@ -1,7 +1,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
-- Environment -- Environment
-- --
-- hl.env() sets variables before the display server initialises, which is what -- hl.env() sets variables before the display server initializes, which is what
-- makes it valid for AQ_* and cursor variables. -- makes it valid for AQ_* and cursor variables.
-- --
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm -- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
@@ -9,6 +9,8 @@
-- instead. They still work here; see the session notes in autostart.lua. -- instead. They still work here; see the session notes in autostart.lua.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── GPU selection ─────────────────────────────────────────────────────────── -- ── GPU selection ───────────────────────────────────────────────────────────
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU -- 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. -- (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), -- (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 -- so setting it would point hyprcursor at nothing. Hyprland falls back to the
-- XCursor path, which is what we want. -- 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") hl.env("XCURSOR_SIZE", "24")
-- ── Toolkits ──────────────────────────────────────────────────────────────── -- ── Toolkits ────────────────────────────────────────────────────────────────
@@ -56,7 +58,7 @@ hl.env("QT_QPA_PLATFORM", "wayland;xcb")
-- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3 -- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3
-- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than -- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than
-- approximating it -- and it gives them GTK file/font/colour dialogs. It also -- approximating it -- and it gives them GTK file/font/color dialogs. It also
-- covers Qt5 and Qt6 with one value. -- covers Qt5 and Qt6 with one value.
-- --
-- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses -- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses
+2 -2
View File
@@ -1,5 +1,5 @@
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# hypridle — idle behaviour # hypridle — idle behavior
# #
# Mirrors the GNOME power profile this replaces, exactly: # Mirrors the GNOME power profile this replaces, exactly:
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min) # org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
@@ -15,7 +15,7 @@
general { general {
# `pidof` guard prevents stacking lockers if this fires twice. # `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 before_sleep_cmd = loginctl lock-session
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })' after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
+1 -1
View File
@@ -15,7 +15,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences first: looks/input/monitors read their defaults through it. -- Shared preferences first: looks/input/monitors read their defaults through it.
-- It never raises, so a missing or malformed settings file costs customisations -- It never raises, so a missing or malformed settings file costs customizations
-- and nothing else. -- and nothing else.
require("prefs") require("prefs")
@@ -1,8 +1,17 @@
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# hyprlock — lock screen # hyprlock — lock screen.
# #
# Tokyo Night Moon, matching quickshell/config/Theme.qml: # GENERATED FILE. Edit hyprlock.conf.template and re-run
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f # quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
# next color scheme change.
#
# The colors here follow the desktop's light/dark setting. They used to be
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
# stayed dark when everything else went light.
#
# 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. # hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -45,7 +54,7 @@ background {
vibrancy_darkness = 0.05 vibrancy_darkness = 0.05
# Shown if the screenshot is unavailable. # Shown if the screenshot is unavailable.
color = rgba(34, 36, 54, 1.0) color = rgba(@BG@, 1.0)
zindex = -1 zindex = -1
} }
@@ -54,7 +63,7 @@ background {
label { label {
monitor = monitor =
text = cmd[update:1000] date +"%-I:%M" text = cmd[update:1000] date +"%-I:%M"
color = rgba(200, 211, 245, 1.0) color = rgba(@FG@, 1.0)
font_size = 120 font_size = 120
font_family = Adwaita Sans Light font_family = Adwaita Sans Light
position = 0, 260 position = 0, 260
@@ -65,7 +74,7 @@ label {
label { label {
monitor = monitor =
text = cmd[update:60000] date +"%A, %B %-d" text = cmd[update:60000] date +"%A, %B %-d"
color = rgba(130, 139, 184, 1.0) color = rgba(@MUTED@, 1.0)
font_size = 24 font_size = 24
font_family = Adwaita Sans font_family = Adwaita Sans
position = 0, 160 position = 0, 160
@@ -84,18 +93,18 @@ input-field {
outline_thickness = 2 outline_thickness = 2
rounding = 26 rounding = 26
outer_color = rgba(130, 170, 255, 0.9) outer_color = rgba(@ACCENT@, 0.9)
inner_color = rgba(46, 47, 61, 0.85) inner_color = rgba(@FIELD@, 0.85)
font_color = rgba(200, 211, 245, 1.0) font_color = rgba(@FG@, 1.0)
check_color = rgba(130, 170, 255, 1.0) check_color = rgba(@ACCENT@, 1.0)
fail_color = rgba(255, 117, 127, 1.0) fail_color = rgba(@ERROR@, 1.0)
dots_size = 0.25 dots_size = 0.25
dots_spacing = 0.3 dots_spacing = 0.3
dots_center = true dots_center = true
placeholder_text = <span foreground="##828bb8"><i>Password</i></span> placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span> fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
fade_on_empty = false fade_on_empty = false
hide_input = false hide_input = false
@@ -105,7 +114,7 @@ input-field {
label { label {
monitor = monitor =
text = $USER text = $USER
color = rgba(200, 211, 245, 0.9) color = rgba(@FG@, 0.9)
font_size = 16 font_size = 16
font_family = Adwaita Sans font_family = Adwaita Sans
position = 0, -110 position = 0, -110
+1 -1
View File
@@ -9,7 +9,7 @@
# hyprland-guiutils hyprland-dialog / hyprland-run # hyprland-guiutils hyprland-dialog / hyprland-run
# hyprpolkitagent authentication prompts (via hyprland-qt-support) # hyprpolkitagent authentication prompts (via hyprland-qt-support)
# #
# Colours are Tokyo Night Moon, matching quickshell/config/Theme.qml and # Colors are Tokyo Night Moon, matching quickshell/config/Theme.qml and
# hypr/looks.lua. Format is hyprlang; path is fixed at # hypr/looks.lua. Format is hyprlang; path is fixed at
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit). # $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
+12 -4
View File
@@ -10,9 +10,9 @@ local prefs = require("prefs")
hl.config({ hl.config({
input = { input = {
kb_layout = prefs.get("keyboardLayout", "us"), kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "", kb_variant = prefs.get("keyboardVariant", ""),
kb_model = "", kb_model = "",
kb_options = "", kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
kb_rules = "", kb_rules = "",
numlock_by_default = prefs.get("numlockByDefault", true), numlock_by_default = prefs.get("numlockByDefault", true),
@@ -21,10 +21,18 @@ hl.config({
repeat_delay = prefs.get("keyRepeatDelay", 500), repeat_delay = prefs.get("keyRepeatDelay", 500),
repeat_rate = prefs.get("keyRepeatRate", 33), 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 behavior", which
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
-- so click-to-focus is 0. Changing the shipped value is a behavior
-- decision rather than a correction, so the value is left alone and
-- only the description is fixed; Settings exposes all four.
follow_mouse = prefs.getInt("followMouse", 1), 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, mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming. -- Flat pointer response, no acceleration. Matters for gaming.
+58 -25
View File
@@ -80,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 .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" }) 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" }) bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ──────────────────────────────────────────────────────────────── -- ── Launcher ────────────────────────────────────────────────────────────────
@@ -118,7 +118,7 @@ bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { descriptio
-- which Hyprland has no equivalent for. -- which Hyprland has no equivalent for.
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" }) bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
-- Notification centre. -- Notification center.
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" }) bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
-- Screenshot / screen record. One key, then pick screen / window / region and -- Screenshot / screen record. One key, then pick screen / window / region and
@@ -133,8 +133,8 @@ bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description =
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")), bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
{ description = "Screen Intelligence" }) { description = "Screen Intelligence" })
-- Colour picker: copies the hex under the cursor to the clipboard. -- Color picker: copies the hex under the cursor to the clipboard.
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" }) bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
-- ── Window management ─────────────────────────────────────────────────────── -- ── Window management ───────────────────────────────────────────────────────
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
@@ -180,24 +180,46 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
-- Resize (Forge: window-resize-<edge>-<increase|decrease>). -- Resize (Forge: window-resize-<edge>-<increase|decrease>).
-- --
-- Forge resized one named edge at a time. Hyprland resizes the active window -- Forge resized one named EDGE at a time: its resize() grows the window for a
-- along an axis and lets the layout decide which edge actually moves, so the -- positive amount in every direction, and the edge only decides which side
-- eight Forge keys collapse onto four behaviours. The pairing is kept -- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical, -- an axis and lets the layout choose the border, so those eight distinct
-- and "increase" always grows while "decrease" always shrinks. -- behaviours collapse onto four and the direction is simply not expressible.
--
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
-- correct on paper and wrong under the fingers: with the edge gone, the keys
-- that used to pull a window open from one side now push it from the other.
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
local step = 60 local step = 60
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) -- SUPER+SHIFT+P was double-bound with the color picker above; moved to
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) -- Comma, which continues the bottom-row cluster (B/M/N) this axis already
-- uses rather than landing on an arbitrary free key.
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab). -- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" }) -- showing what you are choosing between.
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" }) --
-- 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. -- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" }) bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
@@ -211,6 +233,17 @@ bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces: -- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
-- moving right past the last workspace creates a new one, and moving left from -- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping. -- the first clamps instead of wrapping.
-- Behavior for the relative/cyclic binds below. These are Hyprland's own
-- `binds:` options -- not part of general/dwindle -- and have no other home
-- in the config, so they are read here rather than in looks.lua.
hl.config({
binds = {
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
},
})
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" }) bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
@@ -233,18 +266,18 @@ end
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- Minimise, as far as Hyprland has one. -- Minimize, as far as Hyprland has one.
-- --
-- Hyprland has no minimise: it receives the request (the binary has -- Hyprland has no minimize: it receives the request (the binary has
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes -- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
-- no dispatcher, no config option and not even an event to hook, so titlebar -- no dispatcher, no config option and not even an event to hook, so titlebar
-- minimise buttons are inert and cannot be made to work. A tiling WM has no -- minimize buttons are inert and cannot be made to work. A tiling WM has no
-- iconified state and no taskbar to restore from. -- iconified state and no taskbar to restore from.
-- --
-- The scratchpad is the honest equivalent: the window goes away, and the same -- The scratchpad is the honest equivalent: the window goes away, and the same
-- key brings it back. Bound to X to match the muscle memory it replaces. -- key brings it back. Bound to X to match the muscle memory it replaces.
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" }) bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" }) bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })
-- ── Session ───────────────────────────────────────────────────────────────── -- ── Session ─────────────────────────────────────────────────────────────────
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to -- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
+83 -26
View File
@@ -1,8 +1,8 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
-- Look and feel -- Tokyo Night Moon -- Look and feel -- Tokyo Night Moon
-- --
-- Colours here must stay in sync with quickshell/config/Theme.qml. -- Colors here must stay in sync with quickshell/config/Theme.qml.
-- accent #82aaff borders / focus -- accent user-selectable, see `accents` below -- blue (#82aaff) ships
-- bg #222436 base -- bg #222436 base
-- --
-- Performance note: every animation below is event-driven. Nothing uses the -- Performance note: every animation below is event-driven. Nothing uses the
@@ -14,6 +14,30 @@
local prefs = require("prefs") local prefs = require("prefs")
-- Mirrors config/Theme.qml's `accents` map. Lua has no import path into a QML
-- singleton, so the hex pairs are restated here -- just the four hex strings
-- each named accent needs, not the labels, which stay UI-only.
local accents = {
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
orchid = { dark = "c099ff", darkSecondary = "fca7ea", light = "7847bd", lightSecondary = "9854f1" },
teal = { dark = "86e1fc", darkSecondary = "82aaff", light = "007197", lightSecondary = "2e7de9" },
green = { dark = "c3e88d", darkSecondary = "86e1fc", light = "587539", lightSecondary = "007197" },
amber = { dark = "ffc777", darkSecondary = "ff966c", light = "8c6c3e", lightSecondary = "b15c00" },
orange = { dark = "ff966c", darkSecondary = "ff757f", light = "b15c00", lightSecondary = "c64343" },
rose = { dark = "ff757f", darkSecondary = "c099ff", light = "f52a65", lightSecondary = "9854f1" },
slate = { dark = "828bb8", darkSecondary = "82aaff", light = "6172b0", lightSecondary = "2e7de9" },
}
-- The accent pair a fresh session or `hyprctl reload` starts from.
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
-- data, once the shell settles (~1.2s after startup -- see its `settle`
-- Timer). This table exists only so the compositor is never observably blue
-- for a non-blue accent during the gap before that first live apply.
local accentScheme = prefs.get("colorScheme", "dark")
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
hl.config({ hl.config({
general = { general = {
gaps_in = prefs.get("gapsIn", 5), gaps_in = prefs.get("gapsIn", 5),
@@ -22,21 +46,24 @@ hl.config({
border_size = prefs.get("borderSize", 2), border_size = prefs.get("borderSize", 2),
col = { col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair -- The focused accent role, on a diagonal so the pair is visible
-- is visible on both a tall and a wide window. Same two colours as -- on both a tall and a wide window. Driven by the chosen
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the -- accentName (see the `accents` table above); services/
-- tmux theme this palette came from. -- ColorScheme.qml applies the same values live, and restates them
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, -- from Theme.accent/Theme.accentSecondary on every scheme change
-- Unfocused windows get no colour at all. The gradient only means -- too, since each accent carries a separate pair per scheme.
-- something if exactly one window on screen is wearing it. active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
-- Follows the colour scheme: a dark neutral is invisible against a -- The neutral inactive role follows the color scheme because a
-- light desktop. services/ColorScheme.qml applies changes live; -- dark neutral disappears against a light desktop.
-- this is the value a fresh session starts from. -- services/ColorScheme.qml applies the same values live; this is
-- the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light" inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)", 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. -- Enables the per-window "immediate" rule used for games in rules.lua.
-- Harmless on its own; tearing only happens where a rule opts in. -- Harmless on its own; tearing only happens where a rule opts in.
@@ -44,16 +71,22 @@ hl.config({
layout = "dwindle", 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 = { decoration = {
-- 18 to match the shell's popover radius, so a window and a panel sitting -- 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. -- next to each other read as the same object family.
rounding = prefs.get("windowRounding", 18), 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), inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = { blur = {
@@ -83,9 +116,13 @@ hl.config({
shadow = { shadow = {
enabled = prefs.get("shadowEnabled", true), enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20), range = prefs.get("shadowRange", 20),
render_power = 3, render_power = prefs.getInt("shadowRenderPower", 3),
sharp = false, sharp = prefs.get("shadowSharp", false),
color = "rgba(15161eee)", 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 }, offset = { 0, 4 },
scale = 1.0, scale = 1.0,
}, },
@@ -93,11 +130,13 @@ hl.config({
-- New in 0.56. Kept deliberately faint: in this direction the gradient -- New in 0.56. Kept deliberately faint: in this direction the gradient
-- border is the signature, and a strong halo would compete with it. -- border is the signature, and a strong halo would compete with it.
-- This is just enough to lift the focused window off the wallpaper. -- This is just enough to lift the focused window off the wallpaper.
-- Derived from the same accent as active_border above, not hardcoded,
-- so the halo never disagrees with the border it surrounds.
glow = { glow = {
enabled = prefs.get("glowEnabled", true), enabled = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 8), range = prefs.get("glowRange", 8),
render_power = 2, render_power = 2,
color = "rgba(82aaff33)", color = "rgba(" .. accentStart .. "33)",
color_inactive = "rgba(00000000)", color_inactive = "rgba(00000000)",
}, },
@@ -110,14 +149,32 @@ hl.config({
dwindle = { dwindle = {
-- Keep the split orientation a window was created with. Closest match -- Keep the split orientation a window was created with. Closest match
-- to how the Forge extension behaved on GNOME. -- to how the Forge extension behaved on GNOME.
preserve_split = true, preserve_split = prefs.get("preserveSplit", true),
smart_resizing = 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 = { misc = {
force_default_wallpaper = 0, force_default_wallpaper = 0,
disable_hyprland_logo = true, -- Stored as "show the logo / show the splash" and written as Hyprland's
disable_splash_rendering = true, -- `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),
-- 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 -- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree -- followed the preference, the compositor and the shell would disagree
@@ -136,12 +193,12 @@ hl.config({
-- Let a crashed lock screen be recovered rather than stranding you. -- Let a crashed lock screen be recovered rather than stranding you.
allow_session_lock_restore = true, allow_session_lock_restore = true,
-- Don't let apps steal focus by shouting; matches GNOME's behaviour. -- Don't let apps steal focus by shouting; matches GNOME's behavior.
focus_on_activate = false, focus_on_activate = false,
}, },
render = { render = {
-- Colour management is stable and on by default in 0.56. -- Color management is stable and on by default in 0.56.
cm_enabled = true, cm_enabled = true,
-- 1 = automatically flip the monitor into HDR for fullscreen content -- 1 = automatically flip the monitor into HDR for fullscreen content
@@ -172,8 +229,8 @@ hl.config({
}, },
ecosystem = { ecosystem = {
no_update_news = true, no_update_news = not prefs.get("hyprlandUpdateNews", false),
no_donation_nag = true, no_donation_nag = not prefs.get("hyprlandDonationNag", false),
}, },
xwayland = { xwayland = {
+42 -5
View File
@@ -20,9 +20,12 @@
local prefs = require("prefs") local prefs = require("prefs")
-- Per-output overrides written by Panama Settings, keyed by output name: -- Per-output overrides written by Panama Settings, keyed by output name:
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } } -- { ["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 -- Only mode, scale, and transform are read. Color management and bit depth
-- stay here, because those are the settings with a documented reason attached -- 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 -- (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. -- explain the screencopy tradeoff at the moment you would be changing it.
@@ -71,6 +74,26 @@ local function valid_transform(transform)
and transform <= 3 and transform <= 3
end 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) local function display_entry(output)
if type(output) ~= "string" or output == "" if type(output) ~= "string" or output == ""
or output:match("^[%w_.-]+$") == nil then or output:match("^[%w_.-]+$") == nil then
@@ -85,9 +108,23 @@ local function display_entry(output)
or not valid_transform(entry.transform) then or not valid_transform(entry.transform) then
return nil return nil
end 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 return entry
end 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_mode = "4500x3000@60"
local shipped_scale = 1.5 local shipped_scale = 1.5
local shipped_transform = 0 local shipped_transform = 0
@@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2")
hl.monitor({ hl.monitor({
output = "DP-2", output = "DP-2",
mode = dp2 and dp2.mode or shipped_mode, mode = dp2 and dp2.mode or shipped_mode,
position = "0x0", position = display_position(dp2, "0x0"),
scale = dp2 and dp2.scale or shipped_scale, scale = dp2 and dp2.scale or shipped_scale,
transform = dp2 and dp2.transform or shipped_transform, transform = dp2 and dp2.transform or shipped_transform,
@@ -110,7 +147,7 @@ hl.monitor({
}) })
-- Other connected outputs use the same validated per-output store. They keep -- Other connected outputs use the same validated per-output store. They keep
-- automatic placement and the compositor's normal colour policy; DP-2 alone -- automatic placement and the compositor's normal color policy; DP-2 alone
-- carries the panel-specific 10-bit policy documented above. -- carries the panel-specific 10-bit policy documented above.
for output, _ in pairs(displays) do for output, _ in pairs(displays) do
if output ~= "DP-2" then if output ~= "DP-2" then
@@ -119,7 +156,7 @@ for output, _ in pairs(displays) do
hl.monitor({ hl.monitor({
output = output, output = output,
mode = entry.mode, mode = entry.mode,
position = "auto", position = display_position(entry, "auto"),
scale = entry.scale, scale = entry.scale,
transform = entry.transform, transform = entry.transform,
}) })
+1 -1
View File
@@ -16,7 +16,7 @@
-- cannot drift apart. -- cannot drift apart.
-- --
-- Nothing here may raise. A missing, empty, truncated, or actively malformed -- Nothing here may raise. A missing, empty, truncated, or actively malformed
-- file must cost the user nothing worse than their customisations; it must -- file must cost the user nothing worse than their customizations; it must
-- never cost them a working compositor. Every failure path returns the caller's -- never cost them a working compositor. Every failure path returns the caller's
-- fallback. -- fallback.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
+14 -2
View File
@@ -69,7 +69,7 @@ hl.window_rule({
float = true, float = true,
}) })
-- Settings-style utility windows and pickers: float and centre, like GNOME did. -- Settings-style utility windows and pickers: float and center, like GNOME did.
hl.window_rule({ hl.window_rule({
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" }, match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
float = true, float = true,
@@ -77,6 +77,18 @@ hl.window_rule({
center = true, center = true,
}) })
-- Quick Look. The GNOME previewer is what the file manager opens on space,
-- and it is an overlay rather than a window someone manages: tiled, it shoves
-- the file manager aside and has to be dismissed before the list is usable
-- again. Sized generously because a preview that needs zooming is not a
-- preview; it still gets a margin so the file underneath stays visible.
hl.window_rule({
match = { class = "^org\\.gnome\\.NautilusPreviewer$" },
float = true,
size = { "monitor_w * 0.7", "monitor_h * 0.8" },
center = true,
})
-- Portal dialogs (file chooser, screen share picker) should always float. -- Portal dialogs (file chooser, screen share picker) should always float.
hl.window_rule({ hl.window_rule({
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" }, match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
@@ -150,7 +162,7 @@ hl.layer_rule({
ignore_alpha = 0.3, ignore_alpha = 0.3,
}) })
-- Popovers: calendar, quick settings, notification centre, tray menus. -- Popovers: calendar, quick settings, notification center, tray menus.
hl.layer_rule({ hl.layer_rule({
name = "qs-popover", name = "qs-popover",
match = { namespace = "^qs-popover" }, match = { namespace = "^qs-popover" },
+1 -1
View File
@@ -1,7 +1,7 @@
# Tokyo Night Moon — the dark theme Panama ships. # Tokyo Night Moon — the dark theme Panama ships.
# #
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf # Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
# includes current-theme.conf, which Panama generates from the colour scheme # includes current-theme.conf, which Panama generates from the color scheme
# setting; that generated file is gitignored because it is machine state. # setting; that generated file is gitignored because it is machine state.
# #
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables # Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
+1 -1
View File
@@ -6,7 +6,7 @@
-- Hyprland config read. -- Hyprland config read.
-- --
-- Nothing here may raise. A missing or malformed settings file must cost the -- Nothing here may raise. A missing or malformed settings file must cost the
-- user their colour scheme preference and nothing else; editing text is more -- user their color scheme preference and nothing else; editing text is more
-- important than matching the desktop. -- important than matching the desktop.
local M = {} local M = {}
+3 -3
View File
@@ -1,12 +1,12 @@
-- Tokyo Night, following the desktop's colour scheme. -- Tokyo Night, following the desktop's color scheme.
-- --
-- Moon when Panama is dark, Day when it is light. Same theme family either way, -- 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 -- so the editor keeps the identity the rest of the desktop has rather than
-- becoming a different-looking application when the scheme flips. -- becoming a different-looking application when the scheme flips.
-- --
-- The readability fixes below are deliberately dark-only. They were written -- The readability fixes below are deliberately dark-only. They were written
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and -- against Moon's palette -- a pale comment color, a mid-gray gutter -- and
-- applying them to Day would put light grey text on a light background, which -- applying them to Day would put light gray text on a light background, which
-- is exactly the legibility problem they exist to solve, inverted. -- is exactly the legibility problem they exist to solve, inverted.
local panama = require("config.panama") local panama = require("config.panama")
+1 -1
View File
@@ -22,7 +22,7 @@ console_title_template = '{{ .Shell }} in {{ .Folder }}'
[[blocks.segments]] [[blocks.segments]]
type = 'git' type = 'git'
style = 'plain' style = 'plain'
foreground = 'p:grey' foreground = 'p:gray'
background = 'transparent' background = 'transparent'
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>' template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
@@ -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. // back to shipped defaults and let the next write replace it.
parsed = {}; 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 recognizes is carried through untouched and silently
// stops taking effect, so the conversion has to happen here rather than
// being noticed later by whoever owns that setting.
const result = Migrations.apply(raw);
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.revision++;
root.loaded = true; root.loaded = true;
} }
@@ -19,7 +19,7 @@ Singleton {
printErrors: false printErrors: false
atomicWrites: true atomicWrites: true
onSaved: root.saveError = "" onSaved: root.saveError = ""
onSaveFailed: error => root.saveError = "Could not save Home favourites." onSaveFailed: error => root.saveError = "Could not save Home favorites."
JsonAdapter { JsonAdapter {
id: values id: values
+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 recognize -- and an unrecognized key is silently carried through
// untouched, so the user's choice simply stops taking effect with nothing to
// say why. That is the failure this exists to prevent.
//
// 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 };
}
}
+695 -22
View File
@@ -91,6 +91,31 @@ Singleton {
label: "Automatically hide the Dock", label: "Automatically hide the Dock",
detail: "Reveal it at the bottom edge when a workspace is occupied" detail: "Reveal it at the bottom edge when a workspace is occupied"
}, },
{
key: "dockPosition", type: "enum", def: "bottom", group: "dock",
label: "Position",
detail: "Which edge the Dock lives on",
options: [
{ value: "bottom", label: "Bottom" },
{ value: "left", label: "Left" },
{ value: "right", label: "Right" }
]
},
{
// A "json" value: the screen names the Dock appears on. Empty means
// every screen, which is both the sensible default and the right
// answer for the common single-monitor case -- storing a list of
// names there would go stale the moment a display is unplugged.
key: "dockScreens", type: "json", def: [], group: "dock",
label: "Screens",
detail: "Which displays show the Dock"
},
{
key: "dockIconSize", type: "int", def: 48, min: 32, max: 80, step: 4,
unit: "px", group: "dock",
label: "Icon size",
detail: "How large the Dock's application icons are drawn"
},
{ {
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25, key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
unit: "ms", unit: "ms",
@@ -107,6 +132,41 @@ Singleton {
}, },
// ── Focus ─────────────────────────────────────────────────────────── // ── Focus ───────────────────────────────────────────────────────────
// ── Focus modes ─────────────────────────────────────────────────────
// A "json" value: named modes, each with what turns it on and what it
// does. Triggers rather than a scheduler -- a mode is on because a
// condition is true right now, which is re-evaluated rather than fired
// once. A schedule is one of those conditions ("is now inside this
// window?"), which is why suspend, a reboot mid-window, and a lid
// opened after the start time all behave correctly without special
// cases: there is no alarm to have missed.
//
// Gaming ships enabled because the behaviour already existed as
// gamingSilenceNotifications; Sleep ships disabled, because a desktop
// that starts silencing someone on first boot has overstepped.
{
key: "focusModes", type: "json", group: "focus",
label: "Focus modes",
detail: "What quiets this machine, and what turns it on",
def: [
{
id: "deep-work", name: "Deep work", enabled: true,
triggers: [{ kind: "manual" }],
durationMinutes: 45, silence: true, keepAwake: true, allow: []
},
{
id: "gaming", name: "Gaming", enabled: true,
triggers: [{ kind: "game" }],
durationMinutes: 0, silence: true, keepAwake: true, allow: []
},
{
id: "sleep", name: "Sleep", enabled: false,
triggers: [{ kind: "schedule", start: "23:30", end: "07:00",
days: [0, 1, 2, 3, 4, 5, 6] }],
durationMinutes: 0, silence: true, keepAwake: false, allow: []
}
]
},
{ {
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5, key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min", unit: "min",
@@ -128,10 +188,21 @@ Singleton {
{ {
key: "vrrPolicy", type: "enum", def: 3, group: "display", key: "vrrPolicy", type: "enum", def: 3, group: "display",
label: "Variable refresh rate", 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: [ options: [
{ value: 0, label: "Off" }, { value: 0, label: "Off",
{ value: 3, label: "Content-aware" } 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" } hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
}, },
@@ -140,8 +211,12 @@ Singleton {
label: "Direct scanout", label: "Direct scanout",
detail: "Lets fullscreen content bypass compositing", detail: "Lets fullscreen content bypass compositing",
options: [ options: [
{ value: 0, label: "Off" }, { value: 0, label: "Off",
{ value: 2, label: "Automatic" } 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" } hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
}, },
@@ -156,7 +231,7 @@ Singleton {
unit: "px", unit: "px",
group: "windows", group: "windows",
label: "Inner gaps", label: "Inner gaps",
detail: "Space between neighbouring tiled windows", detail: "Space between neighboring tiled windows",
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" } hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
}, },
{ {
@@ -191,6 +266,147 @@ Singleton {
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" } 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 behavior 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: "Center" }
],
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 ───────────────────────────────────────────────────────── // ── Effects ─────────────────────────────────────────────────────────
{ {
key: "blurEnabled", type: "bool", def: true, group: "effects", key: "blurEnabled", type: "bool", def: true, group: "effects",
@@ -226,6 +442,19 @@ Singleton {
detail: "How far the shadow spreads from the window edge", detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" } 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", key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow", label: "Focus glow",
@@ -257,6 +486,24 @@ Singleton {
detail: "XKB layout name, or a comma-separated list to switch between", detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" } 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
// serialized into an hl.config string.
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
label: "Layout variant",
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
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", key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login", label: "Num Lock on login",
@@ -280,19 +527,35 @@ Singleton {
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" } hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
}, },
{ {
key: "followMouse", type: "enum", def: 1, group: "input", key: "followMouse", type: "enum", def: 1, group: "pointer",
label: "Focus follows pointer", label: "Pointer focus",
detail: "Click to focus matches GNOME; sloppy focus follows the pointer", 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 labeled "Click to focus" was in fact following
// the pointer, and the way to actually get click-to-focus was to
// choose "Never". Value 3 was missing entirely.
//
// enum-hypr-map-contract now pins every mapped enum against that
// published map, so this cannot drift again.
options: [ options: [
{ value: 0, label: "Never" }, { value: 0, label: "Click to focus",
{ value: 1, label: "Click to focus" }, detail: "Moving the pointer never changes focus" },
{ value: 2, label: "Sloppy 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" } 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, key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input", group: "pointer",
label: "Pointer speed", label: "Pointer speed",
detail: "Zero is flat, unaccelerated response", detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" } hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
@@ -300,7 +563,7 @@ Singleton {
{ {
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1, key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s", unit: "s",
group: "input", group: "pointer",
label: "Hide pointer after", label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it", 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 // Reported as a float even though it is only ever set to whole
@@ -310,6 +573,233 @@ Singleton {
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" } 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: "highContrast", type: "bool", def: false, group: "accessibility",
label: "High contrast",
detail: "Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here.",
// No hypr mapping: this is a GNOME interface setting the portal
// republishes as org.freedesktop.appearance contrast, which is what
// libadwaita actually reads. DesktopStyle applies it.
},
{
key: "dimInactive", type: "bool", def: false, group: "accessibility",
label: "Dim inactive windows",
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" }
},
// ── Gaming ──────────────────────────────────────────────────────────
// What Panama does while a game runs. gamemode tells us when that
// starts and stops through its own hook scripts, so these are real
// behaviours rather than hints -- and each one is undone afterwards to
// whatever it was before, not to a default.
{
key: "gamingPerformanceProfile", type: "bool", def: true, group: "gaming",
label: "Use the performance power profile",
detail: "Switches while a game runs and switches back when it exits"
},
{
key: "gamingNotifyOnStart", type: "bool", def: false, group: "gaming",
label: "Say when Game Mode engages",
detail: "A notification when a game requests it, which is otherwise invisible"
},
// ── Night light ───────────────────────────────────────────────────── // ── Night light ─────────────────────────────────────────────────────
{ {
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
@@ -341,6 +831,66 @@ Singleton {
label: "Wallpaper", label: "Wallpaper",
detail: "Shown on every output" 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 ─────────────────────────────────────────── // ── Idle, lock, and sleep ───────────────────────────────────────────
// Written into a generated hypridle config; see scripts/panama-idle. // Written into a generated hypridle config; see scripts/panama-idle.
@@ -386,14 +936,14 @@ Singleton {
detail: "A time earlier than the start simply means the next morning" detail: "A time earlier than the start simply means the next morning"
}, },
// ── Colour scheme ─────────────────────────────────────────────────── // ── Color scheme ───────────────────────────────────────────────────
// Light is Tokyo Night Day, the official light variant, rather than a // 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 // palette invented to merely not be dark. Both share the same hues at
// different lightness, which is what keeps the Prism identity intact // different lightness, which is what keeps the Prism identity intact
// across the switch. // across the switch.
// //
// services/ColorScheme.qml pushes the choice to GTK and to the // services/ColorScheme.qml pushes the choice to GTK and to the
// compositor's border colours, because an application toolbar or a // compositor's border colors, because an application toolbar or a
// window border still wearing the other scheme is more jarring than // window border still wearing the other scheme is more jarring than
// either scheme on its own. // either scheme on its own.
{ {
@@ -405,6 +955,44 @@ Singleton {
{ value: "light", label: "Light" } { value: "light", label: "Light" }
] ]
}, },
{
key: "accentName", type: "enum", def: "blue", group: "appearance",
label: "Accent color",
detail: "Drives the focused window border, the bar hairline, and every active state",
// NAMED accents, not a free color. Each name carries a curated
// pair per scheme, because one hex cannot serve both: a color
// legible on the dark ground is usually illegible on the light one.
// The palette and its measured contrast live in config/Theme.qml,
// which is also what stops this list drifting from what is drawn.
options: [
{ value: "blue", label: "Prism blue" },
{ value: "orchid", label: "Orchid" },
{ value: "teal", label: "Teal" },
{ value: "green", label: "Green" },
{ value: "amber", label: "Amber" },
{ value: "orange", label: "Orange" },
{ value: "rose", label: "Rose" },
{ value: "slate", label: "Slate" }
]
},
// ── 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 ────────────────────────────────────────────────────── // ── Typography ──────────────────────────────────────────────────────
// The single largest thing in this desktop that used to be changeable // The single largest thing in this desktop that used to be changeable
@@ -435,6 +1023,91 @@ Singleton {
label: "Interface text size", label: "Interface text size",
detail: "The base size the rest of the shell's type scales from" 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 honor GNOME's window preferences.
// Hyprland itself has no server-side titlebar buttons, so minimize is
// deliberately absent rather than presented as a switch that lies.
{
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 ─────────────────────────────────────────────────── // ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and // Backed by gsettings so GTK applications agree with the shell, and
@@ -472,7 +1145,7 @@ Singleton {
// ── Which GPU the vitals readout tracks ───────────────────────────── // ── Which GPU the vitals readout tracks ─────────────────────────────
// A sysfs path rather than a card number, because the number is neither // A sysfs path rather than a card number, because the number is neither
// stable across machines nor meaningful. Constrained to the one shape // stable across machines nor meaningful. Constrained to the one shape
// that can be read for utilisation; VitalsWidget hides itself when the // that can be read for utilization; VitalsWidget hides itself when the
// path is unreadable, so a stale value degrades to no readout rather // path is unreadable, so a stale value degrades to no readout rather
// than a wrong one. // than a wrong one.
{ {
@@ -488,7 +1161,7 @@ Singleton {
// Coordinates rather than a place name, because that is what Open-Meteo // Coordinates rather than a place name, because that is what Open-Meteo
// takes and it needs no API key. weatherLocation is only the label shown // takes and it needs no API key. weatherLocation is only the label shown
// in the UI; it is never sent anywhere, so it can say whatever makes the // in the UI; it is never sent anywhere, so it can say whatever makes the
// reading recognisable. // reading recognizable.
{ {
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001, key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
group: "weather", internal: true, group: "weather", internal: true,
@@ -622,8 +1295,8 @@ Singleton {
}, },
// ── Display configuration ─────────────────────────────────────────── // ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform } }, applied by // { "<output>": { mode, scale, transform, x, y, primary } }, applied by
// hypr/monitors.lua on top of the shipped values. Colour management and // hypr/monitors.lua on top of the shipped values. Color management and
// bit depth are deliberately not here: those carry a documented // bit depth are deliberately not here: those carry a documented
// screencopy tradeoff that a settings page cannot explain at the moment // screencopy tradeoff that a settings page cannot explain at the moment
// you would be changing it. // you would be changing it.
@@ -631,7 +1304,7 @@ Singleton {
key: "displays", type: "json", def: ({}), group: "display", key: "displays", type: "json", def: ({}), group: "display",
internal: true, internal: true,
label: "Display configuration", label: "Display configuration",
detail: "Resolution, scale, and rotation per connected display" detail: "Resolution, scale, rotation, position, and primary display"
}, },
// ── Per-application notification rules ────────────────────────────── // ── Per-application notification rules ──────────────────────────────
@@ -736,7 +1409,7 @@ Singleton {
case "string": { case "string": {
const text = typeof value === "string" ? value : String(value); const text = typeof value === "string" ? value : String(value);
// A constrained string is rejected rather than sanitised. Several // A constrained string is rejected rather than sanitised. Several
// of these are serialised into an hl.config payload, and quietly // of these are serialized into an hl.config payload, and quietly
// stripping characters would turn a typo into a different setting // stripping characters would turn a typo into a different setting
// instead of an error the user can see. // instead of an error the user can see.
if (entry.pattern && !new RegExp(entry.pattern).test(text)) if (entry.pattern && !new RegExp(entry.pattern).test(text))
+6 -1
View File
@@ -62,7 +62,12 @@ Singleton {
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes") readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
// ── Dock ──────────────────────────────────────────────────────────────── // ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites. // Pinned apps, in order, taken from the GNOME dash favorites.
readonly property string dockPosition: DesktopPreferences.get("dockPosition")
readonly property var dockScreens: {
const stored = DesktopPreferences.get("dockScreens");
return Array.isArray(stored) ? stored : [];
}
readonly property var dockPinned: DesktopPreferences.get("dockPinned") readonly property var dockPinned: DesktopPreferences.get("dockPinned")
// Dash-to-Dock was set to intellihide against all windows: the dock hides // Dash-to-Dock was set to intellihide against all windows: the dock hides
+56 -15
View File
@@ -1,9 +1,9 @@
pragma Singleton pragma Singleton
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Tokyo Night Moon — the single source of truth for every colour, radius, // Tokyo Night Moon — the single source of truth for every color, radius,
// duration and font in the shell. Nothing below this file should hardcode a // duration and font in the shell. Nothing below this file should hardcode a
// colour; if you find yourself writing "#" outside this file, add a token here. // color; if you find yourself writing "#" outside this file, add a token here.
// //
// The bar keeps GNOME's 36px information density but sits directly on the // The bar keeps GNOME's 36px information density but sits directly on the
// desktop, edge to edge. The dock retains the former glass vocabulary: bottom, // desktop, edge to edge. The dock retains the former glass vocabulary: bottom,
@@ -18,7 +18,7 @@ import QtQuick
Singleton { Singleton {
id: root id: root
// ── Colour scheme ─────────────────────────────────────────────────────── // ── Color scheme ───────────────────────────────────────────────────────
// Tokyo Night ships an official light variant (Day), so light mode is that // 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 // 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 // same hues at different lightness, which is what lets the Prism identity
@@ -42,13 +42,48 @@ Singleton {
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5" readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb" readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
// The pair. `accent` is the primary and carries every state meaning // ── The accent ──────────────────────────────────────────────────────────
// (focused, active, on). `accentSecondary` is the orchid from the tmux //
// theme — it never appears alone, only as the far end of a gradient. That // `accent` is the primary and carries every state meaning (focused, active,
// restraint is the whole point: the two colours meeting is the signature, // on). `accentSecondary` never appears alone, only as the far end of a
// so the pink stops being special the moment it's used as a flat fill. // gradient. That restraint is the whole point: the two colors meeting is
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue // the signature, so the second color stops being special the moment it is
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux // used as a flat fill.
//
// NAMED accents rather than a free color. Each name carries a curated
// triple per scheme, because an arbitrary hex cannot work in both: a color
// legible on the Moon background is usually illegible on the Day one, and a
// picker that lets someone choose an unreadable desktop is not a feature.
// Every pair below measures at least 3:1 against the ground it sits on.
// This is also GNOME's model, which is the parity being chased.
//
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
// the default.
//
// `gnome` is the nearest member of GNOME's own accent-color enum, which is
// a fixed list of nine we do not get to extend. It is what libadwaita
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
// accent here recolors them too instead of leaving them in GNOME blue.
// Nearest by hue, not by name: "rose" maps to red rather than pink because
// it is the red role in this palette.
readonly property var accents: ({
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue", gnome: "blue" },
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" },
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" },
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" },
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" },
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" },
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" },
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate", gnome: "slate" }
})
// Falls back to blue for an unknown name, so a settings file written by a
// newer Panama -- or edited by hand -- degrades to the shipped identity
// rather than to an undefined color.
readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"]
readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light
readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
readonly property color cyan: root.dark ? "#86e1fc" : "#007197" readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
readonly property color teal: root.dark ? "#4fd6be" : "#118c74" readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
@@ -75,6 +110,13 @@ Singleton {
// light one, and text on it stops being legible. // light one, and text on it stops being legible.
readonly property real dockAlpha: root.dark ? 0.34 : 0.62 readonly property real dockAlpha: root.dark ? 0.34 : 0.62
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97 readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
// Toasts sit a little lighter than a popover you opened on purpose. A
// notification arrives unbidden over whatever you were doing, and at full
// popover weight it reads as a dialog demanding an answer -- but the 6%
// foreground tint it used to have left the text competing with the desktop
// behind it. This is the point between the two.
readonly property real toastAlpha: root.dark ? 0.86 : 0.94
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40 readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10 readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
readonly property real activeAlpha: root.dark ? 0.24 : 0.18 readonly property real activeAlpha: root.dark ? 0.24 : 0.18
@@ -84,7 +126,7 @@ Singleton {
readonly property int barGap: 6 // breathing room below the bar for popovers readonly property int barGap: 6 // breathing room below the bar for popovers
readonly property int barSideMargin: 10 // inset for floating popovers readonly property int barSideMargin: 10 // inset for floating popovers
readonly property int dockIconSize: 48 readonly property int dockIconSize: DesktopPreferences.get("dockIconSize")
readonly property int dockPadding: 8 readonly property int dockPadding: 8
readonly property int dockGap: 8 readonly property int dockGap: 8
readonly property int dockRadius: 20 readonly property int dockRadius: 20
@@ -93,7 +135,6 @@ Singleton {
readonly property int popoverPadding: 14 readonly property int popoverPadding: 14
readonly property int popoverWidth: 380 readonly property int popoverWidth: 380
readonly property int controlCenterWidth: 430 readonly property int controlCenterWidth: 430
readonly property int controlCenterTopGap: 2
readonly property int cardRadius: 12 readonly property int cardRadius: 12
readonly property int pillRadius: 999 readonly property int pillRadius: 999
@@ -108,7 +149,7 @@ Singleton {
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the // Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
// pragmatic alternative to freedesktop symbolic icons, which ship with a // pragmatic alternative to freedesktop symbolic icons, which ship with a
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml). // hardcoded dark fill Qt will not recolor (see widgets/ThemedIcon.qml).
readonly property string fontMono: DesktopPreferences.get("iconFont") readonly property string fontMono: DesktopPreferences.get("iconFont")
// Apply to any text whose digits change in place — clocks, percentages, // Apply to any text whose digits change in place — clocks, percentages,
@@ -152,7 +193,7 @@ Singleton {
} }
// Blend two tokens — used for hover/pressed states so we never invent a // Blend two tokens — used for hover/pressed states so we never invent a
// colour that isn't derived from the palette. // color that isn't derived from the palette.
function mix(a: color, b: color, ratio: real): color { function mix(a: color, b: color, ratio: real): color {
return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio); return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio);
} }
@@ -163,7 +204,7 @@ Singleton {
// the active workspace pill and slider fills. // the active workspace pill and slider fills.
// //
// Where the gradient runs left-to-right the blue leads, because the eye // Where the gradient runs left-to-right the blue leads, because the eye
// reads left first and blue is the colour that carries meaning. // reads left first and blue is the color that carries meaning.
readonly property real prismStart: 0.22 readonly property real prismStart: 0.22
readonly property real prismEnd: 0.78 readonly property real prismEnd: 0.78
+1
View File
@@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml
singleton HomePreferences 1.0 HomePreferences.qml singleton HomePreferences 1.0 HomePreferences.qml
singleton Settings 1.0 Settings.qml singleton Settings 1.0 Settings.qml
singleton Theme 1.0 Theme.qml singleton Theme 1.0 Theme.qml
singleton Migrations 1.0 Migrations.qml
@@ -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);
}
}
}
+71 -1
View File
@@ -20,6 +20,9 @@ ShellRoot {
mode: monitor ? monitor.mode : "", mode: monitor ? monitor.mode : "",
scale: monitor ? monitor.scale : 0, scale: monitor ? monitor.scale : 0,
transform: monitor ? monitor.transform : -1, 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, modes: monitor ? monitor.modes.length : 0,
awaiting: Displays.awaitingConfirmation, awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm, canConfirm: Displays.canConfirm,
@@ -36,8 +39,51 @@ ShellRoot {
return Displays.apply(monitor.name, mode, scale, monitor.transform); 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 { function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([ const modes = Displays.normalizeModes([
"[email protected]", "[email protected]",
"[email protected]" "[email protected]"
]); ]);
@@ -49,6 +95,30 @@ ShellRoot {
}); });
} }
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 { function applyBad(kind: string): bool {
const monitor = Displays.monitors[0]; const monitor = Displays.monitors[0];
if (!monitor) return false; if (!monitor) return false;
+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 behavior 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 horizontalPadding: 8
onActivated: ShellState.toggle("activity") 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 { Text {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
@@ -15,7 +15,11 @@ PanelWindow {
color: "transparent" color: "transparent"
anchors.top: true anchors.top: true
anchors.right: true anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2 // The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
// "reserve nothing, but respect what others reserved", so this surface
// already begins below the bar's zone -- adding the bar height here counted
// it twice and left the surface floating 48px under the bar instead of 12.
margins.top: Theme.barGap * 2
margins.right: Theme.barSideMargin margins.right: Theme.barSideMargin
exclusiveZone: 0 exclusiveZone: 0
implicitWidth: 350 implicitWidth: 350
@@ -25,10 +29,18 @@ PanelWindow {
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// Deliberately does not read Capture.recordingSeconds (or anything else
// that ticks once a second): this array is a plain JS array, not an
// identity-preserving model, so any dependency that changes every second
// would make the whole thing re-derive every second, and the Repeater
// below would destroy and recreate every row -- including one the user
// might be hovering or about to click. The set of activities should only
// change when an activity actually starts or stops. Elapsed time is
// rendered by each row's own Text binding instead, further down.
readonly property var activities: { readonly property var activities: {
const result = []; const result = [];
if (PrivacyState.recordingActive) if (PrivacyState.recordingActive)
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true }); result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
if (PrivacyState.screenSharingActive) if (PrivacyState.screenSharingActive)
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false }); result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.cameraActive) if (PrivacyState.cameraActive)
@@ -38,11 +50,12 @@ PanelWindow {
return result; return result;
} }
function elapsed(): string { // Pure formatter, no ticking property read here -- callers decide what
const total = Capture.recordingSeconds; // seconds value to pass, and only they take on the per-second dependency.
const seconds = String(total % 60).padStart(2, "0"); function formatElapsed(totalSeconds: int): string {
const minutes = Math.floor(total / 60) % 60; const seconds = String(totalSeconds % 60).padStart(2, "0");
const hours = Math.floor(total / 3600); const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`; return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
} }
@@ -164,7 +177,11 @@ PanelWindow {
Text { Text {
width: parent.width width: parent.width
text: activityRow.modelData.detail // Only this Text re-evaluates every second while
// recording -- Capture.recordingSeconds is read
// here, not in the parent `activities` array, so
// the row itself is never torn down for a tick.
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
color: Theme.fgDim color: Theme.fgDim
elide: Text.ElideRight elide: Text.ElideRight
font.family: Theme.fontFamily font.family: Theme.fontFamily
+6 -2
View File
@@ -10,7 +10,7 @@
// Bar {} // Bar {}
// } // }
// //
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather centre, // Layout mirrors GNOME's: workspaces + vitals left, clock + weather center,
// media + tray + status cluster right. // media + tray + status cluster right.
import Quickshell import Quickshell
@@ -83,7 +83,7 @@ PanelWindow {
} }
} }
// ── Centre ────────────────────────────────────────────────────────── // ── Center ──────────────────────────────────────────────────────────
Row { Row {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -108,6 +108,10 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
HealthIndicator {
anchors.verticalCenter: parent.verticalCenter
}
ActivityIndicator { ActivityIndicator {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
@@ -15,6 +15,9 @@ Pill {
horizontalPadding: 8 horizontalPadding: 8
onActivated: ShellState.openDateMenu("agenda") 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.visible: root.hovered && root.visible
ToolTip.delay: 500 ToolTip.delay: 500
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event" ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
@@ -1,222 +0,0 @@
// The calendar that drops out of the clock — GNOME's date menu, minus the
// notification list (that lives in its own panel). Month grid, today marked
// with the accent, arrows to page through months, current weather at the foot.
import Quickshell
import QtQuick
import qs.config
import qs.services
import qs.widgets
Popover {
id: root
// Popover's container is a plain Item, which does not derive an implicit
// size from its children, so the window has to be sized from the body.
implicitWidth: body.implicitWidth + contentPadding * 2
implicitHeight: body.implicitHeight + contentPadding * 2
readonly property int cellSize: 34
readonly property int cellHeight: 30
// Hours precision is enough: the only thing that has to change on its own
// is which cell counts as "today", and that only moves at midnight.
SystemClock {
id: clock
precision: SystemClock.Hours
}
readonly property int todayYear: clock.date.getFullYear()
readonly property int todayMonth: clock.date.getMonth()
readonly property int todayDay: clock.date.getDate()
// The month currently on screen. Reset to today every time the popover
// opens, so it never comes back showing wherever you paged off to.
property int viewYear: root.todayYear
property int viewMonth: root.todayMonth
onVisibleChanged: if (visible)
root.showToday()
function showToday(): void {
root.viewYear = root.todayYear;
root.viewMonth = root.todayMonth;
}
function stepMonth(delta: int): void {
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
root.viewYear = d.getFullYear();
root.viewMonth = d.getMonth();
}
// Six weeks of cells, so the grid height never changes as you page through
// months. Days from the neighbouring months fill the edges, dimmed.
readonly property var cells: {
const first = new Date(root.viewYear, root.viewMonth, 1);
const offset = first.getDay(); // 0 = Sunday, matching the header row
const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate();
const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate();
const out = [];
for (let i = 0; i < 42; i++) {
const n = i - offset + 1;
if (n < 1)
out.push({
day: inPrevMonth + n,
current: false
});
else if (n > inThisMonth)
out.push({
day: n - inThisMonth,
current: false
});
else
out.push({
day: n,
current: true
});
}
return out;
}
Column {
id: body
spacing: Theme.itemSpacing
// ── Month header ────────────────────────────────────────────────────
Item {
width: root.cellSize * 7
height: 28
CalendarArrow {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
glyph: "\u{F0141}" // md-chevron_left
onActivated: root.stepMonth(-1)
}
Text {
anchors.centerIn: parent
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
color: Theme.fg
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.showToday()
}
}
CalendarArrow {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
glyph: "\u{F0142}" // md-chevron_right
onActivated: root.stepMonth(1)
}
}
// ── Weekday header ──────────────────────────────────────────────────
Row {
Repeater {
model: ["S", "M", "T", "W", "T", "F", "S"]
delegate: Text {
required property string modelData
width: root.cellSize
height: 20
text: modelData
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
color: Theme.fgMuted
}
}
}
// ── Day grid ────────────────────────────────────────────────────────
Grid {
columns: 7
Repeater {
model: root.cells
delegate: Item {
id: cell
required property var modelData
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
width: root.cellSize
height: root.cellHeight
Rectangle {
anchors.centerIn: parent
width: root.cellHeight - 2
height: root.cellHeight - 2
radius: width / 2
border.width: 0
visible: cell.isToday
color: Theme.accent
}
Text {
anchors.centerIn: parent
text: cell.modelData.day
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
color: {
if (cell.isToday)
return Theme.bgDark;
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
}
}
}
}
}
// ── Weather ─────────────────────────────────────────────────────────
Rectangle {
width: root.cellSize * 7
height: 1
color: Theme.alpha(Theme.fg, 0.1)
visible: Weather.available
}
Row {
spacing: Theme.itemSpacing
visible: Weather.available
Text {
anchors.verticalCenter: parent.verticalCenter
text: Weather.icon
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeTitle
color: Theme.accentAlt
}
Column {
Text {
text: Math.round(Weather.temperature) + Weather.unitSuffix
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
}
Text {
text: Weather.description
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
color: Theme.fgDim
}
}
}
}
}
+7 -2
View File
@@ -1,4 +1,4 @@
// The centre clock, kept in the same place and the same format GNOME had it: // The center clock, kept in the same place and the same format GNOME had it:
// weekday, date, 12-hour time with seconds. Clicking it opens the calendar, // weekday, date, 12-hour time with seconds. Clicking it opens the calendar,
// exactly like GNOME's date menu. // exactly like GNOME's date menu.
@@ -26,11 +26,16 @@ Pill {
onActivated: ShellState.toggleDateMenu("agenda") 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 { Text {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, root.format) text: Qt.formatDateTime(clock.date, root.format)
font.family: Theme.fontFamily font.family: Theme.fontFamily
// Seconds tick once a second in the centre of the bar; without tabular // Seconds tick once a second in the center of the bar; without tabular
// figures the whole clock shifts sideways on every digit change. // figures the whole clock shifts sideways on every digit change.
font.features: Theme.tabularFigures font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSize
@@ -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 QtQuick
import Quickshell.Services.Mpris import Quickshell.Services.Mpris
import qs.config import qs.config
import qs.services
import qs.widgets import qs.widgets
Pill { Pill {
@@ -34,6 +35,9 @@ Pill {
onActivated: if (root.player?.canTogglePlaying) onActivated: if (root.player?.canTogglePlaying)
root.player.togglePlaying() 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 // Scroll up = previous, down = next — the same direction as the workspace
// switcher, so the whole bar scrolls consistently. // switcher, so the whole bar scrolls consistently.
onScrolled: delta => { onScrolled: delta => {
@@ -24,6 +24,9 @@ Pill {
onActivated: root.requestQuickSettings() 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 ─────────────────────────────────────────────────────────────── // ── Audio ───────────────────────────────────────────────────────────────
// Without a tracker, volume and muted silently read as zero/false. // Without a tracker, volume and muted silently read as zero/false.
PwObjectTracker { PwObjectTracker {
@@ -59,7 +62,7 @@ Pill {
if (!root.wifiNetwork) if (!root.wifiNetwork)
return "\u{F092D}"; // md-wifi_strength_off return "\u{F092D}"; // md-wifi_strength_off
// NetworkManager reports strength 0-100; some backends normalise to // NetworkManager reports strength 0-100; some backends normalize to
// 0-1, so accept either rather than showing one bar forever. // 0-1, so accept either rather than showing one bar forever.
const raw = root.wifiNetwork.signalStrength ?? 0; const raw = root.wifiNetwork.signalStrength ?? 0;
const strength = raw <= 1 ? raw * 100 : raw; const strength = raw <= 1 ? raw * 100 : raw;
@@ -1,5 +1,5 @@
// One glyph + percentage pair inside VitalsWidget. Split out so the three // One glyph + percentage pair inside VitalsWidget. Split out so the three
// fields cannot drift apart in spacing, width or colour thresholds. // fields cannot drift apart in spacing, width or color thresholds.
import QtQuick import QtQuick
import qs.config import qs.config
@@ -2,7 +2,7 @@
// Vitals extension was configured with (_processor_usage_, _memory_usage_, // Vitals extension was configured with (_processor_usage_, _memory_usage_,
// _gpu#1_usage_). // _gpu#1_usage_).
// //
// Values are colour-coded rather than graphed: at bar size a number you can // Values are color-coded rather than graphed: at bar size a number you can
// glance at beats a sparkline you have to squint at. // glance at beats a sparkline you have to squint at.
import QtQuick import QtQuick
@@ -13,6 +13,10 @@ import qs.widgets
Pill { Pill {
id: root 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 interactive: false
Row { Row {
@@ -10,6 +10,9 @@ import qs.widgets
Pill { Pill {
id: root 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 interactive: false
visible: Weather.available visible: Weather.available
@@ -36,7 +36,7 @@ Item {
implicitWidth: row.implicitWidth + 12 implicitWidth: row.implicitWidth + 12
// Full bar height makes (0, 0) part of the wheel target. The dots stay // Full bar height makes (0, 0) part of the wheel target. The dots stay
// optically centred by the row and active-pill geometry below. // optically centered by the row and active-pill geometry below.
implicitHeight: Theme.barHeight implicitHeight: Theme.barHeight
// Scroll over the padding either side of the row, not just over a dot. // Scroll over the padding either side of the row, not just over a dot.
@@ -59,7 +59,7 @@ Item {
border.width: 0 // QTBUG-137166: rounded rects can lose their corners border.width: 0 // QTBUG-137166: rounded rects can lose their corners
// The prism, as a fill rather than a hairline. This is the one element // The prism, as a fill rather than a hairline. This is the one element
// in the bar that is always coloured, so it is where the pair belongs. // in the bar that is always colored, so it is where the pair belongs.
gradient: Gradient { gradient: Gradient {
orientation: Gradient.Horizontal orientation: Gradient.Horizontal
GradientStop { GradientStop {
@@ -1,7 +1,7 @@
// The floating control panel at the bottom of the capture overlay. // The floating control panel at the bottom of the capture overlay.
// //
// Layout is a deliberate copy of GNOME 42's screenshot UI: the area segmented // Layout is a deliberate copy of GNOME 42's screenshot UI: the area segmented
// control on top, and below it the shutter dead-centre with "Show Pointer" on // control on top, and below it the shutter dead-center with "Show Pointer" on
// the left and the screenshot/screencast switch on the right. // the left and the screenshot/screencast switch on the right.
import QtQuick import QtQuick
@@ -13,7 +13,7 @@ Rectangle {
signal captureRequested signal captureRequested
// Set by the overlay so the shutter can grey out when Window mode has // Set by the overlay so the shutter can gray out when Window mode has
// nothing pickable (no Hyprland, or an empty workspace). // nothing pickable (no Hyprland, or an empty workspace).
property bool captureEnabled: true property bool captureEnabled: true
@@ -1,7 +1,7 @@
// One segment of the Screen / Window / Selection control. // One segment of the Screen / Window / Selection control.
// //
// Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the // Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolour // Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolor
// them, so they'd be invisible on this dark overlay. // them, so they'd be invisible on this dark overlay.
import QtQuick import QtQuick
@@ -1,109 +0,0 @@
// The "you are being recorded" pill.
//
// GNOME puts a red dot and a timer in the top bar while a screencast runs and
// clicking it stops the recording. The bar is another module's window, so this
// is its own tiny layer surface parked just below it.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
PanelWindow {
id: win
visible: Capture.recording
color: "transparent"
// Top only: with neither left nor right anchored, layer-shell centres the
// surface horizontally.
anchors.top: true
margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap
implicitWidth: pill.implicitWidth
implicitHeight: pill.implicitHeight
exclusiveZone: 0
WlrLayershell.namespace: "qs-popover-recording"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
function elapsed(): string {
const t = Capture.recordingSeconds;
const s = ("0" + (t % 60)).slice(-2);
const m = Math.floor(t / 60) % 60;
const h = Math.floor(t / 3600);
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s;
}
Rectangle {
id: pill
implicitWidth: row.implicitWidth + 12
implicitHeight: 34
radius: Theme.pillRadius
border.width: 0
color: Theme.redDeep
Row {
id: row
anchors.centerIn: parent
spacing: 8
// Static dot, not a blinking one: nothing in this shell repaints
// while idle.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 10
height: 10
radius: 5
border.width: 0
color: Theme.fg
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: win.elapsed()
color: Theme.fg
// Tabular figures: the timer must not shuffle as it counts.
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Rectangle {
id: stop
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 26
radius: width / 2
border.width: 0
color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
anchors.centerIn: parent
text: "󰓛"
color: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
}
MouseArea {
id: stopMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// SIGINT, so wf-recorder finalises the container.
onClicked: Capture.stopRecording()
}
}
}
}
}
@@ -1,6 +1,6 @@
// Selection mode: a GNOME-style rubber band over the frozen screen. // Selection mode: a GNOME-style rubber band over the frozen screen.
// //
// Behaviours copied from GNOME 42: the overlay opens with a sensible centred // Behaviors copied from GNOME 42: the overlay opens with a sensible centered
// selection already in place (so the shutter is immediately useful), dragging // selection already in place (so the shutter is immediately useful), dragging
// inside moves it, dragging a corner resizes from the opposite corner, and // inside moves it, dragging a corner resizes from the opposite corner, and
// dragging anywhere else starts a fresh band. // dragging anywhere else starts a fresh band.
@@ -52,7 +52,7 @@ Item {
root.resetSelection(); root.resetSelection();
} }
// Centred, roughly the middle half of the screen. // Centered, roughly the middle half of the screen.
function resetSelection(): void { function resetSelection(): void {
if (root.width <= 0 || root.height <= 0) if (root.width <= 0 || root.height <= 0)
return; return;
@@ -20,8 +20,11 @@ PanelWindow {
top: true top: true
right: true right: true
} }
// The gap ALONE, not the bar height plus the gap -- see the note in
// QuickSettings.qml: exclusiveZone 0 already places this below the bar's
// reserved zone, so adding the bar height counted it twice.
margins { margins {
top: Theme.barHeight + Theme.barGap * 2 top: Theme.barGap * 2
right: Theme.barSideMargin right: Theme.barSideMargin
} }
@@ -1,11 +1,11 @@
// The month grid from GNOME's date menu. // The month grid from GNOME's date menu.
// //
// Six week rows always, so the grid height never changes as you page through // Six week rows always, so the grid height never changes as you page through
// months and the panel below it never jumps. Days belonging to the neighbouring // months and the panel below it never jumps. Days belonging to the neighboring
// months fill the edges, dimmed. Today wears the accent. // months fill the edges, dimmed. Today wears the accent.
// //
// Days become real navigation targets now that Evolution Data Server backs the // Days become real navigation targets now that Evolution Data Server backs the
// panel. Up to three source-coloured markers show whether a date has events. // panel. Up to three source-colored markers show whether a date has events.
import Quickshell import Quickshell
import QtQuick import QtQuick
@@ -134,7 +134,7 @@ Item {
} }
} }
// Centred on the row rather than between the pill and the arrows, // Centered on the row rather than between the pill and the arrows,
// so it does not shift sideways when the pill appears. // so it does not shift sideways when the pill appears.
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
@@ -1,12 +1,12 @@
// GNOME's date/time menu: calendar, ongoing activity and messages in one panel, // GNOME's date/time menu: calendar, ongoing activity and messages in one panel,
// dropping from the top centre because that is where GNOME put it and the // dropping from the top center because that is where GNOME put it and the
// muscle memory is the whole point. // muscle memory is the whole point.
// //
// A layer-shell surface rather than a PopupWindow because the inline reply // A layer-shell surface rather than a PopupWindow because the inline reply
// fields have to be typeable, and because the clock it hangs from is owned by a // fields have to be typeable, and because the clock it hangs from is owned by a
// different module. // different module.
// //
// Layer-shell centres a surface on whichever axis it is not anchored to, so // Layer-shell centers a surface on whichever axis it is not anchored to, so
// anchoring only `top` is what puts it in the middle. // anchoring only `top` is what puts it in the middle.
import QtQuick import QtQuick
@@ -28,7 +28,11 @@ PanelWindow {
color: "transparent" color: "transparent"
anchors.top: true anchors.top: true
margins.top: Theme.barHeight + Theme.barGap * 2 // The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
// "reserve nothing, but respect what others reserved", so this surface
// already begins below the bar's zone -- adding the bar height here counted
// it twice and left the surface floating 48px under the bar instead of 12.
margins.top: Theme.barGap * 2
exclusiveZone: 0 exclusiveZone: 0
implicitWidth: 760 implicitWidth: 760
@@ -87,7 +91,7 @@ PanelWindow {
} }
// Grows out of the top edge it is anchored to, rather than out of its // Grows out of the top edge it is anchored to, rather than out of its
// own centre — the panel should look like it came from the bar. // own center — the panel should look like it came from the bar.
transform: Scale { transform: Scale {
id: openScale id: openScale
origin.x: surface.width / 2 origin.x: surface.width / 2
+117 -15
View File
@@ -22,10 +22,34 @@ PanelWindow {
property var modelData: null property var modelData: null
screen: root.modelData screen: root.modelData
// Which edge this dock lives on, and everything that follows from it. The
// bottom case is unchanged in every particular: same anchors, same
// geometry, same slide -- so a machine that never touches the setting sees
// exactly the dock it had.
readonly property string position: Settings.dockPosition
readonly property bool vertical: root.position === "left" || root.position === "right"
// Only on the screens asked for. An empty list means all of them, which is
// what a single-monitor machine wants and what an unplugged display should
// not be able to change.
readonly property bool onThisScreen: {
const wanted = Settings.dockScreens;
if (!wanted || wanted.length === 0)
return true;
return wanted.indexOf(String(root.screen?.name ?? "")) >= 0;
}
visible: root.onThisScreen
// A dock spans the edge it lives on, which means anchoring BOTH ends of
// that edge: bottom+left+right across the screen, or top+bottom plus one
// side down it. Anchoring only one end leaves the surface free to collapse
// to its implicit size on that axis -- a side dock came out one pixel tall.
anchors { anchors {
top: root.vertical
bottom: true bottom: true
left: true left: root.position !== "right"
right: true right: root.position !== "left"
} }
color: "transparent" color: "transparent"
@@ -42,18 +66,31 @@ PanelWindow {
// ── Geometry ──────────────────────────────────────────────────────────── // ── Geometry ────────────────────────────────────────────────────────────
// Height = room for the tooltip above the bar + the bar + the gap under it. // Height = room for the tooltip above the bar + the bar + the gap under it.
readonly property int revealStripHeight: 3 readonly property int revealStripHeight: 3
readonly property int bottomMargin: Theme.barGap readonly property int edgeMargin: Theme.barGap
readonly property int tooltipSpace: 34 readonly property int tooltipSpace: 34
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin // Only the axis the dock is thin on gets an implicit size; the other is
// spanned by the anchors above. Setting both would fight them.
implicitHeight: root.vertical ? 0 : tooltipSpace + body.implicitHeight + edgeMargin
implicitWidth: root.vertical ? tooltipSpace + body.implicitWidth + edgeMargin : 0
// ── Intellihide ───────────────────────────────────────────────────────── // ── Intellihide ─────────────────────────────────────────────────────────
// This instance's own monitor, the same lookup Workspaces.qml uses to
// scope a per-screen bar to its own screen. Falls back to null when this
// Dock is created standalone (no `screen` set) rather than via Variants.
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
// Hyprland does not expose live toplevel geometry, so exact overlap cannot // Hyprland does not expose live toplevel geometry, so exact overlap cannot
// be computed. "Is anything on this workspace at all" is the robust proxy, // be computed. "Is anything on this workspace at all" is the robust proxy,
// and it is what Dash-to-Dock's all-windows intellihide felt like in // and it is what Dash-to-Dock's all-windows intellihide felt like in
// practice: an empty workspace keeps the dock out. // practice: an empty workspace keeps the dock out.
//
// Deliberately this instance's own monitor's active workspace, not the
// globally-focused one -- with one Dock per screen, keying off the global
// focus would make focusing an empty workspace on monitor A hide the dock
// on monitor B even though B's own workspace is still busy.
readonly property bool workspaceOccupied: { readonly property bool workspaceOccupied: {
const ws = Hyprland.focusedWorkspace; const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
return !!ws && ws.toplevels.values.length > 0; return !!ws && ws.toplevels.values.length > 0;
} }
@@ -83,15 +120,31 @@ PanelWindow {
onTriggered: root.revealed = false onTriggered: root.revealed = false
} }
// Other modules (the bar, the capture overlay) read this. // Other modules (the bar, the capture overlay) read this. It is one
onRevealedChanged: ShellState.dockRevealed = revealed // shared flag but there is one Dock per monitor, so only the instance on
// the currently-focused monitor is allowed to write it -- otherwise
// whichever instance last changed reveal state would stomp the others,
// and a reader would see an arbitrary monitor's value. This scopes the
// flag to mean "is the dock revealed on the monitor the user is on",
// which is what a capture overlay or the bar actually care about.
// (A true per-monitor flag would need ShellState.dockRevealed itself to
// become keyed by screen, which is out of scope here -- see the report.)
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
onRevealedChanged: root._syncShellState()
onIsFocusedMonitorInstanceChanged: root._syncShellState()
function _syncShellState(): void {
if (root.isFocusedMonitorInstance)
ShellState.dockRevealed = root.revealed;
}
// wantRevealed's first evaluation emits no change signal when it lands on // wantRevealed's first evaluation emits no change signal when it lands on
// false (the default), so the initial state has to be taken explicitly — // false (the default), so the initial state has to be taken explicitly —
// otherwise a shell started on a busy workspace would leave the dock up. // otherwise a shell started on a busy workspace would leave the dock up.
Component.onCompleted: { Component.onCompleted: {
revealed = wantRevealed; revealed = wantRevealed;
ShellState.dockRevealed = revealed; root._syncShellState();
} }
// ── Input region ──────────────────────────────────────────────────────── // ── Input region ────────────────────────────────────────────────────────
@@ -114,22 +167,71 @@ PanelWindow {
id: pointer id: pointer
} }
// Revealed: the dock plus everything between it and the edge, so
// crossing the gap does not count as leaving. Hidden: a sliver along
// the edge the dock lives on, which is the only thing that can bring
// it back -- every other click passes through to the window beneath.
Item { Item {
id: maskItem id: maskItem
x: root.revealed ? body.x : 0
y: root.revealed ? body.y : surface.height - root.revealStripHeight x: {
width: root.revealed ? body.width : surface.width if (!root.revealed)
height: root.revealed ? surface.height - body.y : root.revealStripHeight return root.position === "right" ? surface.width - root.revealStripHeight : 0;
return root.position === "right" ? body.x : 0;
}
y: {
if (!root.revealed)
return root.vertical ? 0 : surface.height - root.revealStripHeight;
return root.vertical ? body.y : body.y;
}
width: {
if (!root.revealed)
return root.vertical ? root.revealStripHeight : surface.width;
return root.vertical
? (root.position === "right" ? surface.width - body.x : body.x + body.width)
: body.width;
}
height: {
if (!root.revealed)
return root.vertical ? surface.height : root.revealStripHeight;
return root.vertical ? body.height : surface.height - body.y;
}
} }
DockBody { DockBody {
id: body id: body
anchors.horizontalCenter: parent.horizontalCenter
// Slides off the bottom edge when hidden. vertical: root.vertical
y: root.revealed ? root.tooltipSpace : surface.height leftSide: root.position === "left"
// Both axes are computed rather than anchored. Anchoring the centre
// on one axis and binding a position on the other looks tidier and
// is a conflict: an anchored centre owns that coordinate, so the
// binding beside it is fighting for the same value.
//
// Centred on the long axis; on the short one it sits a tooltip's
// width in from the edge when revealed, and off-screen when not.
x: {
if (!root.vertical)
return (surface.width - width) / 2;
if (root.position === "left")
return root.revealed ? root.tooltipSpace : -width;
return root.revealed ? surface.width - width - root.tooltipSpace : surface.width;
}
y: {
if (root.vertical)
return (surface.height - height) / 2;
return root.revealed ? root.tooltipSpace : surface.height;
}
opacity: root.revealed ? 1 : 0 opacity: root.revealed ? 1 : 0
Behavior on x {
NumberAnimation {
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
}
}
// Asymmetric on purpose. Revealing is a response to something the // Asymmetric on purpose. Revealing is a response to something the
// user just did, so it has to feel immediate — any delay there // user just did, so it has to feel immediate — any delay there
// reads as lag. Hiding is not a response to anything, so it can // reads as lag. Hiding is not a response to anything, so it can
+45 -15
View File
@@ -111,30 +111,51 @@ Rectangle {
inset: parent.radius inset: parent.radius
} }
implicitWidth: row.implicitWidth + Theme.dockPadding * 2 implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
implicitHeight: row.implicitHeight + Theme.dockPadding * 2 implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
// The item the tooltip is currently describing, or null. // The item the tooltip is currently describing, or null.
property Item hoveredItem: null property Item hoveredItem: null
Row { // Set by the Dock. A side dock runs the same strip down the screen instead
id: row // of across it.
property bool vertical: false
// Which way a tooltip points on a side dock: away from the screen edge, so
// it never opens off-screen.
property bool leftSide: true
// Explicit rather than left to Grid's wrapping. This is always one line, so
// saying how many cells it holds is both simpler to read and immune to
// Grid's default column count quietly wrapping a long dock.
readonly property int cellCount: 2 + (root.items ? root.items.length : 0)
// A Grid rather than a Row so one declaration serves both orientations.
// Row and Column would each need their own children, and the cross-axis
// anchors that centre items in a Row (verticalCenter) are the wrong axis in
// a Column -- Grid centres through its own alignment properties instead,
// which is the same result without the anchors positioners disallow.
Grid {
id: strip
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.dockGap spacing: Theme.dockGap
rows: root.vertical ? root.cellCount : 1
columns: root.vertical ? 1 : root.cellCount
horizontalItemAlignment: Grid.AlignHCenter
verticalItemAlignment: Grid.AlignVCenter
ShowAppsButton { ShowAppsButton {
id: showApps id: showApps
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = showApps onEntered: root.hoveredItem = showApps
onExited: if (root.hoveredItem === showApps) onExited: if (root.hoveredItem === showApps)
root.hoveredItem = null root.hoveredItem = null
} }
// Separator between the launcher and the apps, as in GNOME's dash. // Separator between the launcher and the apps, as in GNOME's dash. It
// turns with the dock: a hairline across a column, down a row.
Rectangle { Rectangle {
anchors.verticalCenter: parent.verticalCenter width: root.vertical ? Theme.dockIconSize * 0.7 : 1
width: 1 height: root.vertical ? 1 : Theme.dockIconSize * 0.7
height: Theme.dockIconSize * 0.7
border.width: 0 border.width: 0
color: Theme.alpha(Theme.fg, 0.14) color: Theme.alpha(Theme.fg, 0.14)
} }
@@ -146,7 +167,6 @@ Rectangle {
id: dockItem id: dockItem
required property var modelData required property var modelData
app: modelData app: modelData
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = dockItem onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem) onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null root.hoveredItem = null
@@ -172,16 +192,26 @@ Rectangle {
} }
} }
// Centred on the hovered item, clamped inside the dock. Reads the item's // Centred on the hovered item along the dock's own axis, and placed just
// x directly (rather than mapToItem) so the binding re-evaluates when // outside the edge it lives on. Reads the item's position directly
// the row reflows. // (rather than mapToItem) so the binding re-evaluates when the strip
// reflows.
x: { x: {
if (!root.hoveredItem) if (!root.hoveredItem)
return 0; return 0;
const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2; if (root.vertical)
return root.leftSide ? root.width + 8 : -width - 8;
const centre = strip.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2)); return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
} }
y: -height - 8 y: {
if (!root.hoveredItem)
return -height - 8;
if (!root.vertical)
return -height - 8;
const centre = strip.y + root.hoveredItem.y + root.hoveredItem.height / 2;
return Math.max(4, Math.min(root.height - height - 4, centre - height / 2));
}
width: tipLabel.implicitWidth + Theme.popoverPadding * 2 width: tipLabel.implicitWidth + Theme.popoverPadding * 2
height: tipLabel.implicitHeight + 8 height: tipLabel.implicitHeight + 8
@@ -1,5 +1,5 @@
// One dock icon: the app, its running-window dots, hover scale and click // One dock icon: the app, its running-window dots, hover scale and click
// behaviour. Reproduces Dash-to-Dock's item: 48px icon, up to four dots // behavior. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
// underneath, grow-on-hover, click to focus / cycle / launch. // underneath, grow-on-hover, click to focus / cycle / launch.
import Quickshell import Quickshell
@@ -72,7 +72,7 @@ Item {
} }
// Last resort for an app with no resolvable icon: an initial, which is // Last resort for an app with no resolvable icon: an initial, which is
// still recognisable, unlike the theme's broken-icon placeholder. // still recognizable, unlike the theme's broken-icon placeholder.
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
visible: root.iconSource === "" visible: root.iconSource === ""
@@ -155,7 +155,7 @@ Item {
return; return;
// Quickshell reports the address without the 0x prefix Hyprland's // Quickshell reports the address without the 0x prefix Hyprland's
// window selector expects; normalise so either form works. // window selector expects; normalize so either form works.
if (next.address) { if (next.address) {
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address; const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`); Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
@@ -1,7 +1,7 @@
// One notification, rendered GNOME-style: app icon, app name, summary, body, // One notification, rendered GNOME-style: app icon, app name, summary, body,
// optional image, action buttons and an inline reply box. // optional image, action buttons and an inline reply box.
// //
// Shared by the toasts and the notification centre so a notification looks the // Shared by the toasts and the notification center so a notification looks the
// same wherever you meet it. // same wherever you meet it.
// //
// IconButton/ThemedIcon come from the quicksettings module — they belong in // IconButton/ThemedIcon come from the quicksettings module — they belong in
@@ -21,7 +21,7 @@ Rectangle {
required property var notification required property var notification
// The centre renders a denser card than a toast banner. // The center renders a denser card than a toast banner.
property bool compact: false property bool compact: false
signal dismissed signal dismissed
@@ -35,7 +35,17 @@ Rectangle {
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0) implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
radius: Theme.cardRadius radius: Theme.cardRadius
border.width: 0 border.width: 0
color: hover.containsMouse ? Theme.alpha(Theme.fg, 0.1) : Theme.alpha(Theme.fg, 0.06) // A real popover surface, the same one the date menu and clipboard panel
// use, rather than a 6% foreground tint.
//
// A tint that faint has nothing behind it: the toast window is transparent,
// so the card was sitting directly on whatever happened to be on screen and
// the text competed with it. Notifications are the one surface someone reads
// without having chosen to look at it, so it has to be legible over a
// bright photo and a white document alike.
color: hover.containsMouse
? Theme.alpha(Theme.mix(Theme.bgPopover, Theme.fg, 0.06), Theme.toastAlpha)
: Theme.alpha(Theme.bgPopover, Theme.toastAlpha)
Behavior on color { Behavior on color {
ColorAnimation { ColorAnimation {
@@ -43,7 +53,7 @@ Rectangle {
} }
} }
// Critical notifications get a red edge rather than a colour wash, so the // Critical notifications get a red edge rather than a color wash, so the
// text contrast never changes. // text contrast never changes.
Rectangle { Rectangle {
anchors.left: parent.left anchors.left: parent.left
@@ -1,219 +0,0 @@
// GNOME's message tray: everything that has arrived, grouped by app.
//
// Drops from the top centre because that is where GNOME's tray lives and the
// muscle memory is the whole point. Layer-shell centres a surface on whichever
// axis it is not anchored to, so anchoring only `top` does it.
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import Quickshell.Widgets
import qs.config
import qs.services
import qs.modules.quicksettings
import qs.widgets
PanelWindow {
id: root
visible: ShellState.notificationsOpen
color: "transparent"
anchors.top: true
margins.top: Theme.barHeight + Theme.barGap * 2
exclusiveZone: 0
implicitWidth: 440
implicitHeight: surface.implicitHeight
WlrLayershell.namespace: "qs-popover-notifications"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
onVisibleChanged: {
if (root.visible)
Notifs.markAllRead();
}
HyprlandFocusGrab {
windows: [root]
active: root.visible
onCleared: ShellState.close()
}
Rectangle {
id: surface
anchors.fill: parent
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
// border so the two don't fight for the same row of pixels.
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: ShellState.close()
Column {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.popoverPadding
spacing: Theme.itemSpacing
// ── Header ──────────────────────────────────────────────────
Item {
width: parent.width
height: 32
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
IconButton {
size: 30
iconSize: 16
icon: "notifications-disabled-symbolic"
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
Rectangle {
width: clearLabel.implicitWidth + 20
height: 28
radius: Theme.pillRadius
border.width: 0
visible: Notifs.hasNotifications
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
id: clearLabel
anchors.centerIn: parent
text: "Clear all"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: clearMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Notifs.dismissAll()
}
}
}
}
// ── Empty state ─────────────────────────────────────────────
Item {
width: parent.width
height: 120
visible: !Notifs.hasNotifications
Text {
anchors.centerIn: parent
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
// ── Grouped history ─────────────────────────────────────────
ScrollColumn {
width: parent.width
maxHeight: 620
spacing: Theme.itemSpacing
visible: Notifs.hasNotifications
Repeater {
model: Notifs.groups
Column {
id: group
required property var modelData
width: parent.width
spacing: 4
Item {
width: parent.width
height: 26
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: group.modelData.app
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
IconButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
size: 24
iconSize: 13
tint: Theme.fgDim
icon: "edit-clear-all-symbolic"
iconFallback: "window-close-symbolic"
onClicked: Notifs.dismissApp(group.modelData.app)
}
}
Repeater {
model: group.modelData.items
NotificationCard {
required property var modelData
width: group.width
compact: true
notification: modelData
onDismissed: Notifs.dismiss(modelData)
}
}
}
}
}
}
}
}
}
@@ -1,7 +1,6 @@
// A single banner: a notification card that slides in and times itself out. // A single banner: a notification card that slides in and times itself out.
import QtQuick import QtQuick
import Quickshell.Services.Notifications
import qs.config import qs.config
import qs.services import qs.services
@@ -14,28 +13,25 @@ Item {
// into a keyboard focus request on the layer surface. // into a keyboard focus request on the layer surface.
signal replyFocusChanged(bool focused) signal replyFocusChanged(bool focused)
// Mirrors the signal above so the dismiss timer below can read it.
property bool replyFocused: false
implicitHeight: card.implicitHeight implicitHeight: card.implicitHeight
// Critical notifications stay until dismissed (the setting is 0). An app // Critical notifications stay until dismissed (the setting is 0). An app
// asking for 0 means "never expire" per the freedesktop spec; -1 means // asking for 0 means "never expire" per the freedesktop spec; -1 means
// "server decides", which is our default. // "server decides", which is our default. Shared with Notifs.qml, which
readonly property int timeoutMs: { // gives a DND-hidden transient notification the same lifetime.
if (root.notification.urgency === NotificationUrgency.Critical) readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
return Settings.notificationTimeoutCriticalMs;
if (root.notification.expireTimeout === 0)
return 0;
if (root.notification.expireTimeout > 0)
return Math.round(root.notification.expireTimeout * 1000);
return Settings.notificationTimeoutMs;
}
HoverHandler { HoverHandler {
id: hover id: hover
} }
Timer { Timer {
// Hovering holds the banner open; the countdown restarts on leave. // Hovering, or actively typing a reply, holds the banner open; the
running: root.timeoutMs > 0 && !hover.hovered // countdown restarts (from the top, same as hover) once both let go.
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
interval: root.timeoutMs interval: root.timeoutMs
onTriggered: Notifs.dropPopup(root.notification) onTriggered: Notifs.dropPopup(root.notification)
} }
@@ -45,7 +41,10 @@ Item {
width: parent.width width: parent.width
notification: root.notification notification: root.notification
onDismissed: Notifs.dropPopup(root.notification) onDismissed: Notifs.dropPopup(root.notification)
onReplyFocusChanged: focused => root.replyFocusChanged(focused) onReplyFocusChanged: focused => {
root.replyFocused = focused;
root.replyFocusChanged(focused);
}
// Slide in from the right edge. Runs once, on creation. // Slide in from the right edge. Runs once, on creation.
NumberAnimation on x { NumberAnimation on x {
@@ -17,7 +17,11 @@ PanelWindow {
anchors.top: true anchors.top: true
anchors.right: true anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2 // The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
// "reserve nothing, but respect what others reserved", so this surface
// already begins below the bar's zone -- adding the bar height here counted
// it twice and left the surface floating 48px under the bar instead of 12.
margins.top: Theme.barGap * 2
margins.right: Theme.barSideMargin margins.right: Theme.barSideMargin
exclusiveZone: 0 exclusiveZone: 0
@@ -45,7 +49,17 @@ PanelWindow {
spacing: Theme.itemSpacing spacing: Theme.itemSpacing
Repeater { Repeater {
model: Notifs.popups.slice(0, Settings.maxVisibleToasts) // Notifs.popups.slice() is a fresh array on every change (a new
// arrival, a dismissal, a sibling toast timing out). Handing that
// straight to Repeater would reset the model and rebuild every
// delegate each time, blowing away whichever toast has a reply
// field mid-typing. ScriptModel diffs by object identity
// (Notification instances are unique QObjects), so only genuinely
// added/removed notifications add/remove delegates — unrelated
// toasts, and their slide-in animations, are untouched.
model: ScriptModel {
values: Notifs.popups.slice(0, Settings.maxVisibleToasts)
}
Toast { Toast {
required property var modelData required property var modelData
@@ -1,6 +1,6 @@
// One workspace in the overview: a proportional card holding live thumbnails // One workspace in the overview: a proportional card holding live thumbnails
// of its windows, labelled underneath. The focused workspace is outlined in the // of its windows, labeled underneath. The focused workspace is outlined in the
// accent colour, as GNOME outlines the current workspace. // accent color, as GNOME outlines the current workspace.
// //
// A card with a null `workspace` is the trailing "new workspace" affordance // A card with a null `workspace` is the trailing "new workspace" affordance
// that dynamic-workspace setups (GNOME's, and Hyprland's) always show. // that dynamic-workspace setups (GNOME's, and Hyprland's) always show.
@@ -0,0 +1,157 @@
// The authentication prompt.
//
// Deliberately a Panama surface rather than the compositor's stock agent: this
// is the window that asks for the password to everything, and it was the one
// window on the desktop that looked like it belonged to something else.
//
// Two things here are security, not styling. It takes EXCLUSIVE keyboard focus,
// so keystrokes cannot reach the window underneath while a password is being
// typed. And the field is cleared on every exit path, including the ones nobody
// plans for.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.modules.settings
PanelWindow {
id: root
visible: Polkit.active
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
exclusiveZone: 0
WlrLayershell.namespace: "qs-polkit"
WlrLayershell.layer: WlrLayer.Overlay
// Exclusive, not OnDemand: a password prompt that lets keystrokes through
// to whatever is behind it is a keylogger with extra steps.
WlrLayershell.keyboardFocus: Polkit.active
? WlrKeyboardFocus.Exclusive
: WlrKeyboardFocus.None
onVisibleChanged: {
if (root.visible) {
field.text = "";
field.forceActiveFocus();
} else {
field.text = "";
}
}
// Dims what is behind, and swallows clicks so nothing outside the dialog
// can be operated while it is waiting.
Rectangle {
anchors.fill: parent
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
MouseArea {
anchors.fill: parent
// Clicking outside does nothing on purpose. Dismissing an
// authentication request by misclick, and having the thing that
// asked report a mysterious failure, is worse than an explicit
// Cancel.
hoverEnabled: true
}
}
Rectangle {
id: dialog
anchors.centerIn: parent
width: 420
implicitHeight: layout.implicitHeight + 44
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
Column {
id: layout
anchors.centerIn: parent
width: parent.width - 44
spacing: 14
Text {
width: parent.width
text: "Authentication required"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: Polkit.message
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: Polkit.users.length > 1
text: "Authenticating as " + Polkit.chosenUser
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
PasswordField {
id: field
width: parent.width
placeholder: "Password"
enabled: !Polkit.authenticating
onAccepted: {
if (field.text !== "")
Polkit.submit(field.text);
field.text = "";
}
}
Text {
width: parent.width
visible: Polkit.failureText !== ""
text: Polkit.failureText
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Row {
anchors.right: parent.right
spacing: 8
SettingsButton {
text: "Cancel"
onClicked: Polkit.cancel()
}
SettingsButton {
text: Polkit.authenticating ? "Checking…" : "Authenticate"
tone: "accent"
enabled: !Polkit.authenticating
onClicked: {
if (field.text !== "")
Polkit.submit(field.text);
field.text = "";
}
}
}
}
}
// Escape cancels, which is what every other dialog on this desktop does.
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: Polkit.cancel()
}
}
@@ -0,0 +1,2 @@
module qs.modules.polkit
PolkitPrompt 1.0 PolkitPrompt.qml
@@ -114,7 +114,7 @@ Item {
width: parent.width width: parent.width
// BlueZ reports a plain freedesktop name ("audio-headphones"); // BlueZ reports a plain freedesktop name ("audio-headphones");
// the symbolic variant is the one that can be recoloured. // the symbolic variant is the one that can be recolored.
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic" icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
iconFallback: "bluetooth-symbolic" iconFallback: "bluetooth-symbolic"
label: deviceRow.modelData.name || deviceRow.modelData.address label: deviceRow.modelData.name || deviceRow.modelData.address
@@ -29,7 +29,7 @@ Item {
property real backlightValue: 0 property real backlightValue: 0
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
readonly property bool labelled: root.rowCount > 1 readonly property bool labeled: root.rowCount > 1
visible: root.rowCount > 0 visible: root.rowCount > 0
implicitHeight: rows.implicitHeight implicitHeight: rows.implicitHeight
@@ -89,7 +89,7 @@ Item {
// Hyprland already knows what each output is called, so the // Hyprland already knows what each output is called, so the
// name comes from there rather than from a second source that // name comes from there rather than from a second source that
// could disagree with the Displays page. The connector is the // could disagree with the Displays page. The connector is the
// fallback, so a display is never an unlabelled slider. // fallback, so a display is never an unlabeled slider.
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
value: modelData.value / 100 value: modelData.value / 100
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100)) onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
@@ -113,7 +113,7 @@ Item {
anchors.leftMargin: 6 anchors.leftMargin: 6
anchors.rightMargin: 6 anchors.rightMargin: 6
anchors.top: parent.top anchors.top: parent.top
visible: root.labelled visible: root.labeled
height: visible ? implicitHeight + 2 : 0 height: visible ? implicitHeight + 2 : 0
text: row.label text: row.label
color: Theme.fgDim color: Theme.fgDim
@@ -124,7 +124,7 @@ Item {
// The slider and its glyph share one strip so the two stay aligned // 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 // whether or not a caption sits above them. Anchoring the glyph to
// both a caption and a centre line instead would conflict, and an // both a caption and a center line instead would conflict, and an
// anchor set to undefined is not released. // anchor set to undefined is not released.
Item { Item {
id: control id: control
@@ -134,7 +134,7 @@ Item {
height: 32 height: 32
// ValueSlider draws its own leading icon, but symbolic icons need // ValueSlider draws its own leading icon, but symbolic icons need
// recolouring to be visible — see ThemedIcon. // recoloring to be visible — see ThemedIcon.
ThemedIcon { ThemedIcon {
id: glyph id: glyph
anchors.left: parent.left anchors.left: parent.left
@@ -36,9 +36,30 @@ Item {
Rectangle { Rectangle {
id: restingCard id: restingCard
// Shown when the section is closed; the expanded grid below replaces
// it when open.
readonly property bool shown: root.hasSelection && !root.expanded
width: parent.width width: parent.width
height: visible ? restingGrid.implicitHeight + 20 : 0
visible: root.hasSelection && !root.expanded // Animated, and on the same curve and duration as the Section that
// replaces it. Previously this hid the instant `expanded` flipped:
// a Column skips invisible children, so everything above snapped up
// while the expanded grid was still sliding open underneath. That
// read as a glitch rather than as an animation, and only here,
// because this is the only control with a resting and an expanded
// form that swap.
height: restingCard.shown ? restingGrid.implicitHeight + 20 : 0
visible: height > 0
clip: true
Behavior on height {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
radius: Theme.cardRadius + 2 radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.warn, 0.028) color: Theme.alpha(Theme.warn, 0.028)
border.width: 1 border.width: 1
@@ -0,0 +1,57 @@
// The three system power profiles, as an expandable list.
//
// A row per profile rather than a cycling button: there are three, and cycling
// through them means passing through one you did not want on a machine where
// the change is immediate and audible. The same shape the audio device lists
// use, so the panel reads consistently.
//
// The daemon owns the profile -- it survives a shell restart and anything else
// on the system can change it -- so this reads back rather than assuming, the
// same as monitor brightness.
import QtQuick
import qs.config
import qs.services
Column {
id: root
spacing: 2
Repeater {
model: PowerProfiles.profiles
RowButton {
required property var modelData
width: root.width
icon: {
switch (modelData) {
case "power-saver": return "power-profile-power-saver-symbolic";
case "performance": return "power-profile-performance-symbolic";
default: return "power-profile-balanced-symbolic";
}
}
iconFallback: "preferences-system-power-symbolic"
label: PowerProfiles.label(modelData)
sublabel: PowerProfiles.detail(modelData)
selected: modelData === PowerProfiles.active
dimmed: PowerProfiles.busy
onClicked: PowerProfiles.set(modelData)
}
}
// Only when it is true. A machine that cannot deliver the profile it is set
// to is the one case where the label alone is misleading.
Text {
visible: PowerProfiles.degraded !== ""
width: root.width
text: "Limited right now: " + PowerProfiles.degraded
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
leftPadding: 10
topPadding: 4
}
}
@@ -20,9 +20,21 @@ PanelWindow {
// Hangs directly beneath the top-right status cluster. Two logical pixels // Hangs directly beneath the top-right status cluster. Two logical pixels
// preserve the Prism edge without making the surface feel detached. // preserve the Prism edge without making the surface feel detached.
//
// The margin is the gap ALONE, not the bar height plus the gap. An
// exclusiveZone of 0 means "reserve nothing, but respect what others
// reserved", so this surface already begins below the bar's 36px zone;
// adding the bar height here counted it twice and left the panel floating
// 38px under the bar instead of 2px. It also means the panel follows the
// bar on its own -- if the bar ever stops reserving space, this closes up
// against the top edge rather than hanging under nothing.
anchors.top: true anchors.top: true
anchors.right: true anchors.right: true
margins.top: Theme.barHeight + Theme.controlCenterTopGap // The same gap every other popover uses. This had its own constant set to
// 2, which left the widest surface in the shell hanging ten pixels higher
// than the date menu beside it -- an early value nothing else converged on
// rather than a decision; it carried no reason, while barGap does.
margins.top: Theme.barGap * 2
margins.right: Theme.barSideMargin margins.right: Theme.barSideMargin
exclusiveZone: 0 exclusiveZone: 0
@@ -45,6 +57,9 @@ PanelWindow {
if (root.visible) { if (root.visible) {
KdeConnect.refresh(); KdeConnect.refresh();
HomeAssistant.refresh(); HomeAssistant.refresh();
// The daemon owns the power profile and anything on the system can
// change it, so the panel asks rather than trusting what it last saw.
PowerProfiles.refresh();
openAnim.restart(); openAnim.restart();
} else { } else {
panel.reset(); panel.reset();
@@ -107,6 +107,27 @@ Item {
onExpanded: root.expand("wifi") onExpanded: root.expand("wifi")
} }
// Only present when there is a wired device at all -- most machines
// this runs on have one, but a laptop without a dock does not, and
// an Ethernet tile there would be a control for absent hardware.
Toggle {
width: root.cellWidth
visible: Connectivity.wiredDevice !== null
icon: Connectivity.wiredOn
? "network-wired-symbolic"
: "network-wired-disconnected-symbolic"
label: "Ethernet"
active: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
sublabel: {
if (Connectivity.wiredOn)
return Connectivity.wiredDevice.linkSpeed > 0
? Connectivity.wiredDevice.linkSpeed + " Mb/s" : "Connected";
return "Off";
}
onToggled: Connectivity.setWired(!Connectivity.wiredOn)
}
Toggle { Toggle {
width: root.cellWidth width: root.cellWidth
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic" icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
@@ -130,6 +151,21 @@ Item {
onToggled: Caffeine.toggle() onToggled: Caffeine.toggle()
} }
Toggle {
width: root.cellWidth
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
label: "Appearance"
sublabel: ColorScheme.dark ? "Dark" : "Light"
// "active" reads as the non-default state across this grid, and
// dark is what Panama ships, so light is the lit one.
active: !ColorScheme.dark
// Writes the preference and stops. ColorScheme propagates it to
// GTK, the portal, the terminals, the launcher, btop, tmux and
// the lock screen; nothing here needs to know that list.
onToggled: SystemSettings.commitPreference("colorScheme",
ColorScheme.dark ? "light" : "dark")
}
Toggle { Toggle {
width: root.cellWidth width: root.cellWidth
icon: "night-light-symbolic" icon: "night-light-symbolic"
@@ -190,6 +226,38 @@ Item {
color: Theme.alpha(Theme.fg, 0.1) color: Theme.alpha(Theme.fg, 0.1)
} }
// ── Power ───────────────────────────────────────────────────────────
// Only where a power-profiles daemon is actually running. A desktop
// without one should not show a control that cannot do anything.
RowButton {
visible: PowerProfiles.available
width: content.width
icon: {
switch (PowerProfiles.active) {
case "power-saver": return "power-profile-power-saver-symbolic";
case "performance": return "power-profile-performance-symbolic";
default: return "power-profile-balanced-symbolic";
}
}
iconFallback: "preferences-system-power-symbolic"
label: "Power profile"
sublabel: PowerProfiles.degraded !== ""
? PowerProfiles.label(PowerProfiles.active) + " · limited"
: PowerProfiles.label(PowerProfiles.active)
selected: root.expandedSection === "power"
onClicked: root.expand("power")
}
Section {
width: content.width
expanded: root.expandedSection === "power"
PowerProfileList {
anchors.left: parent.left
anchors.right: parent.right
}
}
// ── Sliders ───────────────────────────────────────────────────────── // ── Sliders ─────────────────────────────────────────────────────────
AudioSlider { AudioSlider {
width: content.width width: content.width
@@ -257,14 +325,44 @@ Item {
width: content.width width: content.width
height: 36 height: 36
ThemedIcon { // The account's own picture, the same file the lock screen and the
// login screen read. A generic glyph sat here while a real avatar
// was already set, which made the desktop look like it did not know
// whose it was.
Item {
id: avatar id: avatar
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 4 anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
size: 20 width: 22
icon: "avatar-default-symbolic" height: 22
iconFallback: "user-info-symbolic"
ClippingRectangle {
anchors.fill: parent
radius: width / 2
color: "transparent"
visible: UserAccounts.avatarUrl !== ""
Image {
anchors.fill: parent
source: UserAccounts.avatarUrl
fillMode: Image.PreserveAspectCrop
// Replaced in place when the picture changes, so the
// cache has to be told to let go of the old one.
cache: false
asynchronous: true
sourceSize.width: 44
sourceSize.height: 44
}
}
ThemedIcon {
anchors.centerIn: parent
visible: UserAccounts.avatarUrl === ""
size: 20
icon: "avatar-default-symbolic"
iconFallback: "user-info-symbolic"
}
} }
Text { Text {
@@ -273,7 +371,9 @@ Item {
anchors.right: actions.left anchors.right: actions.left
anchors.rightMargin: 8 anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: Quickshell.env("USER") || "user" text: UserAccounts.me
? UserAccounts.displayName(UserAccounts.me)
: (Quickshell.env("USER") || "user")
color: Theme.fg color: Theme.fg
elide: Text.ElideRight elide: Text.ElideRight
font.family: Theme.fontFamily font.family: Theme.fontFamily
@@ -95,7 +95,7 @@ Rectangle {
} }
} }
// Full-height rather than vertically centred so slot contents can anchor // Full-height rather than vertically centered so slot contents can anchor
// their own verticalCenter to it without a size loop. // their own verticalCenter to it without a size loop.
Row { Row {
id: trailingRow id: trailingRow
@@ -137,7 +137,18 @@ Item {
} }
Repeater { Repeater {
model: root.networks // root.networks is a fresh array on every signal-strength tick (the
// sort comparator reads signalStrength/connected/known, so any of
// those changing on ANY network recomputes the whole list). Handing
// that straight to Repeater would reset the model and rebuild every
// delegate each tick, blowing away whichever row has its password
// Section open and focused. ScriptModel diffs by object identity
// (WifiNetwork instances are unique QObjects) and turns a reorder
// into move operations, so existing delegates -- and their expanded
// state -- survive.
model: ScriptModel {
values: root.networks
}
Column { Column {
id: entry id: entry
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
ScrollColumn 1.0 ScrollColumn.qml ScrollColumn 1.0 ScrollColumn.qml
Section 1.0 Section.qml Section 1.0 Section.qml
WifiList 1.0 WifiList.qml WifiList 1.0 WifiList.qml
PowerProfileList 1.0 PowerProfileList.qml
@@ -4,11 +4,20 @@ import qs.config
import qs.services import qs.services
SettingsPage { SettingsPage {
title: "About Panama" id: root
title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste." 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 { SettingsCard {
title: "Panama Desktop" title: "Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling" subtitle: "Tokyo Night Moon · Prism glass · native tiling"
TextRow { 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 { SettingsCard {
title: "Design principles" title: "Design principles"
@@ -0,0 +1,100 @@
// Choosing the desktop accent.
//
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
// dot, because the gradient is the thing being chosen -- the focused window
// border, the bar hairline and every active state are the two colors meeting.
// A row of flat circles would misrepresent all of them.
//
// Named accents rather than a color wheel: each name carries a curated pair
// per scheme, so every choice stays legible in both light and dark. See
// config/Theme.qml for the palette and the reasoning.
import QtQuick
import qs.config
import qs.services
Flow {
id: root
spacing: 10
readonly property string current: DesktopPreferences.get("accentName") || "blue"
// The schema's own option list, not Theme.accents directly -- two lists
// hand-kept in sync is how they drift. This is the same pattern
// ChoiceRow.qml uses for every other enum row.
readonly property var spec: PreferenceSchema.spec("accentName")
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
Repeater {
// The schema's option order is the palette's order, so blue is first
// because it is what Panama ships.
model: root.options
Column {
id: entry
required property var modelData
readonly property string name: entry.modelData.value
readonly property var pair: Theme.accents[entry.name]
readonly property bool selected: entry.name === root.current
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
spacing: 5
// The hit target is the whole swatch+label unit, not just the
// 46px circle: the label exists specifically so someone with a
// color vision deficiency can identify an accent without it, and
// a label that cannot itself be tapped defeats that.
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (!SystemSettings.commitPreference("accentName", entry.name))
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
}
}
Rectangle {
width: 46
height: 46
radius: 23
anchors.horizontalCenter: parent.horizontalCenter
color: "transparent"
// The ring sits outside the gradient rather than over it, so a
// selected swatch still shows its true colors.
border.width: entry.selected ? 2 : 1
border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)
Rectangle {
anchors.fill: parent
anchors.margins: entry.selected ? 4 : 3
radius: width / 2
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: entry.start }
GradientStop { position: 1.0; color: entry.end }
}
}
}
// Always shown, not a tooltip. Telling swatches apart by color is
// exactly what someone with a color vision deficiency cannot do,
// and it is the reason the palette is named rather than freeform --
// hiding the names behind a hover would waste that.
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: entry.modelData.label
color: entry.selected ? Theme.fg : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: entry.selected ? Font.DemiBold : Font.Normal
}
}
}
}
@@ -25,32 +25,66 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Text" 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 } SliderRow { setting: "textScale" }
// Reaches GTK4 applications through the desktop portal, which
// republishes it as org.freedesktop.appearance contrast. No
// high-contrast theme is involved, and none is installed here -- older
// GTK3 applications will not change.
ToggleRow { setting: "highContrast"; divider: false }
} }
SettingsCard { SettingsCard {
title: "Motion" 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 } 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 { SettingsCard {
title: "Contrast" title: "Magnifier"
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read." 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 { SettingsCard {
title: "System accessibility" title: "Contrast"
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack." 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 { ActionRow {
label: "GNOME accessibility settings" label: "GNOME accessibility settings"
detail: "Opens in GNOME Settings" detail: "For the parts GNOME's own stack still owns"
action: "Open" action: "Open"
divider: false divider: false
onTriggered: SystemSettings.openGnomePanel("universal-access") onTriggered: SystemSettings.openGnomePanel("universal-access")
@@ -17,8 +17,23 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
property string expandedPicker: ""
// Which group of cards is on screen. Theme leads deliberately: light and
// dark is the control reached most often, and it used to be the third
// section down, below a wallpaper grid and the whole lock screen.
property string tab: "theme"
// Arriving from another page that named a section opens on it. Taken once
// rather than bound, so the tabs still work normally afterwards.
Component.onCompleted: {
const section = ShellState.takeSettingsSection();
if (section !== "")
root.tab = section;
}
title: "Appearance" 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 { header: Component {
Column { Column {
@@ -42,15 +57,35 @@ SettingsPage {
} }
} }
SettingsTabs {
tabs: [
{ value: "theme", label: "Theme" },
{ value: "background", label: "Background" },
{ value: "type", label: "Typography" },
{ value: "windows", label: "Windows" },
{ value: "shell", label: "Shell" },
]
current: root.tab
onSelected: value => root.tab = value
}
SettingsCard { SettingsCard {
visible: root.tab === "background"
title: "Background" title: "Background"
subtitle: Wallpaper.lastError !== "" subtitle: Wallpaper.lastError !== ""
? 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 { WallpaperPicker {
id: wallpapers id: wallpapers
width: parent.width width: parent.width
mode: wallpaperControls.mode
selectedOutput: wallpaperControls.selectedOutput
} }
ActionRow { ActionRow {
@@ -74,16 +109,43 @@ SettingsPage {
} }
SettingsCard { SettingsCard {
title: "Colour scheme" visible: root.tab === "background"
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 {
visible: root.tab === "theme"
title: "Color scheme"
subtitle: ColorScheme.lastError !== "" subtitle: ColorScheme.lastError !== ""
? 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." : "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 } ChoiceRow { setting: "colorScheme" }
// Drawn as the gradient each accent produces rather than a flat dot,
// because the gradient is what is being chosen.
AccentPicker {
width: parent.width
}
} }
SettingsCard { SettingsCard {
title: "Typography" visible: root.tab === "type"
title: "Shell typography"
subtitle: Fonts.lastError !== "" subtitle: Fonts.lastError !== ""
? Fonts.lastError ? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name." : "Every piece of text in the shell. Samples are drawn in the font they name."
@@ -124,6 +186,141 @@ SettingsPage {
} }
SettingsCard { SettingsCard {
visible: root.tab === "type"
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 {
visible: root.tab === "type"
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 {
visible: root.tab === "windows"
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 {
visible: root.tab === "windows"
title: "Windows" title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
@@ -131,10 +328,14 @@ SettingsPage {
SliderRow { setting: "gapsIn" } SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" } SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" } 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 { SettingsCard {
visible: root.tab === "theme"
title: "Effects" title: "Effects"
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming." subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
@@ -143,12 +344,15 @@ SettingsPage {
SliderRow { setting: "blurPasses" } SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" } ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" } SliderRow { setting: "shadowRange"; zeroLabel: "None" }
SliderRow { setting: "shadowRenderPower" }
ToggleRow { setting: "shadowSharp" }
ToggleRow { setting: "glowEnabled" } ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" } SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false } ToggleRow { setting: "animationsEnabled"; divider: false }
} }
SettingsCard { SettingsCard {
visible: root.tab === "shell"
title: "Clock" title: "Clock"
ToggleRow { setting: "use24Hour" } ToggleRow { setting: "use24Hour" }
@@ -157,12 +361,16 @@ SettingsPage {
} }
SettingsCard { SettingsCard {
visible: root.tab === "shell"
title: "System vitals" title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator." subtitle: "Choose what appears beside the workspace indicator."
ToggleRow { setting: "showCpu" } ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" } ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing } 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. // Only worth asking when there is a choice to make.
ChoiceGrid { ChoiceGrid {
@@ -182,11 +390,4 @@ SettingsPage {
} }
} }
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,15 +12,27 @@ SettingsPage {
lede: "Choose what opens your files and links, and what starts with your session." lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: "" property string expandedRole: ""
property bool addingAutostart: false
// Which entry has been asked to be removed. Removal deletes a file, so
// it never happens on a first press.
property string confirmingAutostartRemoval: ""
readonly property var applications: DesktopEntries.applications.values readonly property var applications: DesktopEntries.applications.values
// Each role governs a whole family of types, not one representative: setting
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
// can never open one image in a viewer and its neighbour in an editor.
// The detail line names the family the way someone would describe it.
readonly property var roles: [ readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] }, { key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] }, { key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] }, { key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] }, { key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] }, { key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] } { key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] },
{ key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] },
{ key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] },
{ key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] }
] ]
function desktopId(entry: var): string { function desktopId(entry: var): string {
@@ -42,16 +54,26 @@ SettingsPage {
} }
function matchesRole(entry: var, role: var): bool { function matchesRole(entry: var, role: var): bool {
const rawCategories = Array.isArray(entry.categories) // DesktopEntries hands back a QML list, not a JavaScript array, so
? entry.categories // Array.isArray is false for it. The old code took that as "this is a
: [String(entry.categories ?? "")]; // string", stringified the list into "Network,WebBrowser" and then split
// on ";" only -- producing the single token "network,webbrowser", which
// matches no category at all.
//
// Nothing failed loudly. Browsers still appeared because their generic
// name contains "web browser", so the terms fallback carried the role
// by itself. Archives matched NOTHING, which meant that row could only
// ever offer the application it already had.
//
// Joining first and splitting on both separators handles the list form
// and a plain string equally.
const raw = entry.categories;
const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? "");
const categories = []; const categories = [];
for (const rawCategory of rawCategories) { for (const value of joined.split(/[;,]/)) {
for (const value of String(rawCategory).split(";")) { const category = value.trim().toLowerCase();
const category = value.trim().toLowerCase(); if (category !== "")
if (category !== "") categories.push(category);
categories.push(category);
}
} }
const metadata = [entry.name, entry.genericName] const metadata = [entry.name, entry.genericName]
.map(value => String(value ?? "").toLowerCase()) .map(value => String(value ?? "").toLowerCase())
@@ -94,14 +116,46 @@ SettingsPage {
width: parent.width width: parent.width
SettingRow { SettingRow {
id: roleRow
readonly property bool open: root.expandedRole === roleBlock.modelData.key
label: roleBlock.modelData.label label: roleBlock.modelData.label
detail: roleBlock.modelData.detail detail: roleBlock.modelData.detail
value: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
controlWidth: 210
// Drawn rather than left to SettingRow's plain value text, so
// the row carries the same chevron a PickerRow does. These
// open a chooser but looked completely inert without it.
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: roleBlock.choices.length > 0
text: roleRow.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1 divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
onActivated: { onActivated: {
root.expandedRole = root.expandedRole === roleBlock.modelData.key root.expandedRole = root.expandedRole === roleBlock.modelData.key
@@ -142,9 +196,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 { SettingsCard {
title: "User autostart" 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 { TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0 visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
@@ -163,19 +272,57 @@ SettingsPage {
required property var modelData required property var modelData
required property int index required property int index
readonly property bool confirming:
root.confirmingAutostartRemoval === String(autostartRow.modelData.id)
label: autostartRow.modelData.name label: autostartRow.modelData.name
detail: autostartRow.modelData.id detail: autostartRow.confirming
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled" ? "Removing deletes this entry. Turning it off instead is reversible."
activatable: !DefaultApps.busy : autostartRow.modelData.id
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1 divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled) controlWidth: 210
// A switch, not the words "Enabled"/"Disabled". The row always
// toggled on click, but read as static text, so a control that
// worked looked like a status nobody could change.
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: autostartRow.confirming
text: "Remove it"
tone: "danger"
enabled: !DefaultApps.busy
onClicked: {
root.confirmingAutostartRemoval = "";
DefaultApps.removeAutostart(String(autostartRow.modelData.id));
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: autostartRow.confirming ? "Keep" : "Remove…"
enabled: !DefaultApps.busy
onClicked: root.confirmingAutostartRemoval =
autostartRow.confirming ? "" : String(autostartRow.modelData.id)
}
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: autostartRow.modelData.enabled
onToggled: value => DefaultApps.setAutostart(autostartRow.modelData.id, value)
}
}
} }
} }
} }
SettingsCard { SettingsCard {
title: "Compositor autostart" 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 { TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0 visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
@@ -32,7 +32,7 @@ SettingRow {
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft) readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight) readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
readonly property bool available: root.node?.audio readonly property bool available: !!root.node?.audio
&& root.leftIndex >= 0 && root.rightIndex >= 0 && root.leftIndex >= 0 && root.rightIndex >= 0
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex) && root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
@@ -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,231 @@
// Choosing which part of a picture becomes the profile picture.
//
// Without this, whatever was picked went to accountsservice whole, and a
// landscape photograph became a squashed thumbnail with the subject off the
// edge. The circle is drawn where the avatar is actually round elsewhere in the
// shell, so what is framed here is what appears there.
//
// The crop is reported in the picture's OWN pixels, not screen ones, so the
// result does not depend on the size this happened to be displayed at.
import QtQuick
import qs.config
Item {
id: root
// Absolute path of the picture being framed.
property string source: ""
// Side of the square viewport, in screen pixels.
readonly property int viewport: 260
// How far in the picture is zoomed, as a multiple of the smallest scale
// that still covers the viewport. 1 means "just covers".
property real zoom: 1
readonly property real maxZoom: 4
// Top-left of the drawn picture, relative to the viewport's top-left.
property real offsetX: 0
property real offsetY: 0
readonly property bool ready: picture.status === Image.Ready
&& picture.sourceSize.width > 0 && picture.sourceSize.height > 0
// The scale at which the shorter side exactly fills the viewport. Any
// smaller and the square would include area the picture does not cover.
readonly property real baseScale: root.ready
? root.viewport / Math.min(picture.sourceSize.width, picture.sourceSize.height)
: 1
readonly property real scale: root.baseScale * root.zoom
readonly property real drawnWidth: root.ready ? picture.sourceSize.width * root.scale : 0
readonly property real drawnHeight: root.ready ? picture.sourceSize.height * root.scale : 0
// Emitted with a square in the picture's own pixel coordinates.
signal cropped(int x, int y, int size)
signal cancelled
implicitWidth: parent ? parent.width : 620
implicitHeight: column.implicitHeight
// Keeps the viewport covered: the picture may never be dragged far enough
// to expose an edge, so the crop is always entirely inside the image.
function clamp(): void {
root.offsetX = Math.min(0, Math.max(root.viewport - root.drawnWidth, root.offsetX));
root.offsetY = Math.min(0, Math.max(root.viewport - root.drawnHeight, root.offsetY));
}
// Start centred, filling the viewport.
function reset(): void {
root.zoom = 1;
root.offsetX = (root.viewport - root.drawnWidth) / 2;
root.offsetY = (root.viewport - root.drawnHeight) / 2;
}
onReadyChanged: if (root.ready) root.reset()
onZoomChanged: root.clamp()
Column {
id: column
width: parent.width
spacing: 14
Row {
spacing: 20
Rectangle {
id: frame
width: root.viewport
height: root.viewport
radius: 12
clip: true
color: Theme.bgDark
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
Image {
id: picture
source: root.source === "" ? "" : "file://" + root.source
x: root.offsetX
y: root.offsetY
width: root.drawnWidth
height: root.drawnHeight
fillMode: Image.Stretch
asynchronous: true
// The picture is drawn at whatever size the frame needs, so
// decoding it at full resolution wastes memory on a photo.
sourceSize.width: 1600
sourceSize.height: 1600
smooth: true
}
// The round mask: a dimming fill with the circle punched out of
// it, then the ring drawn on top. Painted once per change, not
// continuously.
Canvas {
id: mask
anchors.fill: parent
onPaint: {
const context = mask.getContext("2d");
context.clearRect(0, 0, mask.width, mask.height);
context.fillStyle = Qt.rgba(0.12, 0.13, 0.19, 0.62);
context.fillRect(0, 0, mask.width, mask.height);
context.globalCompositeOperation = "destination-out";
context.beginPath();
context.arc(mask.width / 2, mask.height / 2,
mask.width / 2 - 8, 0, Math.PI * 2);
context.fill();
context.globalCompositeOperation = "source-over";
context.strokeStyle = Qt.rgba(0.78, 0.83, 0.96, 0.85);
context.lineWidth = 2;
context.beginPath();
context.arc(mask.width / 2, mask.height / 2,
mask.width / 2 - 8, 0, Math.PI * 2);
context.stroke();
}
}
// The offset is tracked from where the press started rather than
// accumulated per frame, which would drift.
MouseArea {
anchors.fill: parent
enabled: root.ready
cursorShape: Qt.OpenHandCursor
property real pressX: 0
property real pressY: 0
property real originX: 0
property real originY: 0
onPressed: mouse => {
pressX = mouse.x; pressY = mouse.y;
originX = root.offsetX; originY = root.offsetY;
}
onPositionChanged: mouse => {
if (!pressed)
return;
root.offsetX = originX + (mouse.x - pressX);
root.offsetY = originY + (mouse.y - pressY);
root.clamp();
}
onWheel: wheel => {
const before = root.scale;
root.zoom = Math.max(1, Math.min(root.maxZoom,
root.zoom * (wheel.angleDelta.y > 0 ? 1.1 : 1 / 1.1)));
// Keep the centre of the frame pointing at the same part
// of the picture, so zooming does not walk the subject
// out of the circle.
const ratio = root.scale / before;
root.offsetX = root.viewport / 2 - (root.viewport / 2 - root.offsetX) * ratio;
root.offsetY = root.viewport / 2 - (root.viewport / 2 - root.offsetY) * ratio;
root.clamp();
}
}
}
Column {
width: column.width - frame.width - 20
spacing: 12
Text {
width: parent.width
text: root.ready
? "Drag to reposition, scroll to zoom."
: (root.source === "" ? "" : "Opening the picture…")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: root.ready
text: "The circle is what other people see. Written out at 512 × 512."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: picture.status === Image.Error
text: "That file could not be opened as a picture."
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Row {
spacing: 8
SettingsButton {
text: "Set picture"
tone: "accent"
enabled: root.ready
onClicked: {
// Screen coordinates back into the picture's own.
const size = root.viewport / root.scale;
root.cropped(Math.round(-root.offsetX / root.scale),
Math.round(-root.offsetY / root.scale),
Math.round(size));
}
}
SettingsButton {
text: "Reset"
enabled: root.ready
onClicked: root.reset()
}
SettingsButton {
text: "Cancel"
onClicked: root.cancelled()
}
}
}
}
}
}
@@ -0,0 +1,32 @@
// Choosing a profile picture.
//
// A thin wrapper on the same FileDialog the phone controls use, so picking a
// picture is the ordinary file chooser rather than something this desktop
// invented. Nothing privileged happens here -- reading a file the user selected
// needs no authorization; only setting it on the account does.
import QtQuick
import QtQuick.Dialogs
Item {
id: root
signal picked(path: string)
function open(): void {
dialog.open();
}
FileDialog {
id: dialog
title: "Choose a profile picture"
fileMode: FileDialog.OpenFile
nameFilters: ["Pictures (*.png *.jpg *.jpeg *.webp *.gif *.bmp)", "All files (*)"]
onAccepted: {
const value = String(dialog.selectedFile);
if (!value.startsWith("file://"))
return;
root.picked(decodeURIComponent(value.slice(7)));
}
}
}
@@ -19,24 +19,45 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
title: "Network & Devices" title: "Network & Devices"
lede: Connectivity.activeNetwork lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name ? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns." : "Wi-Fi, Bluetooth, and the things Fedora owns."
// Drive the scanners only while this page is the one being shown. // Drive the scanners only while this page is the one being shown.
Component.onCompleted: Connectivity.active = true Component.onCompleted: {
Connectivity.active = true;
if (!WifiShare.scanned)
WifiShare.refresh();
}
Component.onDestruction: Connectivity.active = false Component.onDestruction: Connectivity.active = false
SettingsCard { SettingsCard {
title: "Wired" title: "Wired"
visible: Connectivity.wiredDevice !== null visible: Connectivity.wiredDevice !== null
TextRow { SwitchRow {
label: "Ethernet" label: "Ethernet"
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "" // Three states worth telling apart: on, off but plugged in, and
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected" // nothing in the socket. "Not connected" covered all three and
// explained none of them.
detail: {
const device = Connectivity.wiredDevice;
if (!device)
return "";
if (device.connected)
return device.name + (device.linkSpeed > 0
? " · " + device.linkSpeed + " Mb/s" : "");
// Not "no cable": that cannot be told apart from "switched off"
// by anything reliable here, and guessing produced a switch
// that blamed the hardware for what it had just done itself.
return device.name + " · off";
}
checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
divider: false divider: false
onToggled: value => Connectivity.setWired(value)
} }
} }
@@ -68,6 +89,69 @@ SettingsPage {
} }
} }
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
// alternative is reading a passphrase out loud.
//
// The image holds the password in machine-readable form, so it is generated
// on demand rather than up front, and the helper writes it to tmpfs under
// XDG_RUNTIME_DIR instead of anywhere persistent.
SettingsCard {
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
title: "Share a network"
subtitle: WifiShare.sharing !== ""
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
: "Shows a QR code a phone can scan to join, without reading the password out."
Repeater {
model: WifiShare.shareable
ActionRow {
id: shareRow
required property var modelData
required property int index
label: shareRow.modelData.ssid
detail: WifiShare.sharing === shareRow.modelData.name
? "Showing a code below — anyone who can see the screen can join"
: "Saved network"
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
onTriggered: WifiShare.sharing === shareRow.modelData.name
? WifiShare.stopSharing()
: WifiShare.share(shareRow.modelData.name)
}
}
// Drawn at its natural size on a white plate: a QR code inverted or
// tinted to match a dark theme is unreliable to scan, and this one has
// exactly one job.
Item {
width: parent.width
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
implicitHeight: visible ? plate.height + 20 : 0
Rectangle {
id: plate
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
Image {
anchors.centerIn: parent
width: 184
height: 184
smooth: false
fillMode: Image.PreserveAspectFit
cache: false
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
}
}
}
}
SettingsCard { SettingsCard {
title: "Bluetooth" title: "Bluetooth"
visible: Connectivity.adapter !== null visible: Connectivity.adapter !== null
@@ -101,24 +185,15 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Owned by Fedora" title: "Owned by Fedora"
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable." subtitle: "VPNs and per-connection routing are still configured by GNOME's panel, which is installed and searchable. Printers and online accounts have their own pages here."
ActionRow { ActionRow {
label: "Network connections" label: "Network connections"
detail: "VPN, proxies, and per-connection settings" detail: "VPN, proxies, and per-connection settings"
action: "Open" action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("network") onTriggered: SystemSettings.openGnomePanel("network")
} }
ActionRow {
label: "Printers"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("printers")
}
ActionRow {
label: "Online accounts"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
} }
} }
@@ -0,0 +1,558 @@
// Rootless podman-compose stacks, led by what needs attention.
//
// The grouping is the compose project, because that is the unit a person thinks
// in: not "thirteen containers" but "the Command Center database" and "the
// capacity planner's Supabase". State decides prominence within that grouping --
// what is running gets rows, what is stopped collapses to a line -- so neither
// axis has to be chosen over the other.
//
// The findings at the top are the same crossing the Firewall page reports, seen
// from the side that can close it: the firewall knows only that something is
// listening, while this page knows which container, which compose file, and
// which token is missing from it.
import Quickshell
import QtQuick
import qs.config
import qs.services
Item {
id: root
objectName: "containers"
// "images", "volumes", or empty. Removal never happens on a first press.
property string confirmingPrune: ""
// Projects whose stopped containers have been expanded, by project name.
property var expanded: []
function isExpanded(name: string): bool { return root.expanded.indexOf(name) >= 0; }
function toggleExpanded(name: string): void {
root.expanded = root.isExpanded(name)
? root.expanded.filter(entry => entry !== name)
: root.expanded.concat([name]);
}
function uptimeOf(container: var): string {
const status = String(container.status ?? "");
// podman already phrases this well ("Up 32 hours (healthy)"); the health
// is shown separately, so only the duration is wanted here.
const match = /^Up ([^(]+)/.exec(status);
return match ? match[1].trim() : "";
}
function portSummary(container: var): string {
const ports = container.ports ?? [];
if (ports.length === 0)
return "no published ports";
return ports.map(port => {
const host = String(port.hostIp ?? "");
const where = host === "" ? "every interface" : host;
return where + ":" + port.hostPort + " → " + port.containerPort;
}).join(", ");
}
Component.onCompleted: Containers.refresh()
// ── the list ────────────────────────────────────────────────────────────
SettingsPage {
anchors.fill: parent
visible: Containers.logTarget === ""
title: "Containers"
lede: "Local services you run for development — what they expose, and what they cost."
TextRow {
visible: Containers.lastError !== ""
label: "That did not work"
detail: Containers.lastError
value: ""
divider: false
}
TextRow {
visible: Containers.scanned && !Containers.available
label: "podman is not available"
detail: "Nothing here can be shown until podman is installed."
value: ""
divider: false
}
// ── finding: published to the network ───────────────────────────────
SettingsCard {
visible: Containers.reachable.length > 0
title: Containers.reachable.length === 1
? "A container is published to your whole network"
: "Containers are published to your whole network"
subtitle: "These bind every interface, so any machine on your network can connect. "
+ "For a development database, loopback is almost always what you want. "
+ "Binding adds an address to the compose file and changes nothing else."
Repeater {
model: Containers.reachable
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.container ?? "")
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
+ " · " + String(modelData.image ?? "")
+ (modelData.configFile
? "\n" + Containers.shorten(String(modelData.configFile))
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Open compose file"
visible: String(modelData.configFile ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.openFile(String(modelData.configFile))
}
SettingsButton {
text: "Bind to localhost"
tone: "accent"
// Needs both halves of the label to find the entry in
// the compose file; the Supabase CLI records neither.
visible: String(modelData.configFile ?? "") !== ""
&& String(modelData.service ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.bindLocal(
String(modelData.project), String(modelData.service))
}
}
}
}
TextRow {
visible: Containers.reachable.length > 0
label: "After binding"
detail: "The change takes effect the next time the stack comes up, because a "
+ "published port is fixed when the container is created."
value: ""
divider: false
}
}
// ── finding: disk nothing references ────────────────────────────────
SettingsCard {
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
subtitle: "Images and volumes no container references. Removing them frees the space; "
+ "anything still needed downloads again on next use."
Repeater {
model: Containers.unusedImages.slice(0, 5)
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: ""
value: Containers.formatBytes(Number(modelData.size ?? 0))
}
}
TextRow {
visible: Containers.unusedImages.length > 5
label: "and " + (Containers.unusedImages.length - 5) + " more"
detail: ""
value: ""
}
SettingRow {
width: parent.width
label: root.confirmingPrune === "images"
? "Remove " + Containers.unusedImages.length + " images?"
: "Unused images"
detail: root.confirmingPrune === "images"
? "Each is removed by name. Nothing that a container references is touched."
: Containers.unusedImages.length + " images, "
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
visible: Containers.unusedImages.length > 0
controlWidth: 230
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "images" ? "" : "images"
}
SettingsButton {
visible: root.confirmingPrune === "images"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneImages();
}
}
}
}
SettingRow {
width: parent.width
visible: Containers.unusedVolumes.length > 0
label: root.confirmingPrune === "volumes"
? "Remove " + Containers.unusedVolumes.length + " volumes?"
: "Unused volumes"
detail: root.confirmingPrune === "volumes"
? "A volume holds data. These are the ones podman reports as referenced by "
+ "nothing, but removing them cannot be undone."
: Containers.unusedVolumes.length + " volumes, "
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
controlWidth: 230
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "volumes" ? "" : "volumes"
}
SettingsButton {
visible: root.confirmingPrune === "volumes"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneVolumes();
}
}
}
}
}
// ── the stacks ──────────────────────────────────────────────────────
Repeater {
model: Containers.projects
delegate: SettingsCard {
id: projectCard
required property var modelData
readonly property var runningSet:
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
readonly property var stoppedSet:
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
readonly property string projectName: String(projectCard.modelData.name ?? "")
title: String(projectCard.modelData.title ?? "")
subtitle: {
const total = Number(projectCard.modelData.total ?? 0);
const up = Number(projectCard.modelData.running ?? 0);
const where = String(projectCard.modelData.configFile ?? "");
const counts = up === 0
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
: up + " of " + total + " running";
return where === "" ? counts : counts + " · " + Containers.shorten(where);
}
SettingRow {
width: parent.width
label: "The whole stack"
detail: projectCard.runningSet.length === 0
? "Starts every container this project defines."
: "Stopping leaves the containers in place; nothing is removed."
controlWidth: 250
divider: projectCard.runningSet.length > 0
|| projectCard.stoppedSet.length > 0
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Start all"
visible: projectCard.stoppedSet.length > 0
enabled: !Containers.busy
onClicked: Containers.startProject(projectCard.projectName)
}
SettingsButton {
text: "Restart all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.restartProject(projectCard.projectName)
}
SettingsButton {
text: "Stop all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.stopProject(projectCard.projectName)
}
}
}
Repeater {
model: projectCard.runningSet
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
value: {
const health = String(modelData.health ?? "");
const up = root.uptimeOf(modelData);
if (health !== "" && up !== "")
return health + " · " + up;
return health !== "" ? health : up;
}
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Restart"
enabled: !Containers.busy
onClicked: Containers.restart(String(modelData.name))
}
SettingsButton {
text: "Stop"
enabled: !Containers.busy
onClicked: Containers.stop(String(modelData.name))
}
}
}
}
SettingRow {
width: parent.width
visible: projectCard.stoppedSet.length > 0
activatable: true
divider: root.isExpanded(projectCard.projectName)
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
+ projectCard.stoppedSet.length
+ (projectCard.stoppedSet.length === 1
? " stopped container" : " stopped containers")
detail: {
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
return bad.length === 0
? ""
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
}
onActivated: root.toggleExpanded(projectCard.projectName)
}
Repeater {
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < projectCard.stoppedSet.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Start"
enabled: !Containers.busy
onClicked: Containers.start(String(modelData.name))
}
}
}
}
}
}
// ── containers no compose project claims ────────────────────────────
SettingsCard {
visible: Containers.loose.length > 0
title: "Not part of a project"
subtitle: "Started directly rather than by a compose file."
Repeater {
model: Containers.loose
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < Containers.loose.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: modelData.state === "running" ? "Stop" : "Start"
enabled: !Containers.busy
onClicked: modelData.state === "running"
? Containers.stop(String(modelData.name))
: Containers.start(String(modelData.name))
}
}
}
}
}
TextRow {
visible: Containers.scanned && Containers.available && Containers.total === 0
label: "No containers"
detail: "Nothing has been created on this machine yet."
value: ""
divider: false
}
}
// ── logs ────────────────────────────────────────────────────────────────
//
// A drill-in rather than a panel inside the scrolling page: logs need their
// own scrollback, and nesting one scrolling view inside another makes the
// wheel ambiguous over the region where you most want to use it.
Item {
anchors.fill: parent
visible: Containers.logTarget !== ""
Column {
id: logHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
spacing: 6
Row {
width: parent.width
spacing: 12
SettingsButton {
text: "‹ Back"
anchors.verticalCenter: parent.verticalCenter
onClicked: Containers.closeLogs()
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Containers.logTarget
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 22
font.weight: Font.DemiBold
}
}
Text {
width: parent.width
text: Containers.logError !== ""
? Containers.logError
: (Containers.logFollowing
? "Following. The last " + Containers.logLines.count + " lines are shown."
: "The stream has ended.")
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: logHeader.bottom
anchors.bottom: parent.bottom
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 14
anchors.bottomMargin: 30
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.bgDark, 0.75)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
ListView {
id: logList
anchors.fill: parent
anchors.margins: 12
clip: true
model: Containers.logLines
spacing: 1
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 400
// Stay pinned to the newest line while the reader is already at
// the bottom, and leave the view alone the moment they scroll up
// to read something.
property bool pinned: true
onContentYChanged: logList.pinned =
logList.contentY >= logList.contentHeight - logList.height - 24
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
delegate: Text {
required property string line
width: logList.width - 24
text: line
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
textFormat: Text.PlainText
}
}
}
}
}
@@ -7,7 +7,7 @@
// because that genuinely is a Panama preference. // because that genuinely is a Panama preference.
// //
// Changing the timezone or network time needs privilege. timedatectl asks // Changing the timezone or network time needs privilege. timedatectl asks
// polkit, and a cancelled dialog surfaces as an error rather than as a value // polkit, and a canceled dialog surfaces as an error rather than as a value
// that appears to have been accepted. // that appears to have been accepted.
import QtQuick import QtQuick
@@ -33,7 +33,7 @@ SettingsPage {
SettingRow { SettingRow {
label: "Set automatically" label: "Set automatically"
detail: DateTime.ntpEnabled detail: DateTime.ntpEnabled
? (DateTime.ntpSynchronised ? "Synchronised with a time server" : "Waiting to synchronise") ? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize")
: "The clock is set by hand" : "The clock is set by hand"
controlWidth: 48 controlWidth: 48
divider: false divider: false
@@ -50,38 +50,50 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Timezone" title: "Timezone"
subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone)
+ ". Type to narrow the list."
SearchField { PickerRow {
id: zoneSearch id: zonePicker
width: parent.width
placeholder: "Search timezones"
}
Repeater { label: "Time zone"
model: root.matchingZones detail: DateTime.timezone === ""
? "Reading the system clock"
: DateTime.cityOf(DateTime.timezone) + " — " + DateTime.regionOf(DateTime.timezone)
value: DateTime.timezone === "" ? "Unknown" : DateTime.cityOf(DateTime.timezone)
divider: false
SearchField {
id: zoneSearch
width: parent.width
placeholder: "Search timezones"
}
Repeater {
model: root.matchingZones
TextRow {
required property var modelData
required property int index
label: DateTime.cityOf(modelData)
detail: DateTime.regionOf(modelData)
value: modelData === DateTime.timezone ? "Current" : ""
controlWidth: 90
divider: index < root.matchingZones.length - 1
activatable: true
onActivated: {
DateTime.setTimezone(modelData);
zonePicker.collapse();
}
}
}
TextRow { TextRow {
required property var modelData visible: root.matchingZones.length === 0
required property int index label: "No timezone matches that"
detail: "Try a city or a region, such as \"Denver\" or \"Europe\""
label: DateTime.cityOf(modelData) divider: false
detail: DateTime.regionOf(modelData)
value: modelData === DateTime.timezone ? "Current" : ""
controlWidth: 90
divider: index < root.matchingZones.length - 1
activatable: true
onActivated: DateTime.setTimezone(modelData)
} }
} }
TextRow {
visible: root.matchingZones.length === 0
label: "No timezone matches that"
detail: "Try a city or a region, such as \"Denver\" or \"Europe\""
divider: false
}
} }
SettingsCard { SettingsCard {
@@ -8,21 +8,100 @@
// them. // them.
import QtQuick import QtQuick
import Quickshell
import qs.config import qs.config
import qs.services import qs.services
SettingsPage { SettingsPage {
id: root id: root
// Turning the last screen off would leave no dock anywhere and no obvious
// way back, so the final one cannot be removed -- it collapses to "every
// screen" instead, which is the same thing on one display and recoverable
// on several.
function toggleDockScreen(name: string): void {
const all = Quickshell.screens.map(screen => String(screen.name));
const current = Settings.dockScreens.length === 0
? all.slice()
: Settings.dockScreens.map(String);
const at = current.indexOf(name);
let next = current.slice();
if (at >= 0)
next.splice(at, 1);
else
next.push(name);
if (next.length === 0 || next.length === all.length)
next = [];
DesktopPreferences.set("dockScreens", next);
}
title: "Desktop & Dock" title: "Desktop & Dock"
lede: "Keep the shell instant, spatial, and out of your way." lede: "Keep the shell instant, spatial, and out of your way."
SettingsCard { SettingsCard {
title: "Dock" title: "Dock"
ChoiceRow { setting: "dockPosition" }
// One row per connected screen. Nothing selected means every screen,
// which is stated rather than left as an empty list somebody has to
// interpret -- and it is what a single-monitor machine should do
// without being configured at all.
SettingRow {
label: "Screens"
detail: Settings.dockScreens.length === 0
? "On every display"
: "On " + Settings.dockScreens.length + " of "
+ Quickshell.screens.length + " displays"
visible: Quickshell.screens.length > 1
controlWidth: 260
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Repeater {
model: Quickshell.screens
delegate: Rectangle {
id: screenPill
required property var modelData
readonly property string screenName: String(screenPill.modelData.name ?? "")
// An empty list means all, so every pill reads as on.
readonly property bool on: Settings.dockScreens.length === 0
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
width: pillLabel.implicitWidth + 20
height: 28
radius: 8
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: screenPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
id: pillLabel
anchors.centerIn: parent
text: screenPill.screenName
color: screenPill.on ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
}
}
}
}
ToggleRow { setting: "dockAutohide" } ToggleRow { setting: "dockAutohide" }
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" } SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false } SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockIconSize"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -47,33 +126,146 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Window layout" 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 { // These were two read-only rows reporting "Tiling" and "Dynamic", which
label: "Layout" // described settings rather than facts -- both are ordinary Hyprland
detail: "Dwindle with preserved split direction" // options that simply had no controls. TextRow's own documentation says
value: "Tiling" // a setting the user could reasonably change does not belong in it.
} ChoiceRow { setting: "windowLayout" }
TextRow { ToggleRow { setting: "preserveSplit" }
label: "Workspace movement" ChoiceRow { setting: "forceSplit" }
detail: "Alt+H / Alt+L, add Shift to move a window" ToggleRow { setting: "windowSnapping" }
value: "Dynamic"
}
ActionRow { ActionRow {
label: "Gaps, corners, and effects" label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview" detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance" action: "Open Appearance"
divider: false divider: false
onTriggered: ShellState.openSettings("appearance") onTriggered: ShellState.openSettingsSection("appearance", "windows")
} }
} }
// 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 { SettingsCard {
title: "Focus" title: "Focus"
SliderRow { setting: "focusDurationMinutes"; divider: false } SliderRow { setting: "focusDurationMinutes"; divider: false }
} }
// Distinct from the snapshots below, which put THIS machine back as it
// was. This carries settings to a different one, and deliberately leaves
// behind anything that describes hardware.
SettingsCard {
title: "Carry settings to another machine"
subtitle: SettingsSync.lastError !== ""
? SettingsSync.lastError
: "Everything except what describes this machine: the display arrangement stays here."
ActionRow {
label: "Export"
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
: "Writes " + SettingsSync.defaultPath
action: "Export"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
}
ActionRow {
label: "See what an import would change"
detail: SettingsSync.previewed
? SettingsSync.changes.length + " would change, "
+ SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
}
// Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their
// desktop changed.
ActionRow {
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
label: "Apply those " + SettingsSync.changes.length + " changes"
detail: "Settings the file does not mention are left alone"
action: "Import"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
}
Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : []
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "")
value: ""
}
}
TextRow {
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
label: SettingsSync.applied === 0
? "Nothing needed changing"
: SettingsSync.applied + " settings applied"
detail: "From " + (SettingsSync.exportedFrom || "the export")
value: ""
divider: false
}
}
SettingsCard { SettingsCard {
title: "Snapshots" title: "Snapshots"
subtitle: SettingsBackup.lastError !== "" subtitle: SettingsBackup.lastError !== ""
@@ -111,10 +303,10 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Reset" 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 { ActionRow {
label: "Restore Panama defaults" label: "Restore defaults"
detail: "Applies immediately, including to the compositor" detail: "Applies immediately, including to the compositor"
action: "Restore defaults" action: "Restore defaults"
divider: false divider: false
@@ -50,7 +50,7 @@ Rectangle {
// Stands in for the wallpaper. Static: an animated gradient here would // Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page. // repaint forever behind a settings page.
// Follows the colour scheme. A preview that stays dark while the shell // Follows the color scheme. A preview that stays dark while the shell
// around it is light does not read as "your desktop" -- it reads as a // around it is light does not read as "your desktop" -- it reads as a
// screenshot of someone else's. // screenshot of someone else's.
gradient: Gradient { gradient: Gradient {
@@ -89,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 { Text {
anchors.centerIn: parent anchors.centerIn: parent
text: "Panama" text: Qt.formatTime(new Date(), "h:mm")
color: Theme.alpha(Theme.fg, 0.45) color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: 9 font.pixelSize: 9
@@ -126,7 +129,7 @@ Rectangle {
required property bool focused required property bool focused
// The gradient border is drawn as a filled rounded rect with the window // The gradient border is drawn as a filled rounded rect with the window
// body inset on top of it: QML's Rectangle border takes a colour, not a // body inset on top of it: QML's Rectangle border takes a color, not a
// gradient, and the prism border is the whole signature here. // gradient, and the prism border is the whole signature here.
Rectangle { Rectangle {
id: frame id: frame
@@ -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
}
}
}
}
}
@@ -15,6 +15,9 @@ Column {
property var monitor: null property var monitor: null
property bool enabled: true property bool enabled: true
// Emitted once a mode has been asked for, so a container can put the list
// away. Applying is still this component's job; closing is not.
signal picked
spacing: 0 spacing: 0
readonly property var grouped: { readonly property var grouped: {
@@ -106,11 +109,14 @@ Column {
TapHandler { TapHandler {
enabled: root.enabled && !rate.selected enabled: root.enabled && !rate.selected
onTapped: Displays.apply( onTapped: {
root.monitor.name, Displays.apply(
rate.modelData.mode, root.monitor.name,
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale), rate.modelData.mode,
root.monitor.transform) Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
root.monitor.transform);
root.picked();
}
} }
} }
} }
@@ -27,6 +27,15 @@ SettingsPage {
? root.monitor.mode ? root.monitor.mode
: "" : ""
// Every mode at the resolution in use, which is what a refresh-rate choice
// actually is: the same width and height at a different rate.
readonly property var ratesForCurrentResolution: {
if (!root.monitor || !root.monitor.modes)
return [];
return root.monitor.modes.filter(mode => mode.width === root.monitor.width
&& mode.height === root.monitor.height);
}
function syncSelectedOutput(): void { function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput)) if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : ""; root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
@@ -106,6 +115,20 @@ SettingsPage {
} }
} }
SettingsCard {
visible: Displays.monitors.length > 1
title: "Arrange displays"
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
DisplayArrangement {
width: parent.width
displayService: Displays
selectedOutput: root.selectedOutput
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
onSelectionRequested: output => root.selectedOutput = output
}
}
SettingsCard { SettingsCard {
visible: Displays.monitors.length > 1 visible: Displays.monitors.length > 1
title: "Connected display" title: "Connected display"
@@ -149,7 +172,7 @@ SettingsPage {
ActionRow { ActionRow {
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "") visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
label: "Using a custom display setting" label: "Using a custom display setting"
detail: "Forget it to go back to the resolution and scale Panama ships" detail: "Forget it to go back to the shipped resolution and scale"
action: "Forget" action: "Forget"
divider: false divider: false
onTriggered: Displays.forget(root.monitor.name) onTriggered: Displays.forget(root.monitor.name)
@@ -161,10 +184,61 @@ SettingsPage {
title: "Resolution" title: "Resolution"
subtitle: "Applied straight away, then reverted automatically unless you confirm." subtitle: "Applied straight away, then reverted automatically unless you confirm."
DisplayModePicker { PickerRow {
width: parent.width id: modePicker
monitor: root.monitor
label: "Resolution"
detail: root.monitor
? root.monitor.width + " × " + root.monitor.height + " native"
: "No display selected"
value: root.monitor
? root.monitor.width + " × " + root.monitor.height
: ""
enabled: !Displays.awaitingConfirmation && !Displays.busy enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
DisplayModePicker {
width: parent.width
monitor: root.monitor
enabled: !Displays.awaitingConfirmation && !Displays.busy
onPicked: modePicker.collapse()
}
}
// Refresh rate on its own, because the rates for a resolution used to be
// reachable only by opening the resolution list -- which is now
// collapsed, so changing only the rate meant going through the mode you
// already had.
PickerRow {
id: ratePicker
label: "Refresh rate"
detail: "Rates this display offers at " + (root.monitor
? root.monitor.width + " × " + root.monitor.height
: "the current resolution")
value: root.monitor ? root.monitor.refreshRate.toFixed(2) + " Hz" : ""
visible: root.ratesForCurrentResolution.length > 1
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
Repeater {
model: root.ratesForCurrentResolution
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.refreshLabel ?? "")
value: Displays.modeIsCurrent(root.monitor, modelData) ? "Current" : ""
controlWidth: 90
divider: index < root.ratesForCurrentResolution.length - 1
activatable: !Displays.modeIsCurrent(root.monitor, modelData)
onActivated: {
root.applyWith({ mode: modelData.mode });
ratePicker.collapse();
}
}
}
} }
} }
@@ -248,7 +322,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Gaming display policy" title: "Gaming display policy"
subtitle: "Applied immediately and restored when Panama starts." subtitle: "Applied immediately and restored when the session starts."
ToggleRow { setting: "autoHdr" } ToggleRow { setting: "autoHdr" }
ChoiceRow { setting: "vrrPolicy" } ChoiceRow { setting: "vrrPolicy" }

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