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 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
383 changed files with 30137 additions and 1902 deletions
+3 -2
View File
@@ -14,10 +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 colour scheme; machine state, not configuration. # Generated from the color scheme; machine state, not configuration.
/config/dot/gtk-3.0/settings.ini /config/dot/gtk-3.0/settings.ini
/config/dot/gtk-4.0/settings.ini /config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf /config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf
+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
+1 -1
View File
@@ -2,7 +2,7 @@
# #
# btop ships no Tokyo Night light variant at all, and the nearest stock light # 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 # theme (flat-remix-light) is a different palette that happens to have a similar
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal # background. Same colors as kitty/themes/tokyonight-day.conf so the terminal
# and what runs inside it cannot disagree. # and what runs inside it cannot disagree.
# #
# Gradients keep the same low -> middle -> high meaning as the dark theme, using # Gradients keep the same low -> middle -> high meaning as the dark theme, using
+1 -1
View File
@@ -1,6 +1,6 @@
# GENERATED FILE -- edit settings.ini.template instead. # GENERATED FILE -- edit settings.ini.template instead.
# #
# The theme name and dark preference below follow Panama's colour # The theme name and dark preference below follow Panama's color
# scheme, so this file is regenerated on every switch and is not # scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over # committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no # XSETTINGS and this file is ignored; under Hyprland there is no
+1 -1
View File
@@ -1,6 +1,6 @@
# GENERATED FILE -- edit settings.ini.template instead. # GENERATED FILE -- edit settings.ini.template instead.
# #
# The theme name and dark preference below follow Panama's colour # The theme name and dark preference below follow Panama's color
# scheme, so this file is regenerated on every switch and is not # scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over # committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no # XSETTINGS and this file is ignored; under Hyprland there is no
+10 -10
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,14 +36,14 @@ 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 | The Settings app 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, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | 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 ## System health and recovery
Panama stays silent while the desktop is healthy. A compact bar indicator Panama stays silent while the desktop is healthy. A compact bar indicator
appears only for actionable warnings or errors and opens the same **System appears only for actionable warnings or errors and opens the same **System
Health** page available from Settings and the Vicinae command **Panama: Check Health** page available from Settings and the Vicinae command **Check
System Health**. The terminal summary is available with: System Health**. The terminal summary is available with:
```bash ```bash
@@ -59,7 +59,7 @@ They never install packages, invoke `sudo`, delete user data, or rewrite
arbitrary configuration. arbitrary configuration.
Generic Fedora configuration remains with the system tools that own it. The Generic Fedora configuration remains with the system tools that own it. The
final System Health card hands network settings, users, sharing, colour final System Health card hands network settings, users, sharing, color
profiles, and digital wellbeing to their exact GNOME Settings panels rather profiles, and digital wellbeing to their exact GNOME Settings panels rather
than presenting inert Hyprland controls. than presenting inert Hyprland controls.
@@ -77,9 +77,9 @@ than presenting inert Hyprland controls.
| 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 |
@@ -111,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.
+14 -14
View File
@@ -30,7 +30,7 @@ Don't "fix" them.
| `prefs.lua` | Reads the settings file the Settings app 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 |
@@ -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.
@@ -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,19 +270,19 @@ 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` | 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 |
@@ -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 **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).
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
+4 -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),
@@ -22,10 +22,10 @@ hl.config({
repeat_rate = prefs.get("keyRepeatRate", 33), repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = FOLLOW. The window under the pointer takes focus. This comment -- 1 = FOLLOW. The window under the pointer takes focus. This comment
-- previously claimed 1 was "click to focus, GNOME's behaviour", which -- previously claimed 1 was "click to focus, GNOME's behavior", which
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives -- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}], -- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
-- so click-to-focus is 0. Changing the shipped value is a behaviour -- 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 -- decision rather than a correction, so the value is left alone and
-- only the description is fixed; Settings exposes all four. -- only the description is fixed; Settings exposes all four.
follow_mouse = prefs.getInt("followMouse", 1), follow_mouse = prefs.getInt("followMouse", 1),
+57 -24
View File
@@ -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)
});
}
}
}
@@ -101,7 +101,7 @@ Singleton {
const raw = (parsed && typeof parsed === "object") ? parsed : {}; const raw = (parsed && typeof parsed === "object") ? parsed : {};
// Upgrade before anything reads a value. A stored key the current // Upgrade before anything reads a value. A stored key the current
// schema no longer recognises is carried through untouched and silently // schema no longer recognizes is carried through untouched and silently
// stops taking effect, so the conversion has to happen here rather than // stops taking effect, so the conversion has to happen here rather than
// being noticed later by whoever owns that setting. // being noticed later by whoever owns that setting.
const result = Migrations.apply(raw); const result = Migrations.apply(raw);
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ pragma Singleton
// The schema is the single source of truth for what a setting IS, but it cannot // 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 // 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 // splitting one setting into two all leave a stored value that the new schema
// does not recognise -- and an unrecognised key is silently carried through // does not recognize -- and an unrecognized key is silently carried through
// untouched, so the user's choice simply stops taking effect with nothing to // untouched, so the user's choice simply stops taking effect with nothing to
// say why. That is the failure this exists to prevent. // say why. That is the failure this exists to prevent.
// //
+442 -15
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",
@@ -171,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" }
}, },
{ {
@@ -206,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",
@@ -241,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",
@@ -275,14 +489,14 @@ Singleton {
{ {
key: "keyboardVariant", type: "string", def: "", group: "input", key: "keyboardVariant", type: "string", def: "", group: "input",
// Same shape as the layout list and for the same reason: this is // Same shape as the layout list and for the same reason: this is
// serialised into an hl.config string. // serialized into an hl.config string.
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$", pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
label: "Layout variant", label: "Layout variant",
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout", detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" } hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
}, },
{ {
key: "keyboardOptions", type: "string", def: "", group: "input", key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
// XKB option names are colon-separated pairs in a comma-separated // XKB option names are colon-separated pairs in a comma-separated
// list, e.g. "compose:ralt,caps:escape". // list, e.g. "compose:ralt,caps:escape".
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$", pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
@@ -313,7 +527,7 @@ 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: "Pointer focus", label: "Pointer focus",
detail: "What moving the pointer does to which window is focused", detail: "What moving the pointer does to which window is focused",
// These labels were wrong, and wrong in the worst way: value 1 was // These labels were wrong, and wrong in the worst way: value 1 was
@@ -321,7 +535,7 @@ Singleton {
// The compositor publishes the authoritative mapping itself -- // The compositor publishes the authoritative mapping itself --
// `hyprctl descriptions` gives // `hyprctl descriptions` gives
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}] // map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
// -- so a desktop labelled "Click to focus" was in fact following // -- so a desktop labeled "Click to focus" was in fact following
// the pointer, and the way to actually get click-to-focus was to // the pointer, and the way to actually get click-to-focus was to
// choose "Never". Value 3 was missing entirely. // choose "Never". Value 3 was missing entirely.
// //
@@ -341,7 +555,7 @@ Singleton {
}, },
{ {
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" }
@@ -349,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
@@ -394,6 +608,12 @@ Singleton {
detail: "Swap the primary and secondary buttons", detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" } 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 ──────────────────────────────────────────────────────── // ── Touchpad ────────────────────────────────────────────────────────
// //
@@ -542,6 +762,14 @@ Singleton {
detail: "Moves the magnified view in increments rather than gliding with the pointer", detail: "Moves the magnified view in increments rather than gliding with the pointer",
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" } 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", key: "dimInactive", type: "bool", def: false, group: "accessibility",
label: "Dim inactive windows", label: "Dim inactive windows",
@@ -556,6 +784,22 @@ Singleton {
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" } 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",
@@ -587,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.
@@ -632,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.
{ {
@@ -651,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
@@ -681,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
@@ -718,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.
{ {
@@ -734,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,
@@ -868,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.
@@ -877,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 ──────────────────────────────
@@ -982,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
@@ -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;
@@ -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
}
});
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
// //
// Fixture steps rather than the real list: the real one is empty until the // 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 // first breaking schema change, and a mechanism that has never been run against
// a failing step is not one to discover the behaviour of during an upgrade. // a failing step is not one to discover the behavior of during an upgrade.
import Quickshell import Quickshell
import QtQuick import QtQuick
@@ -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
+2 -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
@@ -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
@@ -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
@@ -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
}
}
}
}
@@ -27,7 +27,12 @@ SettingsPage {
title: "Text" title: "Text"
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are 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 {
@@ -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. Looked for 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: "This desktop 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
@@ -178,7 +232,28 @@ SettingsPage {
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
@@ -197,12 +272,50 @@ 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)
}
}
} }
} }
} }
@@ -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)));
}
}
}
@@ -37,11 +37,27 @@ SettingsPage {
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)
} }
} }
@@ -169,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 {
@@ -62,11 +141,50 @@ SettingsPage {
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. // 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 { SettingsCard {
title: "Workspaces & focus" title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set." subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
@@ -74,8 +192,7 @@ SettingsPage {
ToggleRow { setting: "workspaceBackAndForth" } ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" } ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" } ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" } ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
ChoiceRow { setting: "followMouse"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -84,6 +201,71 @@ SettingsPage {
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 !== ""
@@ -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 {
@@ -129,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"
@@ -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();
}
}
}
} }
} }
@@ -45,6 +45,50 @@ Column {
DesktopPreferences.set("dockPinned", next); DesktopPreferences.set("dockPinned", next);
} }
// ── Dragging ────────────────────────────────────────────────────────────
//
// By a grip rather than the whole row. The objection this file used to
// record -- that dragging inside a Flickable inside a scrolling page is
// hard to get right and fails in a way that reads as breakage -- is real,
// and the answer is preventStealing on the grip: the Flickable cannot take
// a gesture that started there, so a vertical drag reorders instead of
// scrolling the page out from under it. The arrow buttons stay, because
// they are the keyboard-reachable path and a grip is not.
//
// The order is held here while the drag runs and written once on release.
// Committing on every slot crossed would rewrite settings.json a dozen
// times for one gesture.
property int draggingIndex: -1
property var workingOrder: []
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
function beginDrag(index: int): void {
root.workingOrder = root.pinned.slice();
root.draggingIndex = index;
}
function dragTo(target: int): void {
if (root.draggingIndex < 0 || target === root.draggingIndex)
return;
if (target < 0 || target >= root.workingOrder.length)
return;
const next = root.workingOrder.slice();
const moved = next.splice(root.draggingIndex, 1)[0];
next.splice(target, 0, moved);
root.workingOrder = next;
root.draggingIndex = target;
}
function endDrag(): void {
if (root.draggingIndex < 0)
return;
const next = root.workingOrder.slice();
root.draggingIndex = -1;
root.workingOrder = [];
root.commit(next);
}
function move(from: int, to: int): void { function move(from: int, to: int): void {
if (to < 0 || to >= root.pinned.length) if (to < 0 || to >= root.pinned.length)
return; return;
@@ -67,7 +111,7 @@ Column {
} }
Repeater { Repeater {
model: root.pinned model: root.displayed
SettingRow { SettingRow {
id: pin id: pin
@@ -76,15 +120,71 @@ Column {
required property int index required property int index
label: root.nameFor(pin.modelData) label: root.nameFor(pin.modelData)
detail: pin.modelData // The desktop id is shown only when the name alone would not say
// which entry this is. It is developer text, and repeating it under
// fifteen recognisable application names is noise that makes the
// list harder to scan, not easier.
detail: root.pinned.filter(other =>
root.nameFor(other) === root.nameFor(pin.modelData)).length > 1
? pin.modelData
: ""
divider: pin.index < root.pinned.length - 1 divider: pin.index < root.pinned.length - 1
controlWidth: 132 controlWidth: 132
// Lifted while dragging so the row being moved is the one that
// looks moved.
z: root.draggingIndex === pin.index ? 2 : 0
opacity: root.draggingIndex === pin.index ? 0.85 : 1
Row { Row {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 4 spacing: 4
// The grip. preventStealing is the whole reason this works
// inside a scrolling page: without it the Flickable claims the
// vertical gesture and the row never moves.
Item {
width: 26
height: 26
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "\u2261"
color: root.draggingIndex === pin.index ? Theme.accent : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 15
}
MouseArea {
id: grip
anchors.fill: parent
preventStealing: true
cursorShape: Qt.SizeVerCursor
property real pressY: 0
onPressed: mouse => {
grip.pressY = mouse.y;
root.beginDrag(pin.index);
}
onPositionChanged: mouse => {
if (root.draggingIndex < 0 || pin.height <= 0)
return;
// How many whole rows the pointer has travelled from
// where it started. Rounded, so the swap happens as
// the grip passes the midpoint of the next row.
const travelled = (mouse.y - grip.pressY);
const slots = Math.round(travelled / pin.height);
if (slots !== 0)
root.dragTo(root.draggingIndex + slots);
}
onReleased: root.endDrag()
onCanceled: root.endDrag()
}
}
SettingsButton { SettingsButton {
text: "↑" text: "↑"
enabled: pin.index > 0 enabled: pin.index > 0
@@ -0,0 +1,267 @@
// The firewall, led by what another machine can actually reach.
//
// A rules list alone is not an answer: 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 while
// a database and a cache sit open, because Fedora Workstation's zone opens
// every port above 1024 and rootless containers publish on all interfaces.
//
// Rich rules are shown but never edited. They are a syntax rather than a
// setting, and a page that half-supports a syntax is a trap -- but hiding them
// would mean the page misrepresents the configuration.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "firewall"
title: "Firewall"
lede: "What another machine on your network can reach, and what allows it."
property string confirmingRemoval: ""
property bool confirmingRange: false
Component.onCompleted: Firewall.refresh()
TextRow {
visible: Firewall.lastError !== ""
label: "The firewall needs attention"
detail: Firewall.lastError
value: ""
divider: false
}
// ── The finding, when there is one ───────────────────────────────────────
SettingsCard {
visible: Firewall.exposedDataStores.length > 0
title: Firewall.exposedDataStores.length === 1
? "A database is reachable from your network"
: "Databases are reachable from your network"
subtitle: {
const names = Firewall.exposedDataStores.map(entry => String(entry.name));
return names.join(" and ") + " "
+ (names.length === 1 ? "is" : "are")
+ " listening on every interface, and this zone permits it. Anyone on your network can connect.";
}
Repeater {
model: Firewall.exposedDataStores
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
+ (String(modelData.process ?? "") !== ""
? " · " + String(modelData.process) : "")
+ " · allowed by " + String(modelData.allowedBy ?? "")
value: ""
divider: index < Firewall.exposedDataStores.length - 1
}
}
}
// ── Everything reachable ─────────────────────────────────────────────────
SettingsCard {
title: "Reachable right now"
subtitle: !Firewall.scanned
? "Checking what is listening and what the firewall permits…"
: (Firewall.available
? "Listening on a network address, and permitted by the firewall. Both have to be true."
: "The firewall is not running, so nothing here is being filtered.")
Repeater {
model: Firewall.exposed
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
+ (String(modelData.process ?? "") !== "" && String(modelData.process) !== String(modelData.name)
? " · " + String(modelData.process) : "")
+ " · allowed by " + String(modelData.allowedBy ?? "")
value: String(modelData.kind ?? "") === "data" ? "Database" : ""
divider: index < Firewall.exposed.length - 1
}
}
TextRow {
visible: Firewall.exposed.length === 0 && Firewall.scanned && Firewall.available
label: "Nothing is reachable"
detail: "No service is both listening on a network address and permitted"
value: ""
divider: false
}
}
// ── The rules that allow it ──────────────────────────────────────────────
SettingsCard {
visible: Firewall.zone !== null
title: "What this zone allows"
subtitle: Firewall.zone
? String(Firewall.zone.name) + ", applied to "
+ (Firewall.zone.interfaces ?? []).join(" and ")
: ""
// The single rule that explains almost every row above.
Column {
width: parent.width
visible: Firewall.wideOpen
SettingRow {
width: parent.width
label: "Ports " + Firewall.openRanges.join(", ")
detail: root.confirmingRange
? "Closing this cuts off " + Firewall.rangeDependents().length
+ " reachable service" + (Firewall.rangeDependents().length === 1 ? "" : "s")
+ ", including " + Firewall.rangeDependents().slice(0, 3)
.map(entry => String(entry.name)).join(", ")
+ ". Anything that needs a port will have to be allowed by name."
: "Fedora Workstation opens these so applications can listen without asking. It is why most of the list above is reachable."
controlWidth: 230
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingRange ? "Keep it open" : "Close the range…"
enabled: !Firewall.busy
onClicked: root.confirmingRange = !root.confirmingRange
}
SettingsButton {
visible: root.confirmingRange
text: "Close it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
root.confirmingRange = false;
for (const spec of Firewall.openRanges)
Firewall.removePort(String(spec));
}
}
}
}
}
Repeater {
model: Firewall.zone?.services ?? []
delegate: SettingRow {
id: serviceRow
required property var modelData
required property int index
readonly property string serviceName: String(serviceRow.modelData)
readonly property bool confirming: root.confirmingRemoval === serviceRow.serviceName
// Removing ssh while someone is connected over it ends their
// session. Worth saying before, not after.
readonly property bool risky: serviceRow.serviceName === "ssh"
&& Firewall.sshSessions > 0
width: parent.width
label: serviceRow.serviceName
detail: serviceRow.confirming
? (serviceRow.risky
? "Someone is connected over SSH right now. Removing this ends that session."
: "Anything relying on this service stops being reachable.")
: "Allowed by name, so it works whatever the port range says"
controlWidth: 210
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: serviceRow.confirming ? "Keep" : "Remove…"
enabled: !Firewall.busy
onClicked: root.confirmingRemoval =
serviceRow.confirming ? "" : serviceRow.serviceName
}
SettingsButton {
visible: serviceRow.confirming
text: "Remove"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
root.confirmingRemoval = "";
Firewall.removeService(serviceRow.serviceName);
}
}
}
}
}
// Shown, never edited.
TextRow {
visible: (Firewall.zone?.richRules ?? []).length > 0
label: "Rich rules"
detail: "Custom rules in firewalld's own syntax. Shown here so this page does not misrepresent your configuration; edit them with firewall-cmd."
value: (Firewall.zone?.richRules ?? []).length + " defined"
divider: false
}
}
// ── Zones ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Zones"
subtitle: "A zone is a set of rules. Each network connection uses one."
Repeater {
model: Object.keys(Firewall.activeZones ?? ({}))
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData)
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
divider: true
}
}
TextRow {
label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone"
value: Firewall.defaultZone
divider: false
}
}
// ── The service underneath ───────────────────────────────────────────────
SettingsCard {
title: "Firewall service"
TextRow {
label: "firewalld"
detail: !Firewall.scanned
? "Reading the firewall's state…"
: (Firewall.running
? (Firewall.enabledAtBoot
? "Running, and starts with the system"
: "Running, but not started at boot")
: "Not running, so nothing is being filtered")
value: !Firewall.scanned ? "Checking…" : (Firewall.running ? "Running" : "Stopped")
divider: false
}
}
}
@@ -0,0 +1,253 @@
// Gaming: what the machine is doing now, and what it should do while you play.
//
// Live first, because unlike every other page here this one has a genuinely
// live dimension -- "is Game Mode actually on, and how hot is the card" is the
// question that brings someone here mid-session.
//
// The part that makes this Panama's page rather than a gamemode config editor
// is "While a game is running": gamemode runs a script when a game starts and
// another when it exits, so the power profile and Do Not Disturb can follow the
// game and be put back afterwards -- back to what they were, not to a default.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "gaming"
title: "Gaming"
lede: "What this machine is doing, and how it should behave while you play."
readonly property var gpu: Gaming.primaryGpu
// Polling only while this page is on screen.
Component.onCompleted: {
Gaming.refresh();
Gaming.watching = true;
}
Component.onDestruction: Gaming.watching = false
TextRow {
visible: Gaming.lastError !== ""
label: "Gaming needs attention"
detail: Gaming.lastError
value: ""
divider: false
}
// ── Right now ────────────────────────────────────────────────────────────
SettingsCard {
Column {
width: parent.width
spacing: 8
Row {
width: parent.width
spacing: 12
Text {
text: Gaming.active ? "Playing" : "Idle"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Gaming.active
? "Game Mode is engaged"
: "No game has requested Game Mode"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
Repeater {
model: Gaming.gpus
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: modelData.discrete ? "Graphics card" : "Integrated graphics"
detail: String(modelData.driver ?? "")
+ (modelData.discrete ? " · the one games use" : " · idle unless something asks for it")
value: Gaming.gpuSummary(modelData)
divider: true
}
}
TextRow {
label: "CPU governor"
detail: "What the cores are scaling to right now"
value: String(Gaming.gameMode?.governorNow ?? "unknown")
divider: false
}
}
// ── What Panama does about it ────────────────────────────────────────────
SettingsCard {
title: "While a game is running"
subtitle: Gaming.gameMode?.hooksInstalled === true
? "Panama reacts when Game Mode engages, and puts everything back when the game exits."
: "Panama can react when Game Mode engages. This needs a hook in gamemode's configuration."
ActionRow {
visible: Gaming.gameMode?.hooksInstalled !== true
label: "Let Panama react to games"
detail: "Adds a start and end hook to your gamemode configuration. Nothing else in that file is touched."
action: "Enable"
enabled: !Gaming.busy && Gaming.gameMode?.available === true
onTriggered: Gaming.installHooks()
}
ToggleRow {
visible: Gaming.gameMode?.hooksInstalled === true
setting: "gamingPerformanceProfile"
}
// Silencing while a game runs is a focus mode with a trigger, and it
// now lives with the others -- so "something silenced my notifications"
// has one answer rather than two places to look.
ActionRow {
visible: Gaming.gameMode?.hooksInstalled === true
label: "Silence notifications"
detail: {
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
if (!mode)
return "Handled by a focus mode on Notifications & Focus.";
return mode.enabled === true
? "Handled by the Gaming focus mode, which is on."
: "The Gaming focus mode is off, so notifications are not silenced.";
}
action: "Open Focus"
onTriggered: ShellState.settingsPage = "notifications"
}
ToggleRow {
visible: Gaming.gameMode?.hooksInstalled === true
setting: "gamingNotifyOnStart"
}
ActionRow {
visible: Gaming.gameMode?.hooksInstalled === true
label: "Stop reacting to games"
detail: "Removes the hooks. The settings above are kept."
action: "Disable"
enabled: !Gaming.busy
divider: false
onTriggered: Gaming.removeHooks()
}
}
// ── Game Mode itself ─────────────────────────────────────────────────────
SettingsCard {
title: "Game Mode"
subtitle: Gaming.gameMode?.available === true
? "Applied by gamemode to a game while it runs, then undone."
: "gamemode is not installed."
TextRow {
label: "Daemon"
detail: Gaming.gameMode?.daemonRunning === true
? "Running, waiting for a game to ask"
: "Not running, so no game can request it"
value: Gaming.gameMode?.daemonRunning === true ? "Running" : "Stopped"
}
// Said plainly rather than implied: on a machine already running the
// governor gamemode would switch to, its headline effect is nothing.
TextRow {
label: "Governor while gaming"
detail: Gaming.governorAlreadyThere
? "This machine already runs that governor, so Game Mode changes nothing here"
: "Switched for the duration of the game"
value: String(Gaming.gameMode?.governorWhileGaming ?? "unknown")
divider: false
}
}
// ── Overlay ──────────────────────────────────────────────────────────────
SettingsCard {
title: "Performance overlay"
subtitle: Gaming.overlay?.installed === true
? "MangoHud, drawn on top of the game."
: "MangoHud is not installed."
SwitchRow {
label: "Show the overlay in games"
detail: "Takes effect for games launched after your next sign-in, because it is read from the session environment"
checked: Gaming.overlay?.globallyEnabled === true
enabled: !Gaming.busy && Gaming.overlay?.installed === true
onToggled: value => Gaming.setOverlay(value)
}
SegmentRow {
label: "What it shows"
detail: "Frame rate alone, or the full readout with GPU and CPU"
options: [
{ value: "fps", label: "Frame rate" },
{ value: "detailed", label: "Detailed" }
]
value: String(Gaming.overlay?.preset ?? "fps")
enabled: !Gaming.busy && Gaming.overlay?.installed === true
onSelected: value => Gaming.setOverlayPreset(value)
}
TextRow {
label: "Toggle in game"
detail: "Shows and hides the overlay without leaving the game"
value: String(Gaming.overlay?.toggleKey ?? "")
divider: false
}
}
// ── Library ──────────────────────────────────────────────────────────────
SettingsCard {
title: "Library"
subtitle: "Where games live, and what can run them."
TextRow {
label: "Installed games"
detail: String(Gaming.library?.path ?? "")
value: String(Gaming.library?.games ?? 0)
}
// Listed, not chosen. Steam picks the runtime per game in its own
// properties, and a control here would be claiming an authority this
// page does not have.
Repeater {
model: Gaming.library?.protonBuilds ?? []
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: modelData.community === true
? "Community build · chosen per game in Steam"
: "Valve · chosen per game in Steam"
value: "Installed"
divider: true
}
}
TextRow {
label: "gamescope"
detail: "Micro-compositor for scaling and frame limiting, used per game from Steam"
value: Gaming.library?.gamescope === true ? "Available" : "Not installed"
divider: false
}
}
}
@@ -422,27 +422,11 @@ SettingsPage {
} }
} }
ActionRow {
objectName: "health-fedora-handoff:users"
label: "Users"
detail: "Accounts, passwords, and automatic login"
action: "Open users"
onTriggered: SystemSettings.openGnomePanel("system", "users")
}
ActionRow {
objectName: "health-fedora-handoff:sharing"
label: "Sharing"
detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing"
onTriggered: SystemSettings.openGnomePanel("sharing")
}
ActionRow { ActionRow {
objectName: "health-fedora-handoff:color" objectName: "health-fedora-handoff:color"
label: "Colour profiles" label: "Color profiles"
detail: "ICC profiles for displays, printers, and scanners" detail: "Assigning an ICC profile here has no visible effect in this session: the colord daemon that loads it onto a display isn't running under Hyprland"
action: "Open colour" action: "Open color"
onTriggered: SystemSettings.openGnomePanel("color") onTriggered: SystemSettings.openGnomePanel("color")
} }
@@ -1,3 +1,19 @@
// Home — the page you open to do something, not to read a report.
//
// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000,
// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest
// thing on the screen, and nobody has ever needed it here. Below 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.
//
// What Panama already knows is the useful material: whether anything needs
// attention, what is running, and the two or three things worth doing next. A
// finding is shown when there is one and the page is quiet when there is not,
// which is the same shape the Firewall and Containers pages use.
//
// Weather stays. It is the one genuinely ambient thing here, and it belongs
// next to a greeting rather than in a card of its own with a search box open.
import QtQuick import QtQuick
import qs.config import qs.config
import qs.services import qs.services
@@ -6,125 +22,101 @@ SettingsPage {
id: root id: root
readonly property int openedHour: new Date().getHours() readonly property int openedHour: new Date().getHours()
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening") readonly property string greeting: openedHour < 12
? "Good morning"
: (openedHour < 18 ? "Good afternoon" : "Good evening")
// Findings, in the order they deserve attention. Each names the page that
// can actually resolve it, because a home page that reports a problem it
// cannot help with is just an alarm.
readonly property var findings: {
const found = [];
const exposed = Firewall.exposedDataStores ?? [];
if (exposed.length > 0) {
found.push({
label: exposed.length === 1
? "A database is reachable from your network"
: "Databases are reachable from your network",
detail: exposed.map(entry => String(entry.name)).join(" and ")
+ ", published on every interface",
page: "firewall",
action: "Review"
});
}
if (Updates.securityCount > 0) {
found.push({
label: Updates.securityCount + " update"
+ (Updates.securityCount === 1 ? "" : "s") + " carry a security advisory",
detail: "Worth installing before the rest",
page: "updates",
action: "Open"
});
}
if (Updates.rebootNeeded) {
found.push({
label: "A newer kernel is installed than the one running",
detail: "Restart to use it",
page: "updates",
action: "Open"
});
}
if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) {
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
found.push({
label: count + " health check" + (count === 1 ? "" : "s") + " need attention",
detail: "Something the desktop owns is not in its expected state",
page: "services",
action: "Open"
});
}
return found;
}
// One line of everything that is fine, so the quiet case still says
// something rather than showing an empty page.
readonly property string reassurance: {
const parts = [];
if (Health.checks.length > 0)
parts.push(Health.checks.length + " health checks pass");
if (Disks.rootFilesystem)
parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.available ?? 0)) + " free");
const newest = Snapshots.configs.length > 0
? (Snapshots.configs[0].snapshots ?? [])[0]
: null;
if (newest)
parts.push("snapshots ran at " + root.clockOf(String(newest.date ?? "")));
return parts.join(" · ");
}
// snapper prints a full date; only the time is wanted in a summary line.
function clockOf(stamp: string): string {
const match = /(\d{1,2}:\d{2})(?::\d{2})?\s*(AM|PM)?/i.exec(stamp);
if (!match)
return stamp;
return match[2] ? match[1] + " " + match[2].toUpperCase() : match[1];
}
title: `${root.greeting}, Gabriel` title: `${root.greeting}, Gabriel`
lede: "Your desktop is configured and ready." lede: Weather.available
? Math.round(Weather.temperature) + Weather.unitSuffix + " and "
+ Weather.description.toLowerCase() + " in " + Settings.weatherLocation
: "Your desktop is configured and ready."
SettingsCard { Component.onCompleted: {
title: SystemSettings.monitorDescription || "Active display" if (!Firewall.scanned)
subtitle: SystemSettings.monitorName || "Detecting your display…" Firewall.refresh();
if (!Containers.scanned)
Grid { Containers.refresh();
id: monitorLayout if (!Snapshots.scanned)
Snapshots.refresh();
width: parent.width if (!Disks.scanned)
columns: width >= 620 ? 2 : 1 Disks.refresh();
columnSpacing: 28
rowSpacing: 16
Item {
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: 164
Rectangle {
width: Math.min(parent.width - 24, 260)
height: width * 0.64
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 5
radius: 12
color: Theme.alpha(Theme.bgDark, 0.94)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.34)
Rectangle {
anchors.fill: parent
anchors.margins: 10
radius: 7
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
}
Text {
anchors.centerIn: parent
text: SystemSettings.monitorName || "DISPLAY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.letterSpacing: 1.5
}
}
}
}
Column {
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: monitorLayout.columns === 2 ? 164 : implicitHeight
spacing: 13
Text {
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
Text {
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
} }
SettingsCard { // ── What you came here to do ────────────────────────────────────────────
title: "Weather"
subtitle: "Local conditions in the date menu"
TextRow {
label: "Location"
detail: "Only the search term is sent; the name below is a label kept on this machine"
value: Settings.weatherLocation
}
LocationPicker {
width: parent.width
}
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid { Grid {
id: summaryCards id: doing
width: parent.width width: parent.width
columns: width >= 720 ? 2 : 1 columns: width >= 720 ? 2 : 1
@@ -132,20 +124,46 @@ SettingsPage {
rowSpacing: 16 rowSpacing: 16
SettingsCard { SettingsCard {
width: summaryCards.columns === 2 width: doing.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2 ? (doing.width - doing.columnSpacing) / 2
: summaryCards.width : doing.width
title: "Quiet focus" title: "Focus"
subtitle: "Notifications and focused work" subtitle: FocusSession.active
? "A session is running · " + FocusSession.remainingText + " left"
: (FocusModes.active
? FocusModes.activeName + " is on because " + FocusModes.activeReason
: (Notifs.doNotDisturb
? "Do Not Disturb is on"
: "Nothing is quieting this machine"))
SettingRow {
label: FocusSession.active ? "End the session" : "Start a focus session"
detail: FocusSession.active
? "Puts Do Not Disturb and Caffeine back as they were"
: Settings.focusDurationMinutes + " minutes · Super+Shift+F"
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "End" : "Start"
tone: FocusSession.active ? "normal" : "accent"
onClicked: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault()
}
}
SettingRow { SettingRow {
label: "Do Not Disturb" label: "Do Not Disturb"
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible" detail: FocusModes.active
? "Held on by the " + FocusModes.activeName + " mode"
: "Banners are held; the notification centre still fills"
divider: false divider: false
controlWidth: 48 controlWidth: 48
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
enabled: !FocusModes.active && !FocusSession.active
checked: Notifs.doNotDisturb checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value onToggled: value => Notifs.doNotDisturb = value
} }
@@ -153,18 +171,119 @@ SettingsPage {
} }
SettingsCard { SettingsCard {
width: summaryCards.columns === 2 width: doing.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2 ? (doing.width - doing.columnSpacing) / 2
: summaryCards.width : doing.width
title: "Desktop services" title: "Right now"
subtitle: "The essentials are running" subtitle: Containers.running > 0
? Containers.running + " of " + Containers.total + " containers running"
: "No containers are running"
TextRow { TextRow {
label: "Sync & remote access" label: Containers.running > 0
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}` ? Containers.projects.filter(project => project.running > 0)
.map(project => String(project.title)).join(", ")
: "Nothing to report"
detail: Containers.running > 0
? "Started for development, still up"
: "Start a stack from the Containers page"
value: ""
}
ActionRow {
label: "Containers"
detail: "Projects, what they expose, and what they cost"
action: "Open"
divider: false divider: false
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review" onTriggered: ShellState.settingsPage = "containers"
} }
} }
} }
// ── This machine ────────────────────────────────────────────────────────
SettingsCard {
title: "This machine"
subtitle: root.reassurance
TextRow {
visible: root.findings.length === 0
label: "Nothing needs your attention"
detail: "Anything worth acting on would appear here."
value: ""
divider: false
}
Repeater {
model: root.findings
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.label ?? "")
detail: String(modelData.detail ?? "")
controlWidth: 110
divider: index < root.findings.length - 1
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: String(modelData.action ?? "Open")
onClicked: ShellState.settingsPage = String(modelData.page)
}
}
}
}
// ── Worth doing, when there is something ────────────────────────────────
SettingsCard {
visible: Updates.total > 0 || Containers.reclaimable > 0
title: "Do next"
subtitle: "Small things this machine is waiting on."
ActionRow {
visible: Updates.total > 0
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s")
detail: "Packages, applications and firmware, from wherever each comes from"
action: "Open"
divider: Containers.reclaimable > 0
onTriggered: ShellState.settingsPage = "updates"
}
ActionRow {
visible: Containers.reclaimable > 0
label: "Reclaim " + Containers.formatBytes(Containers.reclaimable)
detail: "Container images and volumes nothing references"
action: "Review"
divider: false
onTriggered: ShellState.settingsPage = "containers"
}
}
// ── Weather ─────────────────────────────────────────────────────────────
SettingsCard {
title: "Weather"
subtitle: "Shown above and in the date menu."
// The location is set roughly once a year, so the search that changes it
// is behind a press rather than permanently open.
PickerRow {
id: locationPicker
label: "Location"
detail: "Only the search term is sent; the name is a label kept on this machine"
value: Settings.weatherLocation
LocationPicker {
width: parent.width
onPicked: locationPicker.collapse()
}
}
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
} }
@@ -43,10 +43,14 @@ SettingsPage {
return "Home Assistant is unavailable"; return "Home Assistant is unavailable";
} }
// The entity list is a one-time migration seed for the Control Center
// selection, not the light catalog -- the helper discovers that live from
// Home Assistant. It is passed back unchanged so that saving a URL or a
// token cannot disturb the seed.
function saveHomeAssistantConfig(): void { function saveHomeAssistantConfig(): void {
HomeAssistantConfig.save( HomeAssistantConfig.save(
homeUrlInput.text, homeUrlInput.text,
homeEntitiesInput.text, HomeAssistantConfig.entities.join(", "),
homeTokenInput.text homeTokenInput.text
); );
} }
@@ -57,7 +61,6 @@ SettingsPage {
function onConfigurationSaved(): void { function onConfigurationSaved(): void {
homeTokenInput.clear(); homeTokenInput.clear();
homeUrlInput.text = HomeAssistantConfig.url; homeUrlInput.text = HomeAssistantConfig.url;
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
} }
} }
@@ -130,65 +133,6 @@ SettingsPage {
} }
} }
Column {
width: parent.width
spacing: 7
topPadding: 10
bottomPadding: 12
Text {
width: parent.width
text: "Light entities"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
text: "Comma-separated entity IDs. These define the discoverable light catalog."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
height: 72
radius: 10
color: Theme.alpha(Theme.fg, 0.055)
border.width: homeEntitiesInput.activeFocus ? 2 : 1
border.color: homeEntitiesInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
TextEdit {
id: homeEntitiesInput
anchors.fill: parent
anchors.margins: 10
activeFocusOnTab: true
text: HomeAssistantConfig.entities.join(", ")
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: TextEdit.Wrap
clip: true
Text {
anchors.fill: parent
visible: homeEntitiesInput.text === ""
text: "light.living_room, light.kitchen"
color: Theme.fgMuted
font: homeEntitiesInput.font
wrapMode: Text.WordWrap
}
}
}
}
Text { Text {
width: parent.width width: parent.width
visible: HomeAssistantConfig.lastError !== "" visible: HomeAssistantConfig.lastError !== ""
@@ -12,6 +12,10 @@ import qs.modules.clipboard
Column { Column {
id: root id: root
// Emitted once a place has been chosen, so a container can put the search
// away. Choosing is still this component's job; closing is not.
signal picked
spacing: 0 spacing: 0
SearchField { SearchField {
@@ -37,8 +41,10 @@ Column {
divider: place.index < Geocoding.results.length - 1 divider: place.index < Geocoding.results.length - 1
activatable: true activatable: true
onActivated: { onActivated: {
if (Geocoding.choose(place.modelData)) if (Geocoding.choose(place.modelData)) {
query.text = ""; query.text = "";
root.picked();
}
} }
} }
} }

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