Author SHA1 Message Date
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
211 changed files with 9937 additions and 1318 deletions
+3 -2
View File
@@ -14,10 +14,11 @@
__pycache__/
*.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
# 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-4.0/settings.ini
/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)
flatpak update -y
sudo flatpak update
sudo flatpak update -y
# 2.5) Optional firmware via fwupd
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
fi
+1 -1
View File
@@ -2,7 +2,7 @@
#
# btop ships no Tokyo Night light variant at all, and the nearest stock light
# theme (flat-remix-light) is a different palette that happens to have a similar
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
# background. Same colors as kitty/themes/tokyonight-day.conf so the terminal
# and what runs inside it cannot disagree.
#
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
+1 -1
View File
@@ -1,6 +1,6 @@
# 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
# committed. Under GNOME, gnome-settings-daemon publishes these over
# 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.
#
# 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
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
+8 -8
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 |
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
| Dock | Quickshell dock with GNOME favourites and intellihide | Live |
| Minimize | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | 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 |
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | 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 |
| 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 |
| 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 |
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
@@ -36,7 +36,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | 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
@@ -59,7 +59,7 @@ They never install packages, invoke `sudo`, delete user data, or rewrite
arbitrary configuration.
Generic Fedora configuration remains with the system tools that own it. The
final System Health card hands network settings, users, sharing, colour
final System Health card hands network settings, users, sharing, color
profiles, and digital wellbeing to their exact GNOME Settings panels rather
than presenting inert Hyprland controls.
@@ -77,9 +77,9 @@ than presenting inert Hyprland controls.
| Caffeine | Replaced by a real logind inhibitor in quick settings |
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
| 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 |
| 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 |
| 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 |
@@ -111,4 +111,4 @@ menu for a minority, which is worse than omitting it. Relevant references:
- <https://github.com/hyprwm/Hyprland/discussions/1358>
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 |
| `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
| `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
| `keybinds.lua` | The full keymap |
@@ -87,7 +87,7 @@ persistence, validation, reset, and the live write are all derived from that
entry.
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
wrong-typed settings file costs you your customisations and nothing else;
wrong-typed settings file costs you your customizations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
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
out before the corners. See `quickshell/widgets/PrismEdge.qml`.
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.
3. The active workspace pill in the bar.
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
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`
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
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 + CTRL + H/J/K/L` | Swap window |
| `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 + Q` | Close |
| `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 + Tab` / `SHIFT + Tab` | Cycle windows |
| `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)
| Key | Action |
@@ -270,19 +270,19 @@ The mental model is unchanged from Forge:
### Launcher and shell
| 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 + V` | Clipboard history |
| `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings |
| `SUPER + I` | Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre |
| `SUPER + B` | Notification center |
| `SUPER + grave` | Workspace overview |
| `Print` | Screenshot / record picker |
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
| `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 + 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
dashboard windows.
Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Settings → Home & Phone** to choose favourites,
Home shows the first four selected favorites at rest and every selected light
when expanded. Use **Settings → Home & Phone** to choose favorites,
set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous
@@ -334,7 +334,7 @@ level.
Credentials stay private in the gitignored `config/bash/env` file, with the
existing GNOME extension and Secret Service setup retained as a compatibility
fallback. Favourites, aliases, and order live in Quickshell state. No shell
fallback. Favorites, aliases, and order live in Quickshell state. No shell
restart is required after changing credentials; close and reopen Control Center
to refresh. If Home Assistant is offline, the last known values stay visible
with a stale-state label and Retry action.
@@ -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
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
modifier misfires when you're quick with SUPER combos.
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap.
+25 -13
View File
@@ -23,8 +23,16 @@ hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
hl.exec_cmd("systemctl --user start hyprland-session.target")
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
-- 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.
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
-- The shell: bar, dock, overview, quick settings, notifications, capture.
-- No systemd unit ships with quickshell, so it runs as a compositor child.
@@ -39,18 +47,22 @@ hl.on("hyprland.start", function()
-- Keyring unlock, for Nextcloud and Bitwarden credential storage.
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
-- Tray applications carried over from ~/.config/autostart. These need the
-- Quickshell tray (an SNI host) to be up, hence starting after it.
-- Nextcloud and Bitwarden are NOT started here. Both ship a
-- ~/.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
-- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and
-- its autostart entry launches a specific script with --autostart.
--
-- 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")
-- RustDesk is deliberately absent too: 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.
end)
hl.on("hyprland.shutdown", function()
+2 -2
View File
@@ -1,7 +1,7 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- 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.
--
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
@@ -58,7 +58,7 @@ hl.env("QT_QPA_PLATFORM", "wayland;xcb")
-- "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
-- 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.
--
-- 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:
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
@@ -15,7 +15,7 @@
general {
# `pidof` guard prevents stacking lockers if this fires twice.
lock_cmd = pidof hyprlock || hyprlock
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
before_sleep_cmd = loginctl lock-session
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
+1 -1
View File
@@ -15,7 +15,7 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- 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.
require("prefs")
@@ -1,8 +1,17 @@
# ─────────────────────────────────────────────────────────────────────────────
# hyprlock — lock screen
# hyprlock — lock screen.
#
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
# GENERATED FILE. Edit hyprlock.conf.template and re-run
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
# next 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.
# ─────────────────────────────────────────────────────────────────────────────
@@ -45,7 +54,7 @@ background {
vibrancy_darkness = 0.05
# Shown if the screenshot is unavailable.
color = rgba(34, 36, 54, 1.0)
color = rgba(@BG@, 1.0)
zindex = -1
}
@@ -54,7 +63,7 @@ background {
label {
monitor =
text = cmd[update:1000] date +"%-I:%M"
color = rgba(200, 211, 245, 1.0)
color = rgba(@FG@, 1.0)
font_size = 120
font_family = Adwaita Sans Light
position = 0, 260
@@ -65,7 +74,7 @@ label {
label {
monitor =
text = cmd[update:60000] date +"%A, %B %-d"
color = rgba(130, 139, 184, 1.0)
color = rgba(@MUTED@, 1.0)
font_size = 24
font_family = Adwaita Sans
position = 0, 160
@@ -84,18 +93,18 @@ input-field {
outline_thickness = 2
rounding = 26
outer_color = rgba(130, 170, 255, 0.9)
inner_color = rgba(46, 47, 61, 0.85)
font_color = rgba(200, 211, 245, 1.0)
check_color = rgba(130, 170, 255, 1.0)
fail_color = rgba(255, 117, 127, 1.0)
outer_color = rgba(@ACCENT@, 0.9)
inner_color = rgba(@FIELD@, 0.85)
font_color = rgba(@FG@, 1.0)
check_color = rgba(@ACCENT@, 1.0)
fail_color = rgba(@ERROR@, 1.0)
dots_size = 0.25
dots_spacing = 0.3
dots_center = true
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
fade_on_empty = false
hide_input = false
@@ -105,7 +114,7 @@ input-field {
label {
monitor =
text = $USER
color = rgba(200, 211, 245, 0.9)
color = rgba(@FG@, 0.9)
font_size = 16
font_family = Adwaita Sans
position = 0, -110
+1 -1
View File
@@ -9,7 +9,7 @@
# hyprland-guiutils hyprland-dialog / hyprland-run
# 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
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
# ─────────────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -22,10 +22,10 @@ hl.config({
repeat_rate = prefs.get("keyRepeatRate", 33),
-- 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
-- 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
-- only the description is fixed; Settings exposes all four.
follow_mouse = prefs.getInt("followMouse", 1),
+39 -13
View File
@@ -118,7 +118,7 @@ bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { descriptio
-- which Hyprland has no equivalent for.
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" })
-- 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")),
{ description = "Screen Intelligence" })
-- Colour 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" })
-- Color picker: copies the hex under the cursor to the clipboard.
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
-- ── Window management ───────────────────────────────────────────────────────
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
@@ -182,7 +182,7 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
--
-- Forge resized one named edge at a time. Hyprland resizes the active window
-- along an axis and lets the layout decide which edge actually moves, so the
-- eight Forge keys collapse onto four behaviours. The pairing is kept
-- eight Forge keys collapse onto four behaviors. The pairing is kept
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
-- and "increase" always grows while "decrease" always shrinks.
local step = 60
@@ -192,12 +192,27 @@ bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative =
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- SUPER+SHIFT+P was double-bound with the color picker above; moved to
-- 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 = "Shorter" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
-- showing what you are choosing between.
--
-- The gesture needs three binds, not two. Tab steps the selection, and the
-- switch is only COMMITTED when the modifier is released -- which is the sole
-- way the compositor can tell the gesture is finished. That release bind is on
-- the bare modifier, so it fires on EVERY Super release in the session; the
-- handler returns immediately when no switch is open, which is why this is
-- affordable.
--
-- The release bind carries no description on purpose: it is not a shortcut
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
-- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
@@ -211,6 +226,17 @@ bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
-- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping.
-- 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 + 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" })
@@ -233,18 +259,18 @@ end
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- Minimise, as far as Hyprland has one.
-- 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
-- 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.
--
-- 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.
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
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 = "Minimize to scratchpad" })
-- ── Session ─────────────────────────────────────────────────────────────────
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
+42 -15
View File
@@ -1,8 +1,8 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Look and feel -- Tokyo Night Moon
--
-- Colours here must stay in sync with quickshell/config/Theme.qml.
-- accent #82aaff borders / focus
-- Colors here must stay in sync with quickshell/config/Theme.qml.
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
-- bg #222436 base
--
-- Performance note: every animation below is event-driven. Nothing uses the
@@ -14,6 +14,30 @@
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({
general = {
gaps_in = prefs.get("gapsIn", 5),
@@ -22,16 +46,17 @@ hl.config({
border_size = prefs.get("borderSize", 2),
col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair
-- is visible on both a tall and a wide window. Same two colours as
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
-- tmux theme this palette came from.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means
-- something if exactly one window on screen is wearing it.
-- Follows the colour scheme: a dark neutral is invisible against a
-- light desktop. services/ColorScheme.qml applies changes live;
-- this is the value a fresh session starts from.
-- The focused accent role, on a diagonal so the pair is visible
-- on both a tall and a wide window. Driven by the chosen
-- accentName (see the `accents` table above); services/
-- ColorScheme.qml applies the same values live, and restates them
-- from Theme.accent/Theme.accentSecondary on every scheme change
-- too, since each accent carries a separate pair per scheme.
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
-- The neutral inactive role follows the color scheme because a
-- dark neutral disappears against a light desktop.
-- services/ColorScheme.qml applies the same values live; this is
-- the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)",
},
@@ -105,11 +130,13 @@ hl.config({
-- New in 0.56. Kept deliberately faint: in this direction the gradient
-- border is the signature, and a strong halo would compete with it.
-- 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 = {
enabled = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 8),
render_power = 2,
color = "rgba(82aaff33)",
color = "rgba(" .. accentStart .. "33)",
color_inactive = "rgba(00000000)",
},
@@ -166,12 +193,12 @@ hl.config({
-- Let a crashed lock screen be recovered rather than stranding you.
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,
},
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,
-- 1 = automatically flip the monitor into HDR for fullscreen content
+42 -5
View File
@@ -20,9 +20,12 @@
local prefs = require("prefs")
-- 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
-- (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.
@@ -71,6 +74,26 @@ local function valid_transform(transform)
and transform <= 3
end
local function valid_coordinate(value)
return type(value) == "number"
and value == value
and value == math.floor(value)
and value >= -100000
and value <= 100000
end
local function valid_position(entry)
return valid_coordinate(entry.x) and valid_coordinate(entry.y)
end
local function valid_primary(entry)
return type(entry.primary) == "boolean"
end
local function has_layout_fields(entry)
return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil
end
local function display_entry(output)
if type(output) ~= "string" or output == ""
or output:match("^[%w_.-]+$") == nil then
@@ -85,9 +108,23 @@ local function display_entry(output)
or not valid_transform(entry.transform) then
return nil
end
-- Legacy records have none of the layout fields and keep automatic
-- placement. A partially written extended record is unsafe: accepting its
-- mode but guessing its position could overlap or strand another output.
if has_layout_fields(entry)
and (not valid_position(entry) or not valid_primary(entry)) then
return nil
end
return entry
end
local function display_position(entry, fallback)
if entry ~= nil and has_layout_fields(entry) then
return string.format("%dx%d", entry.x, entry.y)
end
return fallback
end
local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5
local shipped_transform = 0
@@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2")
hl.monitor({
output = "DP-2",
mode = dp2 and dp2.mode or shipped_mode,
position = "0x0",
position = display_position(dp2, "0x0"),
scale = dp2 and dp2.scale or shipped_scale,
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
-- 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.
for output, _ in pairs(displays) do
if output ~= "DP-2" then
@@ -119,7 +156,7 @@ for output, _ in pairs(displays) do
hl.monitor({
output = output,
mode = entry.mode,
position = "auto",
position = display_position(entry, "auto"),
scale = entry.scale,
transform = entry.transform,
})
+1 -1
View File
@@ -16,7 +16,7 @@
-- cannot drift apart.
--
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
-- file must cost the user nothing worse than their customisations; it must
-- 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
-- fallback.
-- ─────────────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -69,7 +69,7 @@ hl.window_rule({
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({
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
float = true,
@@ -150,7 +150,7 @@ hl.layer_rule({
ignore_alpha = 0.3,
})
-- Popovers: calendar, quick settings, notification centre, tray menus.
-- Popovers: calendar, quick settings, notification center, tray menus.
hl.layer_rule({
name = "qs-popover",
match = { namespace = "^qs-popover" },
+1 -1
View File
@@ -1,7 +1,7 @@
# Tokyo Night Moon — the dark theme Panama ships.
#
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
# includes current-theme.conf, which Panama generates from the colour scheme
# includes current-theme.conf, which Panama generates from the color scheme
# setting; that generated file is gitignored because it is machine state.
#
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
+1 -1
View File
@@ -6,7 +6,7 @@
-- Hyprland config read.
--
-- Nothing here may raise. A missing or malformed settings file must cost the
-- user their colour scheme preference and nothing else; editing text is more
-- user their color scheme preference and nothing else; editing text is more
-- important than matching the desktop.
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,
-- so the editor keeps the identity the rest of the desktop has rather than
-- becoming a different-looking application when the scheme flips.
--
-- The readability fixes below are deliberately dark-only. They were written
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
-- applying them to Day would put light grey text on a light background, which
-- against Moon's palette -- a pale comment color, a mid-gray gutter -- and
-- applying them to Day would put light gray text on a light background, which
-- is exactly the legibility problem they exist to solve, inverted.
local panama = require("config.panama")
+1 -1
View File
@@ -22,7 +22,7 @@ console_title_template = '{{ .Shell }} in {{ .Folder }}'
[[blocks.segments]]
type = 'git'
style = 'plain'
foreground = 'p:grey'
foreground = 'p:gray'
background = 'transparent'
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 : {};
// 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
// being noticed later by whoever owns that setting.
const result = Migrations.apply(raw);
@@ -19,7 +19,7 @@ Singleton {
printErrors: false
atomicWrites: true
onSaved: root.saveError = ""
onSaveFailed: error => root.saveError = "Could not save Home favourites."
onSaveFailed: error => root.saveError = "Could not save Home favorites."
JsonAdapter {
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
// describe what a setting USED to be. Renaming a key, changing its units, or
// splitting one setting into two all leave a stored value that the new schema
// does not recognise -- and an unrecognised key is silently carried through
// does not recognize -- and an unrecognized key is silently carried through
// untouched, so the user's choice simply stops taking effect with nothing to
// say why. That is the failure this exists to prevent.
//
@@ -171,7 +171,7 @@ Singleton {
unit: "px",
group: "windows",
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" }
},
{
@@ -278,7 +278,7 @@ Singleton {
// ── Master layout ───────────────────────────────────────────────────
// Only meaningful when the tiling layout is Master and stack. Offering
// that layout with none of its options was an omission: it is the one
// layout whose whole behaviour is in these settings.
// 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",
@@ -295,7 +295,7 @@ Singleton {
{ value: "right", label: "Right" },
{ value: "top", label: "Top" },
{ value: "bottom", label: "Bottom" },
{ value: "center", label: "Centre" }
{ value: "center", label: "Center" }
],
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
},
@@ -429,7 +429,7 @@ Singleton {
{
key: "keyboardVariant", type: "string", def: "", group: "input",
// Same shape as the layout list and for the same reason: this is
// serialised into an hl.config string.
// serialized into an hl.config string.
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
label: "Layout variant",
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
@@ -467,7 +467,7 @@ Singleton {
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",
detail: "What moving the pointer does to which window is focused",
// These labels were wrong, and wrong in the worst way: value 1 was
@@ -475,7 +475,7 @@ Singleton {
// The compositor publishes the authoritative mapping itself --
// `hyprctl descriptions` gives
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
// -- so a desktop labelled "Click to focus" was in fact following
// -- so a desktop labeled "Click to focus" was in fact following
// the pointer, and the way to actually get click-to-focus was to
// choose "Never". Value 3 was missing entirely.
//
@@ -495,7 +495,7 @@ Singleton {
},
{
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input",
group: "pointer",
label: "Pointer speed",
detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
@@ -503,7 +503,7 @@ Singleton {
{
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s",
group: "input",
group: "pointer",
label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
// Reported as a float even though it is only ever set to whole
@@ -747,6 +747,66 @@ Singleton {
label: "Wallpaper",
detail: "Shown on every output"
},
{
key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
options: [
{ value: "single", label: "Single" },
{ value: "slideshow", label: "Slideshow" },
{ value: "per-monitor", label: "Per display" }
]
},
{
key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
label: "Slideshow collection", detail: "Backgrounds selected for rotation"
},
{
key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
unit: "min", group: "wallpaper", label: "Change background every",
detail: "Time between slideshow images"
},
{
key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
label: "Shuffle", detail: "Show every selected image before repeating"
},
{
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
},
// ── Lock-screen appearance ─────────────────────────────────────────
// scripts/panama-lock validates these again before generating a state
// config. The tracked hyprlock.conf remains the safe fallback.
{
key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
label: "Background", detail: "What appears behind the lock screen",
options: [
{ value: "screenshot", label: "Blurred desktop" },
{ value: "wallpaper", label: "Current wallpaper" },
{ value: "solid", label: "Solid color" }
]
},
{
key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
group: "lockAppearance", label: "Background blur",
detail: "Softens what is behind the password field"
},
{
key: "lockShowClock", type: "bool", def: true, group: "lockAppearance",
label: "Show clock", detail: "Use the desktop's 12 or 24-hour format"
},
{
key: "lockShowDate", type: "bool", def: true, group: "lockAppearance",
label: "Show date", detail: "Show the weekday and full date"
},
{
key: "lockShowUser", type: "bool", def: true, group: "lockAppearance",
label: "Show user name", detail: "Identify the signed-in account"
},
{
key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance",
label: "Hide password field until typing", detail: "Keep the empty field out of the way"
},
// ── Idle, lock, and sleep ───────────────────────────────────────────
// Written into a generated hypridle config; see scripts/panama-idle.
@@ -792,14 +852,14 @@ Singleton {
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
// palette invented to merely not be dark. Both share the same hues at
// different lightness, which is what keeps the Prism identity intact
// across the switch.
//
// services/ColorScheme.qml pushes the choice to GTK and to the
// compositor's border colours, because an application toolbar or a
// compositor's border colors, because an application toolbar or a
// window border still wearing the other scheme is more jarring than
// either scheme on its own.
{
@@ -811,6 +871,26 @@ Singleton {
{ 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
@@ -918,7 +998,7 @@ Singleton {
},
// ── Application titlebars ──────────────────────────────────────────
// These affect applications that honour GNOME's window preferences.
// 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.
{
@@ -981,7 +1061,7 @@ Singleton {
// ── Which GPU the vitals readout tracks ─────────────────────────────
// A sysfs path rather than a card number, because the number is neither
// stable across machines nor meaningful. Constrained to the one shape
// that can be read for utilisation; VitalsWidget hides itself when the
// that can be read for utilization; VitalsWidget hides itself when the
// path is unreadable, so a stale value degrades to no readout rather
// than a wrong one.
{
@@ -997,7 +1077,7 @@ Singleton {
// Coordinates rather than a place name, because that is what Open-Meteo
// takes and it needs no API key. weatherLocation is only the label shown
// in the UI; it is never sent anywhere, so it can say whatever makes the
// reading recognisable.
// reading recognizable.
{
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
group: "weather", internal: true,
@@ -1131,8 +1211,8 @@ Singleton {
},
// ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform } }, applied by
// hypr/monitors.lua on top of the shipped values. Colour management and
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
// hypr/monitors.lua on top of the shipped values. Color management and
// bit depth are deliberately not here: those carry a documented
// screencopy tradeoff that a settings page cannot explain at the moment
// you would be changing it.
@@ -1140,7 +1220,7 @@ Singleton {
key: "displays", type: "json", def: ({}), group: "display",
internal: true,
label: "Display configuration",
detail: "Resolution, scale, and rotation per connected display"
detail: "Resolution, scale, rotation, position, and primary display"
},
// ── Per-application notification rules ──────────────────────────────
@@ -1245,7 +1325,7 @@ Singleton {
case "string": {
const text = typeof value === "string" ? value : String(value);
// 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
// instead of an error the user can see.
if (entry.pattern && !new RegExp(entry.pattern).test(text))
+1 -1
View File
@@ -62,7 +62,7 @@ Singleton {
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
// ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites.
// Pinned apps, in order, taken from the GNOME dash favorites.
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
// Dash-to-Dock was set to intellihide against all windows: the dock hides
+41 -13
View File
@@ -1,9 +1,9 @@
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
// 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
// desktop, edge to edge. The dock retains the former glass vocabulary: bottom,
@@ -18,7 +18,7 @@ import QtQuick
Singleton {
id: root
// ── Colour scheme ───────────────────────────────────────────────────────
// ── Color scheme ───────────────────────────────────────────────────────
// Tokyo Night ships an official light variant (Day), so light mode is that
// rather than a palette invented to merely not be dark. The two share the
// same hues at different lightness, which is what lets the Prism identity
@@ -42,13 +42,41 @@ Singleton {
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
// The pair. `accent` is the primary and carries every state meaning
// (focused, active, on). `accentSecondary` is the orchid from the tmux
// theme — it never appears alone, only as the far end of a gradient. That
// restraint is the whole point: the two colours meeting is the signature,
// so the pink stops being special the moment it's used as a flat fill.
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
// ── The accent ──────────────────────────────────────────────────────────
//
// `accent` is the primary and carries every state meaning (focused, active,
// on). `accentSecondary` never appears alone, only as the far end of a
// gradient. That restraint is the whole point: the two colors meeting is
// the signature, so the second color stops being special the moment it is
// 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.
readonly property var accents: ({
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue" },
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid" },
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal" },
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green" },
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber" },
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange" },
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose" },
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "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 cyan: root.dark ? "#86e1fc" : "#007197"
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
@@ -108,7 +136,7 @@ Singleton {
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
// pragmatic alternative to freedesktop symbolic icons, which ship with a
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
// hardcoded dark fill Qt will not recolor (see widgets/ThemedIcon.qml).
readonly property string fontMono: DesktopPreferences.get("iconFont")
// Apply to any text whose digits change in place — clocks, percentages,
@@ -152,7 +180,7 @@ Singleton {
}
// 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 {
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 +191,7 @@ Singleton {
// the active workspace pill and slider fills.
//
// 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 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 : "",
scale: monitor ? monitor.scale : 0,
transform: monitor ? monitor.transform : -1,
x: monitor ? monitor.x : 0,
y: monitor ? monitor.y : 0,
primary: monitor ? monitor.primary : false,
modes: monitor ? monitor.modes.length : 0,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
@@ -36,8 +39,51 @@ ShellRoot {
return Displays.apply(monitor.name, mode, scale, monitor.transform);
}
function transactionStatus(): string {
return JSON.stringify({
layout: Displays.currentLayout(),
pending: Displays.pendingRequestedLayout,
previous: Displays.pendingPreviousLayout,
reverting: Displays.revertExpectedLayout,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
busy: Displays.busy,
generation: Displays.operationGeneration,
revertGeneration: Displays.revertGeneration,
lastError: Displays.lastError
});
}
function applyLayoutFixture(secondX: int, secondY: int): bool {
const layout = Displays.currentLayout();
if (layout.length !== 2) return false;
layout[0].x = 0;
layout[0].y = 0;
layout[0].primary = true;
layout[1].x = secondX;
layout[1].y = secondY;
layout[1].primary = false;
return Displays.applyLayout(layout);
}
function makePrimaryFixture(output: string): bool {
return Displays.makePrimary(output);
}
function injectReadback(text: string, generation: int): void {
Displays.parse(text, generation);
}
function expireApplyVerification(): void {
Displays.verificationTimedOut();
}
function expireRevertVerification(): void {
Displays.revertVerificationTimedOut();
}
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
const modes = Displays.normalizeModes([
"[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 {
const monitor = Displays.monitors[0];
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
// 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 QtQuick
@@ -13,6 +13,9 @@ Pill {
horizontalPadding: 8
onActivated: ShellState.toggle("activity")
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
onSecondaryActivated: ShellState.openSettings("privacy")
Text {
anchors.verticalCenter: parent.verticalCenter
text: {
@@ -25,10 +25,18 @@ PanelWindow {
WlrLayershell.layer: WlrLayer.Overlay
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: {
const result = [];
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)
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.cameraActive)
@@ -38,11 +46,12 @@ PanelWindow {
return result;
}
function elapsed(): string {
const total = Capture.recordingSeconds;
const seconds = String(total % 60).padStart(2, "0");
const minutes = Math.floor(total / 60) % 60;
const hours = Math.floor(total / 3600);
// Pure formatter, no ticking property read here -- callers decide what
// seconds value to pass, and only they take on the per-second dependency.
function formatElapsed(totalSeconds: int): string {
const seconds = String(totalSeconds % 60).padStart(2, "0");
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}`;
}
@@ -164,7 +173,11 @@ PanelWindow {
Text {
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
elide: Text.ElideRight
font.family: Theme.fontFamily
+2 -2
View File
@@ -10,7 +10,7 @@
// 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.
import Quickshell
@@ -83,7 +83,7 @@ PanelWindow {
}
}
// ── Centre ──────────────────────────────────────────────────────────
// ── Center ──────────────────────────────────────────────────────────
Row {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
@@ -15,6 +15,9 @@ Pill {
horizontalPadding: 8
onActivated: ShellState.openDateMenu("agenda")
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
onSecondaryActivated: ShellState.openSettings("datetime")
ToolTip.visible: root.hovered && root.visible
ToolTip.delay: 500
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
@@ -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,
// exactly like GNOME's date menu.
@@ -26,11 +26,16 @@ Pill {
onActivated: ShellState.toggleDateMenu("agenda")
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
// format toggles are mirrored on Appearance, but someone right-clicking a
// clock is far more often after the time itself than its typography.
onSecondaryActivated: ShellState.openSettings("datetime")
Text {
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, root.format)
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.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
@@ -7,6 +7,7 @@
import QtQuick
import Quickshell.Services.Mpris
import qs.config
import qs.services
import qs.widgets
Pill {
@@ -34,6 +35,9 @@ Pill {
onActivated: if (root.player?.canTogglePlaying)
root.player.togglePlaying()
// Right-click opens the settings that govern this widget. Output device and per-application volume.
onSecondaryActivated: ShellState.openSettings("sound")
// Scroll up = previous, down = next — the same direction as the workspace
// switcher, so the whole bar scrolls consistently.
onScrolled: delta => {
@@ -24,6 +24,9 @@ Pill {
onActivated: root.requestQuickSettings()
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
onSecondaryActivated: ShellState.openSettings("connectivity")
// ── Audio ───────────────────────────────────────────────────────────────
// Without a tracker, volume and muted silently read as zero/false.
PwObjectTracker {
@@ -59,7 +62,7 @@ Pill {
if (!root.wifiNetwork)
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.
const raw = root.wifiNetwork.signalStrength ?? 0;
const strength = raw <= 1 ? raw * 100 : raw;
@@ -1,5 +1,5 @@
// 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 qs.config
@@ -2,7 +2,7 @@
// Vitals extension was configured with (_processor_usage_, _memory_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.
import QtQuick
@@ -13,6 +13,10 @@ import qs.widgets
Pill {
id: root
// Right-click opens the settings that govern this widget. Which readouts
// appear in the bar, and how often they update.
onSecondaryActivated: ShellState.openSettings("appearance")
interactive: false
Row {
@@ -10,6 +10,9 @@ import qs.widgets
Pill {
id: root
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
onSecondaryActivated: ShellState.openSettings("home")
interactive: false
visible: Weather.available
@@ -36,7 +36,7 @@ Item {
implicitWidth: row.implicitWidth + 12
// 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
// 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
// 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 {
orientation: Gradient.Horizontal
GradientStop {
@@ -1,7 +1,7 @@
// 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
// 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.
import QtQuick
@@ -13,7 +13,7 @@ Rectangle {
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).
property bool captureEnabled: true
@@ -1,7 +1,7 @@
// One segment of the Screen / Window / Selection control.
//
// 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.
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.
//
// 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
// inside moves it, dragging a corner resizes from the opposite corner, and
// dragging anywhere else starts a fresh band.
@@ -52,7 +52,7 @@ Item {
root.resetSelection();
}
// Centred, roughly the middle half of the screen.
// Centered, roughly the middle half of the screen.
function resetSelection(): void {
if (root.width <= 0 || root.height <= 0)
return;
@@ -1,11 +1,11 @@
// The month grid from GNOME's date menu.
//
// 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.
//
// 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 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.
Text {
anchors.centerIn: parent
@@ -1,12 +1,12 @@
// 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.
//
// 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
// 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.
import QtQuick
@@ -87,7 +87,7 @@ PanelWindow {
}
// 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 {
id: openScale
origin.x: surface.width / 2
+30 -4
View File
@@ -48,12 +48,22 @@ PanelWindow {
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
// ── 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
// 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
// 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: {
const ws = Hyprland.focusedWorkspace;
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
return !!ws && ws.toplevels.values.length > 0;
}
@@ -83,15 +93,31 @@ PanelWindow {
onTriggered: root.revealed = false
}
// Other modules (the bar, the capture overlay) read this.
onRevealedChanged: ShellState.dockRevealed = revealed
// Other modules (the bar, the capture overlay) read this. It is one
// 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
// 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.
Component.onCompleted: {
revealed = wantRevealed;
ShellState.dockRevealed = revealed;
root._syncShellState();
}
// ── Input region ────────────────────────────────────────────────────────
@@ -172,14 +172,14 @@ Rectangle {
}
}
// Centred on the hovered item, clamped inside the dock. Reads the item's
// Centered on the hovered item, clamped inside the dock. Reads the item's
// x directly (rather than mapToItem) so the binding re-evaluates when
// the row reflows.
x: {
if (!root.hoveredItem)
return 0;
const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
const center = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, center - width / 2));
}
y: -height - 8
@@ -1,5 +1,5 @@
// 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.
import Quickshell
@@ -72,7 +72,7 @@ Item {
}
// 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 {
anchors.fill: parent
visible: root.iconSource === ""
@@ -155,7 +155,7 @@ Item {
return;
// 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) {
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
@@ -1,7 +1,7 @@
// One notification, rendered GNOME-style: app icon, app name, summary, body,
// 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.
//
// IconButton/ThemedIcon come from the quicksettings module — they belong in
@@ -21,7 +21,7 @@ Rectangle {
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
signal dismissed
@@ -43,7 +43,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.
Rectangle {
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.
import QtQuick
import Quickshell.Services.Notifications
import qs.config
import qs.services
@@ -14,28 +13,25 @@ Item {
// into a keyboard focus request on the layer surface.
signal replyFocusChanged(bool focused)
// Mirrors the signal above so the dismiss timer below can read it.
property bool replyFocused: false
implicitHeight: card.implicitHeight
// Critical notifications stay until dismissed (the setting is 0). An app
// asking for 0 means "never expire" per the freedesktop spec; -1 means
// "server decides", which is our default.
readonly property int timeoutMs: {
if (root.notification.urgency === NotificationUrgency.Critical)
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;
}
// "server decides", which is our default. Shared with Notifs.qml, which
// gives a DND-hidden transient notification the same lifetime.
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
HoverHandler {
id: hover
}
Timer {
// Hovering holds the banner open; the countdown restarts on leave.
running: root.timeoutMs > 0 && !hover.hovered
// Hovering, or actively typing a reply, holds the banner open; the
// countdown restarts (from the top, same as hover) once both let go.
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
interval: root.timeoutMs
onTriggered: Notifs.dropPopup(root.notification)
}
@@ -45,7 +41,10 @@ Item {
width: parent.width
notification: 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.
NumberAnimation on x {
@@ -45,7 +45,17 @@ PanelWindow {
spacing: Theme.itemSpacing
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 {
required property var modelData
@@ -1,6 +1,6 @@
// One workspace in the overview: a proportional card holding live thumbnails
// of its windows, labelled underneath. The focused workspace is outlined in the
// accent colour, as GNOME outlines the current workspace.
// of its windows, labeled underneath. The focused workspace is outlined in the
// accent color, as GNOME outlines the current workspace.
//
// A card with a null `workspace` is the trailing "new workspace" affordance
// that dynamic-workspace setups (GNOME's, and Hyprland's) always show.
@@ -114,7 +114,7 @@ Item {
width: parent.width
// 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"
iconFallback: "bluetooth-symbolic"
label: deviceRow.modelData.name || deviceRow.modelData.address
@@ -29,7 +29,7 @@ Item {
property real backlightValue: 0
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
implicitHeight: rows.implicitHeight
@@ -89,7 +89,7 @@ Item {
// Hyprland already knows what each output is called, so the
// name comes from there rather than from a second source that
// could disagree with the Displays page. The connector is the
// fallback, so a display is never an unlabelled slider.
// fallback, so a display is never an unlabeled slider.
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
value: modelData.value / 100
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
@@ -113,7 +113,7 @@ Item {
anchors.leftMargin: 6
anchors.rightMargin: 6
anchors.top: parent.top
visible: root.labelled
visible: root.labeled
height: visible ? implicitHeight + 2 : 0
text: row.label
color: Theme.fgDim
@@ -124,7 +124,7 @@ Item {
// The slider and its glyph share one strip so the two stay aligned
// whether or not a caption sits above them. Anchoring the glyph to
// both a caption and a centre line instead would conflict, and an
// both a caption and a center line instead would conflict, and an
// anchor set to undefined is not released.
Item {
id: control
@@ -134,7 +134,7 @@ Item {
height: 32
// ValueSlider draws its own leading icon, but symbolic icons need
// recolouring to be visible — see ThemedIcon.
// recoloring to be visible — see ThemedIcon.
ThemedIcon {
id: glyph
anchors.left: parent.left
@@ -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
}
}
@@ -45,6 +45,9 @@ PanelWindow {
if (root.visible) {
KdeConnect.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();
} else {
panel.reset();
@@ -130,6 +130,21 @@ Item {
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 {
width: root.cellWidth
icon: "night-light-symbolic"
@@ -190,6 +205,38 @@ Item {
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 ─────────────────────────────────────────────────────────
AudioSlider {
width: content.width
@@ -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.
Row {
id: trailingRow
@@ -137,7 +137,18 @@ Item {
}
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 {
id: entry
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
ScrollColumn 1.0 ScrollColumn.qml
Section 1.0 Section.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
}
}
}
}
@@ -50,9 +50,16 @@ SettingsPage {
? Wallpaper.lastError
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperControls {
id: wallpaperControls
width: parent.width
}
WallpaperPicker {
id: wallpapers
width: parent.width
mode: wallpaperControls.mode
selectedOutput: wallpaperControls.selectedOutput
}
ActionRow {
@@ -76,12 +83,36 @@ SettingsPage {
}
SettingsCard {
title: "Colour scheme"
title: "Lock screen"
subtitle: LockScreen.lastError !== ""
? LockScreen.lastError
: "A representative preview of the screen shown before authentication."
LockScreenPreview {
width: parent.width
}
ChoiceRow { setting: "lockBackgroundMode" }
SliderRow { setting: "lockBlurLevel"; zeroLabel: "Off" }
ToggleRow { setting: "lockShowClock" }
ToggleRow { setting: "lockShowDate" }
ToggleRow { setting: "lockShowUser" }
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
}
SettingsCard {
title: "Color scheme"
subtitle: ColorScheme.lastError !== ""
? ColorScheme.lastError
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
ChoiceRow { setting: "colorScheme"; divider: false }
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 {
@@ -300,7 +331,10 @@ SettingsPage {
ToggleRow { setting: "showCpu" }
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.
ChoiceGrid {
@@ -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
}
}
@@ -32,7 +32,7 @@ SettingRow {
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
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.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
@@ -7,7 +7,7 @@
// because that genuinely is a Panama preference.
//
// Changing the timezone or network time needs privilege. timedatectl asks
// polkit, and a cancelled dialog surfaces as an error rather than as a value
// polkit, and a canceled dialog surfaces as an error rather than as a value
// that appears to have been accepted.
import QtQuick
@@ -33,7 +33,7 @@ SettingsPage {
SettingRow {
label: "Set automatically"
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"
controlWidth: 48
divider: false
@@ -50,7 +50,7 @@ Rectangle {
// Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page.
// Follows the colour scheme. A preview that stays dark while the shell
// 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
// screenshot of someone else's.
gradient: Gradient {
@@ -129,7 +129,7 @@ Rectangle {
required property bool focused
// The gradient border is drawn as a filled rounded rect with the window
// body inset on top of it: QML's Rectangle border takes a colour, not a
// 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.
Rectangle {
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
}
}
}
}
}
@@ -106,6 +106,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 {
visible: Displays.monitors.length > 1
title: "Connected display"
@@ -440,9 +440,9 @@ SettingsPage {
ActionRow {
objectName: "health-fedora-handoff:color"
label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners"
action: "Open colour"
label: "Color profiles"
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 color"
onTriggered: SystemSettings.openGnomePanel("color")
}
@@ -117,12 +117,6 @@ SettingsPage {
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid {
id: summaryCards
@@ -0,0 +1,152 @@
import Quickshell
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
property string backgroundMode: DesktopPreferences.get("lockBackgroundMode")
property int blurLevel: DesktopPreferences.get("lockBlurLevel")
property bool showClock: DesktopPreferences.get("lockShowClock")
property bool showDate: DesktopPreferences.get("lockShowDate")
property bool showUser: DesktopPreferences.get("lockShowUser")
property bool fadeOnEmpty: DesktopPreferences.get("lockFadeOnEmpty")
property bool use24Hour: DesktopPreferences.get("use24Hour")
property string wallpaperPath: Wallpaper.active !== ""
? Wallpaper.active
: (Wallpaper.configured !== "" ? Wallpaper.configured : Wallpaper.shippedPath)
readonly property string previewMode: root.backgroundMode
readonly property real blurStrength: Math.max(0, Math.min(1, root.blurLevel / 5))
readonly property bool wallpaperVisible: wallpaper.visible
readonly property bool clockVisible: clockLabel.visible
readonly property bool dateVisible: dateLabel.visible
readonly property bool userVisible: userLabel.visible
readonly property bool passwordVisible: passwordField.visible
width: parent ? parent.width : 620
implicitHeight: 230
radius: Theme.cardRadius
clip: true
color: Theme.bg
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.10)
Image {
id: wallpaper
anchors.fill: parent
visible: root.backgroundMode === "wallpaper"
source: visible && root.wallpaperPath !== "" ? root.wallpaperPath : ""
asynchronous: true
cache: true
fillMode: Image.PreserveAspectCrop
sourceSize.width: 960
sourceSize.height: 540
}
// Screenshot mode stays representative instead of taking a real desktop
// capture inside Settings. The layered window silhouettes communicate the
// selected softness without a live ShaderEffect or GPU repaint loop.
Rectangle {
anchors.fill: parent
visible: root.backgroundMode === "screenshot"
color: Theme.bgDark
Rectangle {
x: parent.width * 0.10
y: parent.height * 0.12
width: parent.width * 0.45
height: parent.height * 0.66
radius: 14 + root.blurStrength * 10
color: Theme.alpha(Theme.bgHighlight, 0.52 - root.blurStrength * 0.18)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.14)
}
Rectangle {
x: parent.width * 0.48
y: parent.height * 0.24
width: parent.width * 0.40
height: parent.height * 0.57
radius: 14 + root.blurStrength * 10
color: Theme.alpha(Theme.bgPanel, 0.64 - root.blurStrength * 0.20)
border.width: 1
border.color: Theme.alpha(Theme.accentSecondary, 0.13)
}
}
Rectangle {
anchors.fill: parent
color: root.backgroundMode === "wallpaper"
? Theme.alpha(Theme.bg, 0.42)
: Theme.alpha(Theme.bg, 0.12 + root.blurStrength * 0.16)
}
Text {
id: clockLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 26
visible: root.showClock
text: Qt.formatDateTime(clock.date, root.use24Hour ? "HH:mm" : "h:mm")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 42
font.weight: Font.Light
}
Text {
id: dateLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: clockLabel.visible ? clockLabel.bottom : parent.top
anchors.topMargin: clockLabel.visible ? 1 : 35
visible: root.showDate
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
id: passwordField
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: root.showUser ? 48 : 30
visible: !root.fadeOnEmpty
width: Math.min(270, parent.width * 0.48)
height: 36
radius: height / 2
color: Theme.alpha(Theme.bgPanel, 0.84)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.78)
Text {
anchors.centerIn: parent
text: "Password"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.italic: true
}
}
Text {
id: userLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 19
visible: root.showUser
text: Quickshell.env("USER") || "User"
color: Theme.alpha(Theme.fg, 0.90)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
}
@@ -20,8 +20,8 @@ SettingsPage {
title: "Mouse & Touchpad"
lede: InputDevices.hasTouchpad
? "Pointer behaviour for your mouse and touchpad."
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
? "Pointer behavior for your mouse and touchpad."
: "Pointer behavior. Touchpad settings appear when a touchpad is attached."
SettingsCard {
title: "Mouse"
@@ -52,7 +52,7 @@ SettingsPage {
}
SettingsCard {
title: "Idle behaviour"
title: "Idle behavior"
subtitle: IdleLock.managed
? "Idle timings are managed here. Changes take effect immediately."
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
@@ -1,7 +1,7 @@
# Settings
The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
The control center for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labeled as
such rather than half-reimplemented.
## System Health
@@ -28,7 +28,7 @@ package, runs `sudo`, deletes user data, or repairs a service Panama does not
own. A repair remains degraded until a fresh scan observes recovery.
The final card is the ownership boundary. Network configuration and the exact
Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME
Users, Sharing, Color profiles, and Digital wellbeing handoffs open GNOME
Settings because Fedora's system services own those areas.
## Adding a setting
@@ -59,6 +59,49 @@ If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
file.
## Setting ownership
Every preference has **one primary page**, derived from its schema `group` and
the route in `services/SettingsSearch.qml`. Search results always open that
owner. A control may appear on a second page only when the same adaptation is
part of another established mental model; otherwise use a labeled handoff to
the owner instead of duplicating it.
### Intentional mirrors
| Setting | Primary page | Mirror | Why the mirror earns its place |
|---|---|---|---|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. |
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
Lock-screen visuals belong only to **Appearance**: background source, blur,
clock, date, user name, and password-field presentation. **Power** owns when
the session locks, while **Privacy** keeps only the established timing mirrors
above. Visual controls must not be copied onto either page.
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
Those values form one safety transaction: every connected output
is applied, verified, confirmed, or restored together. Other pages may link to
Displays, but must never expose a second geometry control or persist a partial
layout.
Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
Window border color follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
**accent role**, driven by the chosen `accentName` and also owned by
`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
`ColorScheme.qml` restates the focused border alongside the inactive one on
every scheme change, rather than leaving a scheme flip to erase a
user-selected accent.
## The rows
| Component | For |
@@ -122,9 +165,10 @@ file with `qs -p`, and discover its PID from the exact Config path in
| File | Holds |
|---|---|
| `~/.config/panama/settings.json` | Everything in the schema. Read by the shell *and* by `hypr/prefs.lua` |
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favorites and aliases |
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
| `$XDG_STATE_HOME/panama/hyprlock.conf` | Generated lock-screen appearance |
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
@@ -19,7 +19,7 @@ FocusScope {
id: root
signal captured(string chord)
signal cancelled
signal canceled
property string message: ""
@@ -97,7 +97,7 @@ FocusScope {
event.accepted = true;
if (event.key === 0x01000000) { // Escape
root.cancelled();
root.canceled();
return;
}
if (root.isModifierOnly(event.key))
@@ -127,6 +127,6 @@ FocusScope {
onActiveFocusChanged: {
if (!activeFocus)
root.cancelled();
root.canceled();
}
}
@@ -7,7 +7,7 @@
// description in hypr/keybinds.lua, and this page just groups and renders them.
//
// The hardware settings above the list are real controls. Keyboard layout,
// repeat behaviour, and pointer response are Hyprland's, so Panama owns them;
// repeat behavior, and pointer response are Hyprland's, so Panama owns them;
// device-specific configuration stays with GNOME.
import QtQuick
@@ -174,7 +174,7 @@ SettingsPage {
Keybinds.rebind(bindRow.modelData.luaChord, chord);
root.capturingChord = "";
}
onCancelled: root.capturingChord = ""
onCanceled: root.capturingChord = ""
}
Row {
@@ -4,7 +4,7 @@
//
// Range, step, label, explanation, and unit all come from PreferenceSchema.
//
// Three behaviours worth knowing:
// Three behaviors worth knowing:
//
// * The row is responsive. The Settings window is a normal tiled window, so
// its width is whatever the layout gives it -- anywhere from a half-screen
@@ -51,9 +51,11 @@ Item {
return typeof value === "number" ? value : root.minimum;
}
// Shown while dragging; -1 means "nothing pending, show what is stored".
property real pending: -1
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
// Shown while dragging; null means "nothing pending, show what is stored".
// Not -1: several schema entries (e.g. pointerSensitivity) are legitimately
// negative, and -1 would be indistinguishable from a real committed value.
property var pending: null
readonly property real shown: root.pending !== null ? root.pending : root.stored
// Below this the label and a usable slider cannot share a line without one
// of them becoming useless.
@@ -61,7 +63,7 @@ Item {
// A fixed trailing width rather than a share of the row. A proportional
// control looks reasonable at 900px and absurd at 4500px, where the slider
// would be a metre long next to a two-word label -- and this window is
// would be a meter long next to a two-word label -- and this window is
// tiled, so it really can be that wide.
readonly property int controlSpan: 300
@@ -176,7 +178,7 @@ Item {
id: commitTimer
interval: 140
onTriggered: {
if (root.pending < 0)
if (root.pending === null)
return;
SystemSettings.commitPreference(root.setting, root.pending);
// Hand the display back to the stored value. If the write was
@@ -188,6 +190,6 @@ Item {
Timer {
id: releaseTimer
interval: 160
onTriggered: root.pending = -1
onTriggered: root.pending = null
}
}
@@ -31,6 +31,15 @@ SettingsPage {
}
}
SettingsCard {
title: "Applications"
subtitle: "Control each application currently playing through PipeWire."
ApplicationMixer {
width: parent.width
}
}
SettingsCard {
title: "Sound feedback"
subtitle: "Use the same event preferences as GTK and GNOME applications."
@@ -67,8 +76,8 @@ SettingsPage {
title: "Advanced sound"
ActionRow {
label: "Application volumes and profiles"
detail: "Open Fedora's complete sound panel"
label: "Device profiles"
detail: "Open Fedora's complete device profile panel"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("sound")
@@ -8,7 +8,7 @@
// setting it edits.
//
// Rejected input is shown as rejected rather than silently dropped or quietly
// sanitised. Several of these strings are serialised into an hl.config payload,
// sanitised. Several of these strings are serialized into an hl.config payload,
// where stripping an unexpected character would turn a typo into a different
// working setting -- so the schema's pattern decides, the field turns red when
// it fails, and nothing is committed until it passes.
@@ -4,7 +4,7 @@
// clock. This is the same control with the readout formatted as a time, so the
// night light schedule says "5:30 PM" rather than a number you have to convert.
//
// Honours the 24-hour clock preference, because a user who has asked for 18:30
// Honors the 24-hour clock preference, because a user who has asked for 18:30
// everywhere else should not be shown 6:30 PM here.
import QtQuick
@@ -0,0 +1,103 @@
import QtQuick
import qs.config
import qs.services
import qs.widgets
Column {
id: root
property string mode: DesktopPreferences.get("wallpaperMode")
property var outputs: Wallpaper.outputNames()
property string selectedOutput: root.outputs.length > 0 ? root.outputs[0] : ""
property var setModeAction: function(mode) { Wallpaper.setMode(mode); }
property var setIntervalAction: function(minutes) { Wallpaper.setIntervalMinutes(minutes); }
property var setShuffleAction: function(enabled) { Wallpaper.setShuffle(enabled); }
width: parent ? parent.width : 620
spacing: 4
ChoiceGrid {
width: parent.width
label: "Wallpaper mode"
detail: "Use one image, rotate a collection, or choose per display"
options: PreferenceSchema.spec("wallpaperMode").options
current: root.mode
onPicked: value => root.setModeAction(value)
}
ChoiceGrid {
visible: root.mode === "per-monitor"
width: parent.width
label: "Display"
detail: "Choose which display the thumbnail grid assigns"
options: root.outputs.map(output => ({ value: output, label: output }))
current: root.selectedOutput
onPicked: value => root.selectedOutput = value
}
SettingRow {
visible: root.mode === "slideshow"
label: "Change background every"
detail: "Time between slideshow images"
controlWidth: 280
Item {
anchors.fill: parent
ValueSlider {
anchors.left: parent.left
anchors.right: intervalText.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
value: (DesktopPreferences.get("wallpaperIntervalMinutes") - 5) / 1435
onMoved: ratio => {
const raw = 5 + ratio * 1435;
root.setIntervalAction(Math.max(5, Math.min(1440, Math.round(raw / 5) * 5)));
}
}
Text {
id: intervalText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 62
text: DesktopPreferences.get("wallpaperIntervalMinutes") + " min"
color: Theme.fgDim
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
}
}
}
SettingRow {
visible: root.mode === "slideshow"
label: "Shuffle"
detail: "Show every selected image before repeating"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: DesktopPreferences.get("wallpaperShuffle") === true
onToggled: enabled => root.setShuffleAction(enabled)
}
}
Text {
width: parent.width
visible: root.mode === "slideshow"
&& (DesktopPreferences.get("wallpaperSlideshowPaths") ?? []).length === 0
text: "Select two or more images below to begin a slideshow."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
topPadding: 5
bottomPadding: 8
}
}
@@ -24,6 +24,14 @@ Item {
// unreachable without a long scroll past pictures. Two rows by default,
// all of them on request.
property bool expanded: false
property string mode: DesktopPreferences.get("wallpaperMode")
property string selectedOutput: Wallpaper.outputNames()[0] ?? ""
property var activeByOutput: Wallpaper.activeByOutput
property var slideshowPaths: DesktopPreferences.get("wallpaperSlideshowPaths") ?? []
property var assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({})
property var setSingleAction: function(path) { Wallpaper.setSingle(path); }
property var toggleSlideshowAction: function(path) { Wallpaper.toggleSlideshowPath(path); }
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
readonly property int collapsedRows: 2
readonly property var shown: root.expanded
@@ -35,6 +43,27 @@ Item {
readonly property int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
function isCurrent(path: string): bool {
return root.activeByOutput[root.selectedOutput] === path;
}
function selected(path: string): bool {
if (root.mode === "slideshow")
return root.slideshowPaths.includes(path);
if (root.mode === "per-monitor")
return root.assignments[root.selectedOutput] === path;
return root.isCurrent(path);
}
function activate(path: string): void {
if (root.mode === "slideshow")
root.toggleSlideshowAction(path);
else if (root.mode === "per-monitor")
root.setAssignmentAction(root.selectedOutput, path);
else
root.setSingleAction(path);
}
Grid {
id: grid
@@ -50,7 +79,8 @@ Item {
required property var modelData
readonly property bool current: Wallpaper.active === tile.modelData
readonly property bool current: root.isCurrent(tile.modelData)
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
width: root.cellWidth
height: Math.round(root.cellWidth * 9 / 16)
@@ -114,6 +144,28 @@ Item {
border.color: Theme.accent
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
width: 24
height: 24
radius: 12
visible: tile.member
color: Theme.alpha(Theme.bgDark, 0.82)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.16)
Text {
anchors.centerIn: parent
text: "✓"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 14
font.weight: Font.DemiBold
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
@@ -132,7 +184,9 @@ Item {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 7
text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData)
text: tile.current
? "Current wallpaper"
: (tile.member ? "In slideshow" : Wallpaper.titleFor(tile.modelData))
color: tile.current ? Theme.accent : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -144,7 +198,7 @@ Item {
HoverHandler { id: hover }
TapHandler {
onTapped: Wallpaper.set(tile.modelData)
onTapped: root.activate(tile.modelData)
}
}
}
@@ -29,10 +29,12 @@ ChoiceRow 1.0 ChoiceRow.qml
ActionRow 1.0 ActionRow.qml
TextRow 1.0 TextRow.qml
DesktopPreview 1.0 DesktopPreview.qml
LockScreenPreview 1.0 LockScreenPreview.qml
PowerPage 1.0 PowerPage.qml
DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml
WallpaperControls 1.0 WallpaperControls.qml
ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsEditor 1.0 DockPinsEditor.qml
@@ -40,12 +42,16 @@ DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.qml
DisplayArrangement 1.0 DisplayArrangement.qml
DisplayIdentify 1.0 DisplayIdentify.qml
WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml
PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml
SoundDeviceRow 1.0 SoundDeviceRow.qml
ApplicationMixer 1.0 ApplicationMixer.qml
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
@@ -55,3 +61,4 @@ PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
AccentPicker 1.0 AccentPicker.qml
@@ -0,0 +1,129 @@
// The Alt-Tab overlay.
//
// Deliberately a list of names rather than thumbnails: at a glance you are
// looking for "the other terminal", and a row of small live previews is both
// slower to read and considerably more expensive to draw than the gesture
// deserves. The dock already renders app identity this way, so the two agree.
//
// Only present while a switch is in progress -- there is nothing to keep alive
// between gestures, and a hidden always-loaded overlay is a surface that can
// go wrong while nobody is looking at it.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
Loader {
id: root
// Plain `modelData`, not `required property var screen`. Variants supplies
// modelData, and shell.qml's own comment warns about exactly this: declaring
// `required property var screen` means the screen never resolves, the window
// is constructed and silently never maps, and NOTHING is logged. Bar and
// Dock both take the screen this way.
property var modelData: null
active: WindowSwitcherState.open
asynchronous: false
sourceComponent: PanelWindow {
screen: root.modelData
// Overlay so it sits above the focused window it is describing.
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-switcher"
// Nothing here is clickable: the gesture is driven entirely from the
// keyboard, and taking input would steal focus from the compositor
// mid-switch, which is the one thing that would break it.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
Rectangle {
anchors.centerIn: parent
width: Math.min(560, parent.width - 96)
implicitHeight: layout.implicitHeight + 24
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgPopover, 0.97)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
Column {
id: layout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 2
Repeater {
model: WindowSwitcherState.windows
Rectangle {
id: row
required property var modelData
required property int index
readonly property bool current: row.index === WindowSwitcherState.index
readonly property string appId: row.modelData?.wayland?.appId ?? ""
readonly property var entry: DesktopEntries.heuristicLookup(row.appId)
width: parent.width
height: 44
radius: 10
border.width: 0
color: row.current ? Theme.alpha(Theme.accent, 0.20) : "transparent"
Image {
id: icon
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
sourceSize.width: 24
sourceSize.height: 24
source: row.entry?.icon ? Quickshell.iconPath(row.entry.icon, true) : ""
visible: source !== ""
}
Text {
anchors.left: icon.visible ? icon.right : parent.left
anchors.leftMargin: icon.visible ? 12 : 14
anchors.right: appName.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
// A window with no title yet is still a window you
// can switch to; naming it after its application is
// better than an empty row.
text: row.modelData?.title || row.entry?.name || row.appId
elide: Text.ElideRight
color: row.current ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: row.current ? Font.DemiBold : Font.Normal
}
Text {
id: appName
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
visible: (row.entry?.name ?? "") !== "" && row.entry.name !== row.modelData?.title
text: row.entry?.name ?? ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
}
}
@@ -255,7 +255,11 @@ def watch(start: int, end: int) -> int:
return 1
loop = GLib.MainLoop()
try:
registry = EDataServer.SourceRegistry.new_sync(None)
except Exception:
_write_snapshot(_unavailable_snapshot())
return 1
debounce_source = 0
subscriptions: list[int] = []
+99 -10
View File
@@ -61,6 +61,16 @@ class DoctorConfig:
runtime_dir: Path
path: str
timeout: float
# Defaults to this file's own directory, where its sibling helpers
# (panama-action, panama-brightness, calendar-agenda) actually live.
scripts_dir: Path = Path(__file__).resolve().parent
# Repair actions (service restarts, the Quickshell restart-shell action)
# can legitimately run longer than a quick health-check probe -- a
# Quickshell restart alone waits for the old process to exit, the new one
# to start, and settle. Reusing `timeout` here would kill a slow-but-
# successful repair and report it as failed even though a following
# health scan would show everything recovered. See run_repair_command.
repair_timeout: float = 15.0
@property
def command_env(self) -> dict[str, str]:
@@ -102,7 +112,7 @@ class RepairResult:
CHECK_ORDER = (
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
"desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire",
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
@@ -114,8 +124,13 @@ SYSTEMCTL_COMMANDS = {
"hypridle": ("systemctl", "--user", "is-active", "--quiet", "hypridle.service"),
"vicinae": ("systemctl", "--user", "is-active", "--quiet", "vicinae.service"),
"pipewire": ("systemctl", "--user", "is-active", "--quiet", "pipewire.service"),
"nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"),
"rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.service"),
# System-scope, not --user: RustDesk ships an enabled *system* service
# (`rustdesk --service`, root-owned) that spawns the session --server and
# --tray on its own -- see autostart.lua's comment on why Panama doesn't
# start it a second time. Querying --user here always reports inactive,
# since no such user-scope unit exists, regardless of whether the real
# service is running.
"rustdesk": ("systemctl", "is-active", "--quiet", "rustdesk.service"),
}
REPAIR_COMMANDS = MappingProxyType({
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
@@ -159,6 +174,7 @@ CHECK_TITLES = {
"desktop.portals": "Desktop portals",
"desktop.hyprpaper": "Hyprpaper",
"desktop.hypridle": "Hypridle",
"desktop.hyprlock": "Lock screen",
"desktop.vicinae": "Vicinae",
"input.pipewire": "PipeWire",
"input.clipboard": "Clipboard",
@@ -192,11 +208,25 @@ def config_from_environment() -> DoctorConfig:
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
# Sibling helpers (panama-action, panama-brightness, calendar-agenda) live
# next to this file. PATH is not a reliable way to find them -- nothing in
# this repository puts the Quickshell scripts directory on PATH -- so they
# are invoked by resolved path instead, the same way check_hyprlock already
# resolves panama-lock.
scripts_dir = environment_path("PANAMA_DOCTOR_SCRIPTS_DIR", Path(__file__).resolve().parent)
try:
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
except ValueError:
timeout = 3.0
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
timeout = max(0.05, min(timeout, 15.0))
try:
repair_timeout = float(os.environ.get("PANAMA_DOCTOR_REPAIR_TIMEOUT", "15"))
except ValueError:
repair_timeout = 15.0
# Never shorter than the probe timeout, and bounded so a hung repair still
# gives up rather than blocking the caller indefinitely.
repair_timeout = max(timeout, min(repair_timeout, 30.0))
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), timeout, scripts_dir, repair_timeout)
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
@@ -221,7 +251,7 @@ def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path
command,
capture_output=True,
text=True,
timeout=config.timeout,
timeout=config.repair_timeout,
check=False,
env=config.command_env,
cwd=cwd,
@@ -273,6 +303,16 @@ def service_check(check_id: str, title: str, service: str, config: DoctorConfig,
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
def process_check(check_id: str, title: str, process: str, config: DoctorConfig, action: Action | None = None) -> Check:
"""Like service_check, for autostarted apps with no systemd unit behind them."""
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", process), config)
if result.state == "ok":
return Check(check_id, group_for(check_id), title, "ok", "Process is running.")
if result.state in {"missing", "unavailable"}:
return Check(check_id, group_for(check_id), title, "error", "Required process probe is unavailable.")
return Check(check_id, group_for(check_id), title, "warning", "Process is not running.", action)
def ipc_target(config: DoctorConfig, target: str) -> CommandResult:
result = run_command(("qs", "ipc", "show"), config)
if result.state != "ok":
@@ -320,8 +360,43 @@ def check_portals(config: DoctorConfig) -> Check:
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
def check_hyprlock(config: DoctorConfig) -> Check:
helper = config.root / "config/dot/quickshell/scripts/panama-lock"
tracked_fallback = config.config_home / "hypr/hyprlock.conf"
generated_config = config.state_home / "panama/hyprlock.conf"
# The doctor seals PATH for every probe. Invoke the authored helper with a
# fixed system-only PATH so its bash shebang and jq dependency remain
# available without inheriting arbitrary parent executables.
result = run_command(("/usr/bin/env", "PATH=/usr/bin:/bin", str(helper), "status"), config)
if result.state != "ok":
if tracked_fallback.is_file():
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is unavailable; the tracked fallback remains available.")
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
try:
state = json.loads(result.stdout)
generated = state["generated"]
path = state["path"]
fallback = state["fallback"]
error = state["error"]
if not isinstance(generated, bool) or not isinstance(path, str) \
or not isinstance(fallback, bool) or not isinstance(error, str):
raise ValueError
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
if tracked_fallback.is_file():
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is invalid; the tracked fallback remains available.")
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
if generated and not fallback and generated_config.is_file():
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "ok", "Managed lock-screen configuration is available.")
if tracked_fallback.is_file():
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "The tracked lock-screen fallback is in use.")
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
def check_brightness(config: DoctorConfig) -> Check:
result = run_command(("panama-brightness", "list"), config)
result = run_command((str(config.scripts_dir / "panama-brightness"), "list"), config)
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
if result.state == "timeout":
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
@@ -342,9 +417,18 @@ def check_brightness(config: DoctorConfig) -> Check:
def check_nextcloud(config: DoctorConfig) -> Check:
if not (config.config_home / "autostart" / "nextcloud.desktop").is_file():
# The client's autostart entry is Title-cased ("Nextcloud.desktop") on
# current nextcloud-client packages, but that has changed case before --
# match either so a future package update doesn't silently reintroduce
# this as "autostart is not configured" again.
autostart_dir = config.config_home / "autostart"
if not any(autostart_dir.glob("[Nn]extcloud.desktop")):
return Check("integration.nextcloud", "integrations", "Nextcloud", "unconfigured", "Nextcloud autostart is not configured.")
return service_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud"))
# No systemd unit backs this (see the autostart.lua comment on why Panama
# doesn't start it as one): the client is a plain autostarted process, so
# check for the process directly instead of a systemctl service that was
# never going to exist.
return process_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud"))
def check_rustdesk(config: DoctorConfig) -> Check:
@@ -382,7 +466,7 @@ def check_home_assistant(config: DoctorConfig) -> Check:
def check_calendar(config: DoctorConfig) -> Check:
result = run_command(("calendar-agenda", "probe"), config)
result = run_command((str(config.scripts_dir / "calendar-agenda"), "probe"), config)
action = Action("open", "Open Date & Time", target="datetime")
if result.state == "missing":
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
@@ -514,7 +598,7 @@ def unavailable_versions() -> list[dict[str, str]]:
def collect_checks(config: DoctorConfig) -> list[Check]:
probes: dict[str, Callable[[], Check]] = {
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
@@ -547,6 +631,11 @@ def snapshot(config: DoctorConfig) -> dict[str, object]:
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
command = REPAIR_COMMANDS[check_id]
if check_id == "desktop.quickshell":
# panama-action is a sibling helper script, not a PATH-resolved
# executable; see check_brightness and check_calendar for the same
# resolution against the same bug.
command = (str(config.scripts_dir / command[0]), *command[1:])
exit_code, _ = run_repair_command(command, config)
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
else "The authored repair command could not be completed."
+16 -3
View File
@@ -25,6 +25,14 @@ generated="$state_dir/hypridle.conf"
dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d"
dropin="$dropin_dir/panama.conf"
# Set by generate() to the mktemp path it is currently writing, so concurrent
# invocations (e.g. rapid settings changes each spawning `apply`) never share
# a tmp file and interleave writes into a corrupt hypridle.conf. Cleared once
# the atomic mv below lands, so this is a no-op on a normal exit.
generated_tmp=""
cleanup() { rm -f "$generated_tmp" 2>/dev/null || true; }
trap cleanup EXIT
read_setting() {
local key="$1" fallback="$2"
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
@@ -53,13 +61,17 @@ generate() {
load
mkdir -p "$state_dir"
# Unique per invocation, in the same directory as the destination so the
# final mv is an atomic same-filesystem rename rather than a copy.
generated_tmp="$(mktemp "$generated.XXXXXX")"
{
printf '# Generated by panama-idle from %s\n' "$settings"
printf '# Do not edit: it is rewritten whenever the idle settings change.\n'
printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n'
printf 'general {\n'
printf ' lock_cmd = pidof hyprlock || hyprlock\n'
printf ' lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run\n'
if [[ "$lock_on_sleep" == "true" ]]; then
printf ' before_sleep_cmd = loginctl lock-session\n'
fi
@@ -91,9 +103,10 @@ generate() {
printf ' on-timeout = systemctl suspend\n'
printf '}\n'
fi
} >"$generated.tmp"
} >"$generated_tmp"
mv "$generated.tmp" "$generated"
mv "$generated_tmp" "$generated"
generated_tmp=""
}
install_dropin() {
+61 -11
View File
@@ -39,13 +39,36 @@ import re
import sys
def daemon_origin():
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
def secrets_name_owner_pid():
"""PID currently owning the org.freedesktop.secrets D-Bus name, if any.
A D-Bus-activated daemon is the signature of the crash-and-replace case
above: it is the one that cannot have the login password. PAM's daemon lives
outside the app slice, so the cgroup tells the two apart.
This is the only reliable way to identify which daemon actually answers
Secret Service calls right now.
"""
try:
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
result = bus.call_sync(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
"GetConnectionUnixProcessID",
GLib.Variant("(s)", ("org.freedesktop.secrets",)),
GLib.VariantType("(u)"),
Gio.DBusCallFlags.NONE,
-1,
None,
)
return result.unpack()[0]
except Exception: # noqa: BLE001 - no name owner is a legitimate state
return None
def any_keyring_daemon_running():
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
@@ -55,19 +78,46 @@ def daemon_origin():
cmdline = handle.read().decode("utf-8", "replace")
except OSError:
continue
if "gnome-keyring-daemon" not in cmdline:
continue
if "gnome-keyring-daemon" in cmdline:
return True
except OSError:
pass
return False
def daemon_origin():
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
A D-Bus-activated daemon is the signature of the crash-and-replace case
above: it is the one that cannot have the login password. PAM's daemon lives
outside the app slice, so the cgroup tells the two apart.
A machine can have two gnome-keyring-daemon processes at once -- a
lingering PAM one alongside its D-Bus-activated replacement -- so which
process this reports on matters: it must be the one that actually owns
org.freedesktop.secrets right now, not merely the first one /proc happens
to enumerate.
"""
owner_pid = secrets_name_owner_pid()
if owner_pid is None:
return "unknown" if any_keyring_daemon_running() else "none"
try:
with open(f"/proc/{pid}/cgroup", "r") as handle:
with open(f"/proc/{owner_pid}/cmdline", "rb") as handle:
cmdline = handle.read().decode("utf-8", "replace")
except OSError:
return "unknown"
if "gnome-keyring-daemon" not in cmdline:
return "unknown"
try:
with open(f"/proc/{owner_pid}/cgroup", "r") as handle:
cgroup = handle.read()
except OSError:
return "unknown"
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
return "dbus"
return "pam"
except OSError:
pass
return "none"
def load_service():

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