Author SHA1 Message Date
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
152 changed files with 1621 additions and 1084 deletions
+2 -2
View File
@@ -14,10 +14,10 @@
__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
+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
+1 -1
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)
+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")
+2 -2
View File
@@ -3,9 +3,9 @@
#
# GENERATED FILE. Edit hyprlock.conf.template and re-run
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
# next colour scheme change.
# next color scheme change.
#
# The colours here follow the desktop's light/dark setting. They used to be
# 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.
#
+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),
+24 -10
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,7 +192,10 @@ 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), now with an overlay
@@ -223,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" })
@@ -245,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
+39 -11
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,12 +46,14 @@ hl.config({
border_size = prefs.get("borderSize", 2),
col = {
-- The focused accent role: blue leads, orchid follows, on a
-- diagonal so the pair is visible on both a tall and a wide
-- window. ColorScheme never writes this role; a future accent
-- picker can own it without fighting light/dark mode.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- The neutral inactive role follows the colour scheme because a
-- 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.
@@ -104,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)",
},
@@ -165,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
+2 -2
View File
@@ -25,7 +25,7 @@ local prefs = require("prefs")
-- 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.
@@ -147,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
+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 }}</>'
@@ -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",
@@ -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.
//
@@ -852,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.
{
@@ -871,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
@@ -978,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.
{
@@ -1041,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.
{
@@ -1057,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,
@@ -1192,7 +1212,7 @@ Singleton {
// ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
// hypr/monitors.lua on top of the shipped values. Colour management and
// hypr/monitors.lua on top of the shipped values. Color management and
// bit depth are deliberately not here: those carry a documented
// screencopy tradeoff that a settings page cannot explain at the moment
// you would be changing it.
@@ -1305,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
+1 -1
View File
@@ -83,7 +83,7 @@ ShellRoot {
}
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
const modes = Displays.normalizeModes([
"[email protected]",
"[email protected]"
]);
+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
@@ -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
@@ -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
}
}
}
}
}
+2 -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.
@@ -35,7 +35,7 @@ Pill {
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
@@ -62,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
@@ -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
}
}
}
}
@@ -101,12 +101,18 @@ SettingsPage {
}
SettingsCard {
title: "Colour scheme"
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 {
@@ -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
@@ -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")
}
@@ -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
@@ -64,7 +64,7 @@ file.
Every preference has **one primary page**, derived from its schema `group` and
the route in `services/SettingsSearch.qml`. Search results always open that
owner. A control may appear on a second page only when the same adaptation is
part of another established mental model; otherwise use a labelled handoff to
part of another established mental model; otherwise use a labeled handoff to
the owner instead of duplicating it.
### Intentional mirrors
@@ -72,7 +72,7 @@ the owner instead of duplicating it.
| Setting | Primary page | Mirror | Why the mirror earns its place |
|---|---|---|---|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
| `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. |
@@ -93,12 +93,14 @@ Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
Window border colour follows the same ownership rule. The inactive border is a
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 owned by the visual theme (and, eventually, an accent picker).
`ColorScheme.qml` must never write the focused border, so changing schemes
cannot erase a user-selected accent.
**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
@@ -163,7 +165,7 @@ 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 |
@@ -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
}
}
@@ -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
@@ -61,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
@@ -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] = []
+61 -8
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]:
@@ -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"),
@@ -193,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:
@@ -222,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,
@@ -274,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":
@@ -357,7 +396,7 @@ def check_hyprlock(config: DoctorConfig) -> Check:
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)
@@ -378,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:
@@ -418,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.")
@@ -583,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."
+15 -2
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,6 +61,10 @@ 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'
@@ -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():
+42 -7
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# Generates Panama's hyprlock configuration into the state directory. The
# tracked config is never rewritten and remains the fallback if generation
# fails, so a malformed preference can never leave the session without a
# working locker.
# gitignored fallback config (rendered from the tracked .template at link
# time) is never rewritten by this script and remains the fallback if
# generation fails, so a malformed preference can never leave the session
# without a working locker.
set -euo pipefail
@@ -78,6 +79,31 @@ valid_path() {
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
}
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: the lock screen shows a flat focus ring, not the
# two-stop gradient the window border does. Falls back to blue for an unknown
# name, matching Theme.accentPair's own fallback.
accent_hex() {
local name="$1" scheme="$2"
case "$name" in
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
esac
}
# hyprlock wants "R, G, B" decimal, not hex.
hex_to_rgb() {
local hex="$1"
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
load_preferences() {
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
settings_valid=true
@@ -99,6 +125,12 @@ load_preferences() {
color_scheme="$(read_string colorScheme dark)"
[[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark
accent_name="$(read_string accentName blue)"
case "$accent_name" in
blue|orchid|teal|green|amber|orange|rose|slate) ;;
*) accent_name=blue ;;
esac
wallpaper_mode="$(read_string wallpaperMode single)"
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|| wallpaper_mode=single
@@ -121,8 +153,6 @@ load_preferences() {
background_color='rgba(225, 226, 231, 1.0)'
foreground_color='rgba(55, 96, 191, 1.0)'
dim_color='rgba(97, 114, 176, 1.0)'
accent_color='rgba(46, 125, 233, 1.0)'
accent_ring_color='rgba(46, 125, 233, 0.9)'
error_color='rgba(245, 42, 101, 1.0)'
field_color='rgba(208, 213, 227, 0.85)'
dim_hex='6172b0'
@@ -131,13 +161,18 @@ load_preferences() {
background_color='rgba(34, 36, 54, 1.0)'
foreground_color='rgba(200, 211, 245, 1.0)'
dim_color='rgba(130, 139, 184, 1.0)'
accent_color='rgba(130, 170, 255, 1.0)'
accent_ring_color='rgba(130, 170, 255, 0.9)'
error_color='rgba(255, 117, 127, 1.0)'
field_color='rgba(46, 47, 61, 0.85)'
dim_hex='828bb8'
error_hex='ff757f'
fi
# The focus ring is the accent role, not a scheme-relative one: it follows
# accentName the same way ColorScheme.qml's active_border does, and needs
# both the accent and the scheme since each accent carries a separate pair.
accent_rgb="$(hex_to_rgb "$(accent_hex "$accent_name" "$color_scheme")")"
accent_color="rgba($accent_rgb, 1.0)"
accent_ring_color="rgba($accent_rgb, 0.9)"
}
monitor_names() {
+1 -1
View File
@@ -71,7 +71,7 @@ adjust_microphone() {
brightness_percent() {
local output="$1" percent
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
percent="$(awk -F, 'NR == 1 { value=$4; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$percent"
}
@@ -67,15 +67,19 @@ cmd_list() {
}
cmd_set() {
local profile="${1:-}"
local profile="${1:-}" status
# Constrained rather than passed through: this reaches a system service.
[[ "$profile" =~ ^[a-z-]+$ ]] || {
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
return 2
}
# pipefail is set above, so $? here is busctl's real exit status, not
# head's -- a failed write (daemon stopped, profile rejected) must be
# reported rather than always claimed as a success.
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
| head -2 >&2
return 0
status=$?
return "$status"
}
case "${1:-list}" in
@@ -135,7 +135,7 @@ def valid_home(value: Any) -> bool:
def validate_home(value: Any, label: str) -> dict[str, Any]:
if not valid_home(value):
fail(f"{label} does not contain valid Home favourites.")
fail(f"{label} does not contain valid Home favorites.")
return value
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Propagates the colour scheme to applications that do not read the desktop
# Propagates the color scheme to applications that do not read the desktop
# portal.
#
# Most modern applications DO follow org.freedesktop.portal.Settings and need
@@ -16,21 +16,61 @@
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
# scheme in light mode, so it is generated from a template here instead.
#
# panama-theme-apps dark|light
# panama-theme-apps dark|light [accent]
#
# kitty gets it twice: the generated include file so terminals opened later
# start correct, and a live `set-colors` over its control socket so terminals
# already open change now. Without the second, a scheme change appears to do
# nothing until you open a new window.
#
# kitty's active_border_color and hyprlock's focus ring are also the accent
# role, not just the scheme -- see accent_hex() below, which mirrors the same
# lookup hypr/looks.lua and scripts/panama-lock carry for the same reason.
set -euo pipefail
scheme="${1:-dark}"
case "$scheme" in
dark|light) ;;
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
*) printf 'usage: panama-theme-apps [dark|light] [accent]\n' >&2; exit 2 ;;
esac
# Falls back to blue for an unknown or omitted name, matching Theme.qml's own
# accentPair fallback -- so a caller that only ever knew about scheme (a stale
# ColorScheme.qml, a manual invocation) still gets a real accent instead of an
# empty string reaching sed.
accent="${2:-blue}"
case "$accent" in
blue|orchid|teal|green|amber|orange|rose|slate) ;;
*) accent=blue ;;
esac
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: both consumers below draw a flat color, not the
# two-stop gradient the window border does.
accent_hex() {
local name="$1" scheme="$2"
case "$name" in
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
esac
}
# hyprlock wants "R, G, B" decimal, not hex.
hex_to_rgb() {
local hex="$1"
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
accent_border_hex="$(accent_hex "$accent" "$scheme")"
# ── hyprlock ─────────────────────────────────────────────────────────────────
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
# hyprlock`), so it reads this file each time and needs no restart.
@@ -45,7 +85,6 @@ lock_template="$lock_dir/hyprlock.conf.template"
if [[ "$scheme" == "light" ]]; then
lock_fg="55, 96, 191" # #3760bf
lock_muted="97, 114, 176" # #6172b0
lock_accent="46, 125, 233" # #2e7de9
lock_error="245, 42, 101" # #f52a65
lock_bg="225, 226, 231" # #e1e2e7
lock_field="208, 213, 227" # #d0d5e3
@@ -54,7 +93,6 @@ if [[ "$scheme" == "light" ]]; then
else
lock_fg="200, 211, 245" # #c8d3f5
lock_muted="130, 139, 184" # #828bb8
lock_accent="130, 170, 255" # #82aaff
lock_error="255, 117, 127" # #ff757f
lock_bg="34, 36, 54" # #222436
lock_field="46, 47, 61" # #2e2f3d
@@ -62,11 +100,15 @@ else
lock_error_hex="ff757f"
fi
# The focus ring is the accent role, not a scheme-relative one -- follows
# accentName the same way scripts/panama-lock's own generator does.
lock_accent="$(hex_to_rgb "$accent_border_hex")"
status_hyprlock="skipped"
if [[ -r "$lock_template" ]]; then
# Written atomically: a lock triggered mid-write would otherwise read a
# truncated config and fall back to hyprlock's own defaults, which is a
# bright grey screen with none of this desktop's identity.
# bright gray screen with none of this desktop's identity.
if sed -e "s/@FG@/$lock_fg/g" \
-e "s/@MUTED@/$lock_muted/g" \
-e "s/@ACCENT@/$lock_accent/g" \
@@ -117,7 +159,7 @@ fi
# it on the next write.
#
# btop reads its theme once at startup, so a running instance keeps the old
# colours until it is restarted. That is acceptable for a monitor you open when
# colors until it is restarted. That is acceptable for a monitor you open when
# you want it, and forcing a restart would kill a process the user is watching.
btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
btop_theme="tokyonight-moon"
@@ -169,9 +211,16 @@ theme_file="$kitty_dir/themes/tokyonight-moon.conf"
status_kitty="skipped"
if [[ -r "$theme_file" ]]; then
# Written atomically: kitty may read this while a new window is starting.
# The accent override is appended after the copied theme rather than
# edited into the tracked theme file: kitty applies settings top to
# bottom, so a later active_border_color line wins, and this file is
# itself generated -- the tracked per-scheme theme stays scheme-only.
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
&& printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
status_kitty="written"
else
rm -f "$kitty_dir/current-theme.conf.tmp"
fi
# Live-apply to running terminals. kitty appends its PID to the socket name
@@ -182,7 +231,8 @@ if [[ -r "$theme_file" ]]; then
applied=0
while read -r socket; do
[[ -n "$socket" ]] || continue
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1 \
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then
applied=$((applied + 1))
fi
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
+35 -10
View File
@@ -26,6 +26,16 @@
set -uo pipefail
# The payload file (created in cmd_qr) is tracked at script scope so it can be
# removed no matter how the script exits -- success, an emit_error exit 0, or a
# signal -- rather than only on a clean function return.
payload_file=""
cleanup_payload() {
[[ -n $payload_file ]] && rm -f -- "$payload_file"
}
trap cleanup_payload EXIT
emit_error() {
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
@@ -35,22 +45,33 @@ command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
cmd_list() {
local rows=() name ssid psk
local rows=() name type ssid psk
while IFS= read -r name; do
[[ -n "$name" ]] || continue
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
# nmcli's terse mode backslash-escapes ':' and '\' WITHIN a field so a
# combined NAME,TYPE line stays splittable -- but a plain awk -F:
# doesn't know that, so a name containing either character (e.g.
# "Cafe: Guest") gets split in the wrong place and TYPE no longer
# lines up, silently dropping the connection from this list. Querying
# one field at a time with escaping turned off (-e no) sidesteps the
# problem entirely: there's nothing to split, so each value comes
# back exactly as stored.
type="$(nmcli -e no -g connection.type connection show "$name" 2>/dev/null)"
[[ "$type" == "802-11-wireless" ]] || continue
ssid="$(nmcli -e no -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
[[ -n "$ssid" ]] || ssid="$name"
# Only networks whose passphrase this user can actually read are
# shareable. An enterprise network has no passphrase to share at all,
# and a QR code for one would simply not work.
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
psk="$(nmcli -e no -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
'{name: $name, ssid: $ssid, shareable: $shareable}')")
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
| awk -F: '$2 == "802-11-wireless" { print $1 }')
done < <(nmcli -e no -t -f NAME connection show 2>/dev/null)
if [[ ${#rows[@]} -eq 0 ]]; then
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
@@ -77,11 +98,16 @@ cmd_qr() {
local name="${1:-}"
[[ -n "$name" ]] || emit_error 'no network named'
local ssid hidden psk_file payload_file out_dir out_file
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
local ssid hidden psk_file out_dir out_file
# -e no here too: $name is the literal connection name (list emits it
# un-escaped -- see cmd_list), and nmcli's terse escaping is a one-way
# transform on VALUES, not something connection-show lookups expect on
# their NAME argument. Escaping $ssid/$psk here would feed escape_field
# an already-escaped value below and double-escape it.
ssid="$(nmcli -e no -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
hidden="$(nmcli -e no -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
@@ -96,13 +122,12 @@ cmd_qr() {
# Built in a file rather than a variable that could be echoed, and piped to
# qrencode on stdin so the passphrase never appears in argv.
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
trap 'rm -f "$payload_file"' RETURN
{
printf 'WIFI:T:WPA;S:'
printf '%s' "$ssid" | escape_field
printf ';P:'
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
nmcli -e no -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
printf ';H:%s;;' "$hidden"
} >"$payload_file"
+31 -3
View File
@@ -138,6 +138,9 @@ Singleton {
Process {
id: writer
onExited: {
reader.exited = false;
reader.streamFinished = false;
reader.settled = false;
reader.command = [root.helperPath, "get", String(root.writingBus)];
reader.running = true;
}
@@ -145,9 +148,36 @@ Singleton {
Process {
id: reader
property string outputText: ""
property bool exited: false
property bool streamFinished: false
property bool settled: false
stdout: StdioCollector {
onStreamFinished: {
const actual = parseInt(this.text.trim());
reader.outputText = this.text;
reader.streamFinished = true;
root.settleReader();
}
}
// `running` can still read true at the moment onStreamFinished fires --
// the same exited/streamFinished ordering hazard HomeAssistantConfig.qml
// guards against -- so pump() must not be re-entered from here directly.
// Settle on whichever of exited/streamFinished arrives last instead.
onExited: (code, status) => {
reader.exited = true;
root.settleReader();
}
}
function settleReader(): void {
if (reader.settled || !reader.exited || !reader.streamFinished)
return;
reader.settled = true;
const actual = parseInt(reader.outputText.trim());
if (!isNaN(actual)) {
root.displays = root.displays.map(display =>
display.bus === root.writingBus
@@ -159,5 +189,3 @@ Singleton {
root.pump();
}
}
}
}
@@ -32,6 +32,13 @@ Singleton {
property int fixtureNow: 0
property bool watchWanted: false
// Counts watch attempts that exit without ever emitting a snapshot, so a
// helper that is missing or crashes on launch (e.g. no evolution-data-server)
// is reported instead of retried forever in silence.
property int consecutiveWatchFailures: 0
property bool watchProducedSnapshot: false
readonly property int maxConsecutiveWatchFailures: 3
readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000)
readonly property var nextEvent: {
const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch);
@@ -135,14 +142,20 @@ Singleton {
function _restartWatch(): void {
if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart)
return;
// Once the helper has been declared unavailable, don't flash back to
// "loading" on every retry -- only a real snapshot should clear it.
if (root.phase !== "unavailable")
root.phase = root.events.length > 0 ? root.phase : "loading";
root.watchWanted = false;
root.watchProducedSnapshot = false;
startTimer.restart();
}
function consumeSnapshot(data: string): void {
if (root.fixtureMode || data.trim() === "")
return;
root.watchProducedSnapshot = true;
root.consecutiveWatchFailures = 0;
try {
const snapshot = JSON.parse(data);
if (snapshot.ok !== true) {
@@ -263,6 +276,7 @@ Singleton {
root.errors = [];
root.selectedDate = new Date();
root.phase = "loading";
root.consecutiveWatchFailures = 0;
root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
root._restartWatch();
}
@@ -275,6 +289,16 @@ Singleton {
onRead: data => root.consumeSnapshot(data)
}
onExited: (code, status) => {
if (!root.watchProducedSnapshot) {
root.consecutiveWatchFailures += 1;
// The helper died before ever emitting a snapshot, repeatedly --
// stop pretending this is still loading and surface the same
// "unavailable" state probe()/collect_snapshot() report.
if (root.consecutiveWatchFailures >= root.maxConsecutiveWatchFailures) {
root.phase = "unavailable";
root.errors = [{ code: "eds-unavailable" }];
}
}
if (root.watchWanted && !root.fixtureMode)
restartTimer.restart();
}
+20 -5
View File
@@ -402,20 +402,35 @@ esac'
id: recProc
onExited: (code, status) => {
recTimer.stop();
const path = root.recordingPath;
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
// user pressed stop and the file was finalised normally.
if (root.recordingPath !== "") {
// user pressed stop and the file was finalised normally. Any other
// code means wf-recorder died or errored before finalising, so the
// file may be missing or truncated -- never report success then.
const clean = code === 2 || code === 130;
if (path !== "") {
if (clean) {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording saved",
detail: root.recordingPath.split("/").pop(),
detail: path.split("/").pop(),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: "open-path",
actionData: root.recordingPath
actionData: path
});
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", path]);
} else {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording failed",
detail: "wf-recorder exited unexpectedly (code " + code + ")",
tone: "warn",
priority: StatusEvents.importantPriority
});
}
}
root.recordingPath = "";
root.recordingSeconds = 0;
+47 -8
View File
@@ -55,6 +55,18 @@ Singleton {
// installed. Distinct from an empty history.
property bool available: true
// query's `exited` and its stdout `streamFinished` are not guaranteed to
// fire in a particular order (the same signal-ordering hazard
// HomeAssistantConfig.qml's settle-both pattern guards against). These
// track which of the two have arrived for the query currently in flight
// so the result is only finalized once both are known -- otherwise a
// failed query's empty stdout could be parsed as "empty history" before
// the non-zero exit code is seen, or vice versa.
property bool _queryExited: false
property int _queryExitCode: 0
property bool _queryStdoutDone: false
property string _queryStdoutText: ""
// Wall-clock seconds at the moment `entries` was filled. Relative times are
// rendered against this rather than against a live clock, so a row's label
// cannot change while the user is reading it and nothing has to tick.
@@ -69,6 +81,8 @@ Singleton {
if (query.running)
return;
root.loading = true;
root._queryExited = false;
root._queryStdoutDone = false;
query.running = true;
}
@@ -146,16 +160,18 @@ Singleton {
command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql]
stdout: StdioCollector {
onStreamFinished: root._parse(this.text)
onStreamFinished: {
root._queryStdoutDone = true;
root._queryStdoutText = this.text;
root._settleQuery();
}
}
onExited: exitCode => {
root.loading = false;
if (exitCode !== 0) {
root.available = false;
root.entries = [];
root.refreshed();
}
root._queryExited = true;
root._queryExitCode = exitCode;
root._settleQuery();
}
}
@@ -169,6 +185,29 @@ Singleton {
}
}
// Called from both query.onExited and its stdout streamFinished. Only
// finalizes once both signals have arrived, since their firing order is
// not guaranteed -- see the _queryExited / _queryStdoutDone comment
// above. A non-zero exit always means the history is unavailable,
// regardless of what (if anything) stdout produced; only a clean exit
// reaches _parse, where empty stdout is legitimately "no history yet".
function _settleQuery(): void {
if (!root._queryExited || !root._queryStdoutDone)
return;
const exitCode = root._queryExitCode;
const text = root._queryStdoutText;
root._queryExited = false;
root._queryStdoutDone = false;
if (exitCode !== 0) {
root.available = false;
root.entries = [];
root.refreshed();
return;
}
root._parse(text);
}
function _parse(text: string): void {
root.available = true;
root.queriedAt = Date.now() / 1000;
@@ -181,7 +220,7 @@ Singleton {
}
try {
root.entries = JSON.parse(text).map(row => root._normalise(row));
root.entries = JSON.parse(text).map(row => root._normalize(row));
} catch (e) {
console.warn("Clipboard: could not parse history —", e);
root.entries = [];
@@ -192,7 +231,7 @@ Singleton {
// Turn a raw row into what the UI actually asks questions of, so no
// delegate has to know about column names or Vicinae's integer kinds.
function _normalise(row: var): var {
function _normalize(row: var): var {
const encrypted = row.enc !== 0;
const textual = row.kind === root.kindText || row.kind === root.kindLink;
const raw = row.preview || "";
+84 -16
View File
@@ -6,7 +6,7 @@ pragma Singleton
// because every token there is a binding. Two other things draw on this desktop
// and do not read Panama's store:
//
// GTK applications read gsettings colour-scheme and gtk-theme
// GTK applications read gsettings color-scheme and gtk-theme
// the compositor draws window borders and shadows
//
// A toolbar or a window border still wearing the other scheme is more jarring
@@ -29,8 +29,28 @@ Singleton {
readonly property string inactiveBorderDark: "rgba(3b426199)"
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
// The focused border follows the chosen accent. `ee` is the shipped alpha
// for the Prism gradient; Theme owns which colors, this owns the form the
// compositor wants them in.
function hyprColor(value: var): string {
// Qt gives "#rrggbb" -- or "#aarrggbb" if the color ever carries an
// alpha channel. slice(-6) keeps the trailing rrggbb either way;
// slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
// call it RGB. Hyprland wants rgba(rrggbbaa).
return "rgba(" + String(value).replace("#", "").slice(-6) + "ee)";
}
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
property string lastError: ""
// What the last push actually sent, so apply() can tell an accent-only
// change apart from a scheme change and skip the steps that do not depend
// on whichever did not move. Their starting values do not matter: the
// first apply() always runs with force set, which ignores both.
property bool appliedDark: false
property string appliedAccentName: ""
// Applied one command at a time: Process runs a single command, and several
// of these are separate programs.
property var pending: []
@@ -39,7 +59,7 @@ Singleton {
id: runner
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "The colour scheme could not be applied everywhere.";
root.lastError = "The color scheme could not be applied everywhere.";
root.drain();
}
}
@@ -65,7 +85,7 @@ Singleton {
Timer {
id: settle
interval: 1200
onTriggered: root.apply()
onTriggered: root.apply(true)
}
Connections {
@@ -76,12 +96,30 @@ Singleton {
Timer {
id: coalesce
interval: 250
onTriggered: root.apply()
onTriggered: root.apply(false)
}
function apply(): void {
// `force` pushes every step regardless of what moved -- startup needs
// that, because gsettings and the compositor keep their own state and
// have no way to know a previous Quickshell session already told them the
// answer. Everywhere else this reacts to ANY preference changing (see
// onRevisionChanged above), so most calls have nothing to do with either
// scheme or accent; only the steps whose inputs actually moved since the
// last push run, which keeps sampling accent swatches from also rewriting
// gsettings and re-running the whole app-theming script on every sample.
function apply(force: bool): void {
root.lastError = "";
const accentName = DesktopPreferences.get("accentName") || "blue";
const schemeChanged = force || root.dark !== root.appliedDark;
const accentChanged = force || accentName !== root.appliedAccentName;
if (!schemeChanged && !accentChanged)
return;
const commands = [];
if (schemeChanged) {
const scheme = root.dark ? "prefer-dark" : "prefer-light";
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
@@ -99,21 +137,51 @@ Singleton {
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
const commands = [
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
];
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
// Unfocused window borders need scheme-relative contrast. The focused
// Prism border is deliberately owned by the accent/theme layer.
// Unfocused window borders need scheme-relative contrast, and stay
// this service's to own. The FOCUSED border below is the accent
// role and belongs to the theme -- see modules/settings/README.md.
// Unlike that role, this one does not depend on the accent, so an
// accent-only change never needs to restate it.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
}
// Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
// write above and needs nothing here.
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
if (schemeChanged || accentChanged) {
// Each accent carries a separate pair for light and dark, so this
// runs on either kind of change: a scheme flip restates the same
// accent's other pair, and an accent change restates the same
// scheme's other colors.
//
// A two-stop gradient at the shipped angle. Written as a Lua TABLE:
// the string form of a gradient carries only one stop, and passing
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently
// keeps the previous value. Multi-stop must be the table form --
// built by the same serializeValue() SystemSettings uses for every
// other gradient, rather than a second hand-rolled copy of that
// escaping here.
const activeBorder = SystemSettings.serializeValue({
colors: [root.accentBorderStart, root.accentBorderEnd],
angle: 115
});
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
// Applications that predate org.freedesktop.appearance and carry
// their own palettes -- terminals, chiefly. Everything that reads
// the portal (GTK4, Qt6, Chromium, Electron) is already handled by
// the gsettings write above and needs nothing here. The accent is
// passed through too: kitty's border and the hyprlock fallback
// template are also the accent role, not just the scheme -- so
// this still has to run on an accent-only change, even though the
// scheme-relative work it also does is then redundant.
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
}
root.appliedDark = root.dark;
root.appliedAccentName = accentName;
root.enqueue(commands);
}
@@ -146,4 +146,11 @@ Singleton {
onWifiDeviceChanged: root.syncScanners()
onWifiEnabledChanged: root.syncScanners()
onAdapterChanged: root.syncScanners()
// adapter.enabled has no property on root to bind onXChanged to, so it
// needs its own Connections -- the Bluetooth equivalent of onWifiEnabledChanged.
Connections {
target: root.adapter
function onEnabledChanged(): void { root.syncScanners(); }
}
}
+4 -4
View File
@@ -23,7 +23,7 @@ Singleton {
property string timezone: ""
property bool ntpEnabled: false
property bool ntpSynchronised: false
property bool ntpSynchronized: false
property string localTime: ""
property string universalTime: ""
property string rtcTime: ""
@@ -79,7 +79,7 @@ Singleton {
// status is re-read either way.
root.lastError = exitCode === 0
? ""
: "The system rejected that change, or authentication was cancelled.";
: "The system rejected that change, or authentication was canceled.";
root.refresh();
}
}
@@ -101,7 +101,7 @@ Singleton {
else if (key === "NTP")
root.ntpEnabled = value === "yes";
else if (key === "NTPSynchronized")
root.ntpSynchronised = value === "yes";
root.ntpSynchronized = value === "yes";
}
root.lastError = "";
}
@@ -115,7 +115,7 @@ Singleton {
// caller-supplied text reaches the command.
function setTimezone(zone: string): bool {
if (root.zones.indexOf(zone) < 0) {
root.lastError = "That is not a timezone this system recognises.";
root.lastError = "That is not a timezone this system recognizes.";
return false;
}
if (writeRun.running)
+4 -10
View File
@@ -95,7 +95,6 @@ Singleton {
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
return;
}
verifyTimer.attempts = 0;
verifyTimer.ticks = 0;
verifyTimer.restart();
}
@@ -107,7 +106,6 @@ Singleton {
// Exit status is advisory only. Hyprland's Lua bridge can report
// success without applying a value, so exact readback decides.
root.revertVerificationActive = true;
revertVerifyTimer.attempts = 0;
revertVerifyTimer.ticks = 0;
revertVerifyTimer.restart();
}
@@ -143,7 +141,7 @@ Singleton {
? persistedPrimaries[0].name
: (origin?.name ?? raw[0]?.name ?? "");
root.monitors = raw.map(monitor => {
const modes = root.normaliseModes(monitor.availableModes ?? []);
const modes = root.normalizeModes(monitor.availableModes ?? []);
const width = monitor.width ?? 0;
const height = monitor.height ?? 0;
const refreshRate = monitor.refreshRate ?? 0;
@@ -202,7 +200,7 @@ Singleton {
// resolution at distinct rates such as 60.00 and 59.94. Those identities
// remain separate because confirmation and recovery must read back the
// exact mode the user chose, even when their rounded labels look similar.
function normaliseModes(raw: var): var {
function normalizeModes(raw: var): var {
const seen = {};
const out = [];
for (const entry of raw) {
@@ -542,7 +540,6 @@ Singleton {
Timer {
id: verifyTimer
property int attempts: 0
property int ticks: 0
interval: 120
repeat: true
@@ -552,14 +549,12 @@ Singleton {
root.verificationTimedOut();
return;
}
if (root.refresh())
attempts++;
root.refresh();
}
}
Timer {
id: revertVerifyTimer
property int attempts: 0
property int ticks: 0
interval: 120
repeat: true
@@ -569,8 +564,7 @@ Singleton {
root.revertVerificationTimedOut();
return;
}
if (root.refresh())
attempts++;
root.refresh();
}
}
+7 -1
View File
@@ -40,6 +40,12 @@ Singleton {
root.searching = false;
if (exitCode !== 0)
root.lastError = "Could not reach the location service.";
// Typing kept going while this fetch was in flight -- rather than
// leaving the newer query stranded until another keystroke, go
// fetch it now. run() no-ops if pending is now too short.
if (root.pending !== root.lastQuery)
root.run();
}
}
@@ -108,7 +114,7 @@ Singleton {
}
// Stores a chosen place. Coordinates are rounded to four decimals -- roughly
// ten metres, far finer than a weather reading resolves, and it keeps a
// ten meters, far finer than a weather reading resolves, and it keeps a
// precise home location out of the settings file.
function choose(place: var): bool {
const latitude = Math.round(place.latitude * 10000) / 10000;
@@ -81,7 +81,7 @@ Singleton {
}
// "AMD ... [Radeon RX 7700 XT / 7800 XT] (rev c8)" is what lspci gives; the
// bracketed marketing name is the part anyone recognises.
// bracketed marketing name is the part anyone recognizes.
function shortName(name: string): string {
const bracketed = String(name).match(/\[([^\]]+)\]\s*(?:\(rev[^)]*\))?\s*$/);
return bracketed ? bracketed[1] : String(name).replace(/\s*\(rev[^)]*\)\s*$/, "");
+14 -1
View File
@@ -95,7 +95,16 @@ Singleton {
property string payload: ""
onStarted: copyProcess.write(copyProcess.payload)
stdinEnabled: true
onStarted: {
copyProcess.write(copyProcess.payload);
// wl-copy reads stdin until EOF before it exits; leaving the
// channel open (Process.write alone never closes it) would hang
// it forever waiting for more input. Disabling stdin closes the
// write side -- see HomeAssistantConfig.qml's writeProc for the
// same stdinEnabled pattern.
copyProcess.stdinEnabled = false;
}
onExited: (exitCode, exitStatus) => {
root.lastCopyResult = exitCode === 0
? "Report copied."
@@ -339,6 +348,10 @@ Singleton {
return false;
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
root.lastCopyResult = "";
// Re-arm stdin: the previous run closed it (see copyProcess.onStarted)
// and a disabled channel stays closed even after being set back to
// true mid-run, so each new run needs it explicitly re-enabled.
copyProcess.stdinEnabled = true;
copyProcess.exec(["wl-copy"]);
return true;
}
+37 -3
View File
@@ -25,6 +25,16 @@ Singleton {
property string actionKind: ""
property string actionPath: ""
// actionProc's `exited` and its stdout `streamFinished` are not guaranteed
// to fire in a particular order (same hazard HomeAssistantConfig.qml's
// settle-both pattern guards against). These track which of the two have
// been observed for the action currently in flight so finishAction() is
// only ever called once both have arrived, with the real stdout JSON as
// the authoritative result.
property bool actionExited: false
property bool actionStdoutDone: false
property string actionStdoutText: ""
readonly property var preferredPhone: {
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
return phones.find(device => device.reachable) ?? phones[0] ?? null;
@@ -66,6 +76,8 @@ Singleton {
root.transferActive = true;
root.transferFileName = path.split("/").pop();
root.transferDeviceName = root.preferredPhone.name;
root.actionExited = false;
root.actionStdoutDone = false;
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
actionProc.running = true;
}
@@ -83,6 +95,8 @@ Singleton {
return;
root.actionKind = kind;
root.actionPath = "";
root.actionExited = false;
root.actionStdoutDone = false;
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
actionProc.running = true;
}
@@ -95,6 +109,22 @@ Singleton {
root.transferActive = false;
root.transferFileName = "";
root.transferDeviceName = "";
root.actionExited = false;
root.actionStdoutDone = false;
}
// Called from both actionProc.onExited and its stdout streamFinished.
// Only finalizes once both signals have arrived for the in-flight action,
// since their firing order is not guaranteed -- see the actionExited /
// actionStdoutDone comment above.
function settleAction(): void {
if (root.actionKind === "")
return;
if (!root.actionExited || !root.actionStdoutDone)
return;
root.actionExited = false;
root.actionStdoutDone = false;
root.finishAction(root.actionStdoutText);
}
function finishAction(text: string): void {
@@ -198,11 +228,15 @@ Singleton {
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.finishAction(this.text)
onStreamFinished: {
root.actionStdoutDone = true;
root.actionStdoutText = this.text;
root.settleAction();
}
}
onExited: (code, status) => {
if (root.actionKind !== "")
root.finishAction("");
root.actionExited = true;
root.settleAction();
}
}
@@ -43,7 +43,7 @@ Singleton {
readonly property bool scheduled: root.inWindow(clock.hours + clock.minutes / 60)
// A manual toggle always wins: it drops out of the schedule rather than
// being silently reverted a minute later. Same as GNOME's behaviour when
// being silently reverted a minute later. Same as GNOME's behavior when
// you flip night light off during a scheduled evening.
function toggle(): void {
if (root.automatic) {
+66 -9
View File
@@ -4,13 +4,13 @@ pragma Singleton
// The freedesktop notification server, plus the two lists the UI renders:
//
// popups — what Toasts.qml is currently showing (transient, timed)
// history — GNOME's message tray, what NotificationCenter.qml shows
// history — GNOME's message tray, what NotificationList.qml shows
//
// A notification lives exactly as long as `tracked` is true, so history holds
// the *live* objects rather than copies: that keeps actions and inline replies
// working from the tray, which is what GNOME does. The cost is that a
// notification an app closes itself (progress bars, "download finished"
// replacing "downloading") disappears from history too — correct behaviour,
// replacing "downloading") disappears from history too — correct behavior,
// but the reason history is not append-only.
// ─────────────────────────────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ Singleton {
// Suppresses toasts entirely. Notifications still reach history.
property bool doNotDisturb: false
// Cleared when the notification centre is opened. The bar binds to this.
// Cleared when the notification center is opened. The bar binds to this.
property int unreadCount: 0
// Kept separate from the persisted map so this version can safely run
@@ -80,6 +80,21 @@ Singleton {
return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : "";
}
// Freedesktop timeout resolution, shared by the toast countdown (Toast.qml)
// and the no-display expiry a transient notification gets while Do Not
// Disturb is on (below). Critical urgency and an explicit expireTimeout
// override policy; -1 ("server decides") falls back to it. 0 means "never
// auto-expire" per spec.
function notificationTimeoutMs(notification: var): int {
if (notification.urgency === NotificationUrgency.Critical)
return Settings.notificationTimeoutCriticalMs;
if (notification.expireTimeout === 0)
return 0;
if (notification.expireTimeout > 0)
return Math.round(notification.expireTimeout * 1000);
return Settings.notificationTimeoutMs;
}
readonly property bool hasNotifications: root.history.length > 0
function notificationAppId(notification: var): string {
@@ -156,7 +171,7 @@ Singleton {
}
// history grouped by app, in most-recent-app-first order — the shape
// NotificationCenter.qml renders directly.
// NotificationList.qml renders directly.
readonly property var groups: {
const out = [];
const byApp = {};
@@ -219,8 +234,44 @@ Singleton {
root.unreadCount += 1;
}
if (!root.doNotDisturb)
if (!root.doNotDisturb) {
root.popups = [notification].concat(root.popups);
} else if (notification.transient) {
// Never shown, and (being transient) never filed in history
// either — nothing will otherwise dismiss() it, so schedule
// the same release its popup timeout would have given it.
root.scheduleTransientExpiry(notification);
}
}
// Runs a DND-hidden transient notification through the same lifetime it
// would have gotten as a visible popup (Toast.qml's countdown), just
// without ever showing it, so it still gets released instead of staying
// tracked forever.
function scheduleTransientExpiry(notification: var): void {
const ms = root.notificationTimeoutMs(notification);
if (ms <= 0)
return;
const timer = Qt.createQmlObject("import QtQuick; Timer { repeat: false }", root);
timer.interval = ms;
timer.triggered.connect(() => {
timer.destroy();
root.releaseTransient(notification);
});
// Closed some other way first (app-side close, dismissAll()) — cancel
// the pending timer instead of firing a stale dismiss() later.
notification.closed.connect(() => timer.destroy());
timer.running = true;
}
// Transient notifications are never filed in history, so nothing else
// holds a reference once their lifetime ends — release tracked state
// directly. Shared by the DND-hidden expiry above and dismissAll() below.
function releaseTransient(n: var): void {
if (n.transient)
n.dismiss();
}
// ── Mutation ────────────────────────────────────────────────────────────
@@ -243,10 +294,7 @@ Singleton {
if (next.length === root.popups.length)
return;
root.popups = next;
// Transients were never in history, so nothing else holds them.
if (n.transient)
n.dismiss();
root.releaseTransient(n);
}
function dismiss(n: Notification): void {
@@ -257,10 +305,19 @@ Singleton {
// Copy first: dismiss() re-enters through forget() and rewrites both
// lists while we iterate.
const all = root.history.slice();
// Transient notifications are never filed in history — whether
// currently shown as a popup or hidden by Do Not Disturb with a
// pending scheduleTransientExpiry() timer — so releasing them needs
// its own pass over the server's full tracked set.
const transients = root.active.values.filter(n => n.transient);
root.history = [];
root.popups = [];
for (const n of all)
n.dismiss();
for (const n of transients)
root.releaseTransient(n);
root.unreadCount = 0;
}
@@ -33,7 +33,7 @@ Singleton {
// Non-empty when the machine cannot actually deliver the profile it is set
// to -- thermal throttling, or a laptop running on battery. Worth showing,
// because otherwise "performance" is a claim the hardware is not honouring.
// because otherwise "performance" is a claim the hardware is not honoring.
property string degraded: ""
readonly property bool available: root.profiles.length > 0
@@ -52,7 +52,7 @@ Singleton {
function detail(profile: string): string {
switch (profile) {
case "power-saver": return "Reduces performance to save energy and run quieter";
case "balanced": return "Standard behaviour, scaling up only when needed";
case "balanced": return "Standard behavior, scaling up only when needed";
case "performance": return "Holds higher clocks, using more power and making more noise";
default: return "";
}

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