Author SHA1 Message Date
Gabriel Brown 5e6dc9e533 Release stale Caffeine inhibitors 2026-08-18 07:43:15 -04:00
Gabriel Brown ca64d4b9b6 Harden Panama launcher actions 2026-08-18 07:31:11 -04:00
Gabriel Brown b28edcd01f Add Panama commands to the launcher 2026-08-18 07:31:11 -04:00
Gabriel Brown 9c574f9eb7 Add a light mode that keeps the same identity
Light is Tokyo Night Day, the palette's own light variant, rather than
one invented to merely not be dark. Both share the same hues at
different lightness, which is what lets the Prism signature survive the
switch: blue still leads into orchid, it is simply a darker blue on a
lighter ground.

Every colour token became a binding on one boolean, so flipping it
repaints the whole shell without any component needing to know it
happened. That only worked because nothing outside Theme.qml defines a
colour; the two places that did are fixed here.

Surface alphas differ by scheme. The 0.34 that reads as glass over a
dark desktop reads as haze over a light one, and text stops being
legible on it.

Two things that draw on this desktop do not read Panama's store: GTK
applications, which read gsettings, and the compositor, which draws
window borders. A toolbar or a border still wearing the other scheme is
more jarring than either scheme on its own, so ColorScheme pushes the
choice to both. It also pushes at startup, since a scheme chosen in a
previous session would otherwise be in effect only for the shell.

The unfocused window border follows too. It is a flat neutral, and a
dark neutral is invisible against a light desktop. The focused border is
the prism gradient and needs no variant.

The live preview follows the scheme as well. A preview that stayed dark
while the shell around it went light did not read as "your desktop", it
read as a screenshot of someone else's.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:20:35 -04:00
Gabriel Brown d1b2cd2083 Make typography choosable instead of hardcoded
Theme.qml was the largest remaining thing in this desktop that could
only be changed by editing a file, and typeface is the first thing
someone changes when they want a desktop to feel like theirs. Interface
font, icon font, and the base text size are settings now.

The two font choices are deliberately separate lists. Theme.fontMono is
used only to draw glyphs -- workspace pills, the status cluster, search
icons -- so a plain monospace family there replaces every icon in the
shell with tofu. The picker offers only Nerd Fonts for that slot and
says why.

Candidates are rendered in the family they name. A list of font names
set in the current font tells you nothing about what you are choosing.

The four type sizes derive from the base rather than being stored
separately, so the relationship between body, caption, heading and title
survives a change instead of four numbers drifting apart.

hypr/looks.lua reads the same key. Following the preference only on the
QML side would leave the compositor and the shell disagreeing about the
interface font, which nobody notices until a tooltip renders in a
different typeface.

Only a family this machine reports is accepted: the value reaches
hl.config as a string, and a settings file moved between machines will
name fonts that are not installed. A missing family is reported rather
than silently substituted by fontconfig.

Also collapses the wallpaper grid to two rows. Sixty tiles is two
screens of pictures on a page that also holds typography, window
geometry and effects -- everything below it was unreachable without
scrolling past all of them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:06:53 -04:00
Gabriel Brown 8c4dee0ca8 Give Settings its own icon and the first dock slot
Panama Settings shipped with Icon=preferences-system-symbolic, a
monochrome glyph drawn for 16px toolbar use. Beside full-colour
application icons in a 48px dock it reads as a missing icon rather than
a quiet one. It now has its own: a gear, because a settings icon has to
be recognisable before it is clever, rendered in the Prism gradient on
the dark tile so it belongs to this desktop.

An earlier attempt drew the gear as a ring with radial strokes; at dock
size the strokes merged into the ring and it read as an X. The shipped
version is a real toothed outline, checked at 48px rather than only at
128.

The dock also pinned GNOME Settings first. Panama now covers what GNOME
Settings did for this desktop and delegates the remainder to it by name,
so pinning the thing it delegates TO put the fallback in front of the
real one. GNOME Settings stays installed and searchable.

link-dotfiles installs icons alongside desktop entries, so this survives
a fresh setup rather than being a file that happens to exist here.

The new contract asserts every pinned application resolves to an
installed desktop entry. DockBody drops an unresolvable pin rather than
drawing a broken icon, which is right at runtime and invisible to debug:
a typo or a renamed desktop id just removes an icon with nothing logged.
It also cost me a false negative while writing it -- DesktopEntries
populates asynchronously, and asking too early reports every pin as
missing.

Also makes tests/quickshell/osd-ui-contract.sh executable. It was
committed mode 644, the only test in the suite that was, so the runner
could not invoke it. It passes.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:54:43 -04:00
Gabriel Brown 71ee18048e Add a living Prism component gallery 2026-08-18 06:50:32 -04:00
Gabriel Brown 668983e16d Add polished system feedback overlays 2026-08-18 06:46:11 -04:00
Gabriel Brown 17e23a6cf4 Merge remaining settings work
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:38:46 -04:00
Gabriel Brown d18fe51553 Make the weather location and graphics device choosable
The last two values that could only be changed by editing a file.

Weather was pinned to hardcoded coordinates, so the card could not be
pointed anywhere else. It is a location search now, not latitude and
longitude fields: nobody knows their own coordinates, and a control that
demands them is one nobody uses. Open-Meteo's geocoding endpoint needs
no key, the same reason the forecast already uses them. Only the search
term leaves the machine -- the stored place name is a label -- and
coordinates are rounded to four decimals, far finer than a weather
reading resolves and coarse enough to keep a precise home location out
of the settings file.

The graphics readout was hardcoded to card1. This machine has two amdgpu
cards, discrete and integrated, so that was right only by luck, and the
path is meaningless on any other machine. GPUs are enumerated with a
readable name from lspci, since sysfs exposes only numeric ids, and the
picker appears only when there is more than one to choose between. A
stored path the machine does not have is refused and reported rather
than silently measuring nothing.

Also merges the per-application notification rules UI. Its three commits
were believed integrated but the page half was not actually in the tree:
main had the service side in Notifs.qml and zero references to
setAppRule in NotificationsPage. Ancestry is not content.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:38:46 -04:00
Gabriel Brown 9f9515ffe0 Merge per-application notification rules
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:29:47 -04:00
Gabriel Brown 94353aa0bd Close the gaps the cross-UI audit found
Audited bar, dock, quick settings, date menu and Settings for three
things: a setting reachable in one UI but not another, a setting that
exists but is unreachable anywhere, and UI that states something false.

Night Light was fully exposed in Quick Settings and had no control
anywhere in Settings. It now has a card on Displays, where GNOME also
puts it, with on/off, schedule, times and temperature.

Adding those controls would have shipped the exact defect this audit
exists to find. NightLight declared enabled, temperature and automatic
as bindings on the store, but toggle() assigns to them, and an
assignment destroys a QML binding permanently -- so the service wrote to
the store and never read from it again. The Settings controls would have
written values the service ignored, while Quick Settings kept working.
It now follows the store. Every other service was swept for the same
pattern; this was the only one.

The night light schedule was two hardcoded literals, so the hours could
not be changed. They are schema keys now, with a row that renders 17.5
as "5:30 PM" and honours the 24-hour preference rather than showing a
decimal nobody reads as a time.

keyboardLayout was in the schema and read by input.lua but had no
control anywhere: configurable in principle, unreachable in practice. It
is surfaced on Input & Shortcuts as read-only, with the reason, because
it needs a compositor reload and a control implying instant apply would
be a smaller lie but still a lie.

Caffeine was a Quick Settings toggle mentioned only in a subtitle in
Settings. It has a real control now.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:25:15 -04:00
Gabriel Brown 3a2295ff53 Make credential setup keyboard-safe and verifiable 2026-08-18 06:14:07 -04:00
Gabriel Brown de1b8a7673 Add private Home Assistant credentials settings 2026-08-18 06:14:07 -04:00
Gabriel Brown 39a26adcc2 Make Home light ordering explicit and durable 2026-08-18 06:14:07 -04:00
Gabriel Brown eff0bc44ff fix: resolve persisted notification app labels 2026-08-18 06:14:07 -04:00
Gabriel Brown c038c57278 fix: harden notification application rule integration 2026-08-18 06:14:07 -04:00
Gabriel Brown 420b7aa1a6 feat: add notification application rules 2026-08-18 06:14:07 -04:00
Gabriel Brown f22e405b50 fix: resolve persisted notification app labels 2026-08-18 05:57:10 -04:00
Gabriel Brown 3cfa592db9 Build complete PipeWire sound settings 2026-08-18 05:54:14 -04:00
Gabriel Brown 81096e2d95 Declare the Sound and notification-rule manifest entries
Registers the components and schema key the codex agent needs for the
Sound page and per-application notification rules, so its branches
compile against a manifest that already holds them rather than each
carrying a conflicting edit to the same file.

An absent notification rule is permissive rather than denying: a newly
installed application must be able to notify without an entry being
written for it first.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:54:07 -04:00
Gabriel Brown c415c4f176 fix: harden notification application rule integration 2026-08-18 05:51:32 -04:00
Gabriel Brown 86742da63d Merge Wi-Fi and Bluetooth settings
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:50:07 -04:00
Gabriel Brown 3c521cf5fa Handle Wi-Fi and Bluetooth in Settings
Network & Devices was 92 lines and two buttons that opened GNOME. It now
scans, joins, and pairs directly through Quickshell.Networking and
Quickshell.Bluetooth -- NetworkManager and BlueZ over DBus, no shelling
out to nmcli or bluetoothctl. That was the founding requirement for this
desktop: never having to drop to a terminal to join a network.

Scanning follows the page being visible. Wi-Fi scanning and especially
Bluetooth discovery hold the radio, and running either for a list nobody
is looking at spends airtime on nothing.

Joining a secured network gets a real password field, not the clipboard
popover's search box with different placeholder text: a Wi-Fi key typed
into a field that echoes it is readable by anyone behind you, and a
search glyph in front of a password prompt is simply wrong.

Two bugs found by looking at the rendered page, both silent:

The device lookups used enum names that do not exist --
NetworkDeviceType.Wifi rather than DeviceType.Wifi -- so both returned
null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi
was connected. Nothing was logged; QML resolves an unknown enum member
to undefined and compares happily.

signalStrength is 0.0-1.0, not a percentage, so thresholds written for
0-100 put every network including the connected one in the bottom
bucket. The labels now use the same buckets as the icons in
quicksettings/WifiList.qml so the two cannot disagree.

The contract compares what the service resolves against what nmcli
reports, rather than only checking that nothing crashed.

Also makes the Home Assistant bridge hermetic: resolve_config read the
user's private env file even when a caller supplied an explicit
environment, so adding a real PANAMA_HOME_ASSISTANT_ENTITIES to that
file silently overrode a fixture asserting the legacy fallback. An
explicit environment is now the whole environment; production still
reads the file. Its live contract skips when no token is configured --
an absent credential is not a defect, and a suite expected to be red
stops being read -- while a configured-but-broken bridge still fails.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:50:07 -04:00
Gabriel Brown c087998710 feat: add notification application rules 2026-08-18 05:38:39 -04:00
Gabriel Brown b9800bfbab Scope compositor isolation to reset tests 2026-08-18 03:25:39 -04:00
Gabriel Brown 7b8270fdd6 Keep Settings recovery isolated and display-safe 2026-08-18 03:19:45 -04:00
Gabriel Brown 6d3f888784 Protect live state during restore and reset 2026-08-18 03:10:31 -04:00
Gabriel Brown c2cd79b547 Serialize display recovery checks 2026-08-18 02:55:43 -04:00
Gabriel Brown 0a18290137 Close display verification races 2026-08-18 02:55:43 -04:00
Gabriel Brown efc53435a2 Verify display restoration and expose outputs 2026-08-18 02:55:43 -04:00
Gabriel Brown 67674d297e Polish display confirmation state 2026-08-18 02:55:43 -04:00
Gabriel Brown 2d4b126f14 Harden display apply and recovery 2026-08-18 02:55:43 -04:00
Gabriel Brown 5671324eb2 Make displays configurable, with a revert countdown
Resolution, refresh rate, scale, and rotation, applied through
hl.monitor{} and stored per output.

This is the only setting in Panama where a wrong value can leave the
user unable to SEE the screen well enough to undo it: a mode the panel
cannot show, or a scale that makes everything unreadable, is not
recoverable through the UI that caused it. So a change is never applied
irreversibly. It is applied, then reverted automatically after fifteen
seconds unless confirmed, and confirming is what writes it to the
settings store -- letting the countdown run leaves nothing behind.

The contract tests that property specifically: it applies a scale, waits
out the countdown, and asserts the display came back and that nothing
was stored. A regression there is not a broken feature, it is a user
staring at a blank monitor.

Modes are grouped by resolution with refresh rates beside them. The
panel reports 35, many differing only in refresh-rate rounding -- 60.00
and 59.94 -- which as a flat list of buttons is noise rather than
choice; equal rounded pairs collapse, leaving 21.

Only mode, scale, and transform are configurable. Colour management and
bit depth stay in monitors.lua because they carry a documented screencopy
tradeoff that a settings page cannot explain at the moment you would be
changing it.

Also replaces the display policy rows with the schema-bound ones, so the
page no longer restates labels that PreferenceSchema already holds.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 02:55:43 -04:00
Gabriel Brown 83391e0453 Make settings restore crash-safe 2026-08-18 02:22:23 -04:00
Gabriel Brown 172b099a04 Complete settings snapshot restoration 2026-08-18 02:22:23 -04:00
Gabriel Brown cae72b5179 Make Settings sidebar content scrollable 2026-08-18 01:54:00 -04:00
Gabriel Brown b3b8e0d66d Prevent shortcut reset collisions 2026-08-18 01:47:51 -04:00
Gabriel Brown 787d2b121a Use the durable Home reset boundary 2026-08-18 01:39:34 -04:00
Gabriel Brown 1b323c2fa5 Document Settings completion plan 2026-08-18 01:36:30 -04:00
Gabriel Brown 1ca571458c Complete Panama settings controls 2026-08-18 01:36:07 -04:00
Gabriel Brown 768801dbe4 Fix application role filtering 2026-08-18 01:28:29 -04:00
Gabriel Brown 375ecfcd95 Add application and autostart settings 2026-08-18 01:28:29 -04:00
Gabriel Brown ce95b34d19 Harden Home preferences reset contract 2026-08-18 01:28:29 -04:00
Gabriel Brown 9d430a3079 Finish Home and Phone settings cohesion 2026-08-18 01:28:29 -04:00
Gabriel Brown bac68d2bfb Document the settings vocabulary and its traps
Records how to add a setting -- one schema entry -- and the failure
modes found building the pages. Every one of them fails silently rather
than loudly, which is why they are worth writing down: a wrong readAs
makes successful writes look rejected, hyprpaper accepts an all-outputs
wallpaper request and ignores it, and a copy of the Quickshell config
shares the live shell's ID so an "isolated" harness can kill the running
desktop.

Also documents the two generated configurations and why they are
generated: ~/.config/hypr is a symlink into this repository, so writing
there at runtime would commit machine state to a tracked file.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:15:53 -04:00
Gabriel Brown 634a9ebe07 Let shortcuts be rebound from Settings
Every bind in keybinds.lua now goes through a small wrapper that
substitutes the chord from a stored override. Only the chord is taken
from settings; the action is always the Lua value written in that file,
so an override can move a shortcut but can never make one do something
else. That is the property that makes reading them from a file the user
can edit safe, and it is why the alternative -- storing dispatchers --
was not considered.

Overrides are keyed by the shipped chord rather than the description.
Keying by description moved every bind that shared one: rebinding
SUPER+C also moved the XF86Calculator hardware key onto the same chord,
silently costing it. Chords are unique; descriptions are not.

Applying needs hyprctl reload rather than a live hl.bind. Hyprland
reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
so the action cannot be reconstructed from outside to re-bind it; reload
re-runs the config, which re-reads the settings file.

The capture control ignores modifier-only presses, because every chord
passes through them and holding Super would otherwise be captured the
moment the modifier went down. It refuses a bare letter, which would
swallow ordinary typing, and refuses a key with no keysym name rather
than storing something that would fail to bind. Rebinding onto a chord
already in use is refused rather than shadowing the existing shortcut.

The refactor was verified by snapshotting all 113 binds before and
after: the keymap is byte-identical, and identical again after applying
an override and resetting it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:13:36 -04:00
Gabriel Brown 150f3cdb09 Make the Dock editable and add settings snapshots
The Dock's pinned applications were a sixteen-entry literal in
Settings.qml, so changing what sits in the Dock meant editing QML. They
are now an ordered list in the shared store, with move up, move down,
unpin, and a filtered picker for adding installed applications. Keeping
them in the shared store rather than a file of their own means they are
covered by Restore defaults like everything else.

This needed a "json" schema type for values the schema stores and resets
but does not validate field by field. It exists so structured settings
can live in the one file rather than growing a fourth preference store;
the owning service validates the contents.

Snapshots make the settings app safe to experiment with. The whole
configuration is one file, so a backup is a copy and a restore is an
overwrite, and restoring snapshots what it replaces so it is itself
undoable. A snapshot is validated as JSON before it can be restored over
a working configuration, and a name that is not a plain snapshot
filename from the backup directory is refused.

Snapshot names carry milliseconds. At one-second resolution a save
followed promptly by a restore produced the same filename twice, and the
restore's own safety snapshot overwrote the file it was about to read --
found by the contract, which restores immediately after saving.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:59:08 -04:00
Gabriel Brown 6377bfb8fd Route the Applications page and add its settings
Wires the schema entries and routing the codex agent needs for default
applications, weather, vitals refresh, notification timing, and capture,
so its pages can be written against keys that already exist.

Capture directories and encoder arguments are enums rather than free
text. Both are handed to a recorder process, and a settings page has no
reason to expose an arbitrary string there.

ApplicationsPage.qml is a placeholder so the page id can be routed,
registered, and searchable before the real page lands. It is owned by
the other agent and expected to be replaced wholesale.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:51:10 -04:00
Gabriel Brown 2bc12e6022 Add wallpaper, power, date, accessibility, and real search
Continues the settings expansion toward replacing GNOME Settings for
everything Panama actually owns.

Wallpaper. A thumbnail grid rather than a path field: the value of this
setting is the picture, so typing a path to something you cannot see is
the worst version of it. Two things about hyprpaper 0.8 shaped this. Its
IPC is much smaller than older documentation suggests -- preload,
listloaded, unload, and reload all answer "invalid hyprpaper request",
so setting is a single call with no preload. And the "<empty>,<path>"
form that used to mean every output is silently ignored, so a wallpaper
set that way appears to succeed and never changes; outputs are walked
explicitly instead. hyprpaper.conf lives in the repo through the
~/.config/hypr symlink and so cannot hold machine state, which is why
the choice lives in the shared settings store and is re-applied at
startup.

Power & Lock. hypridle has no IPC for reconfiguration and its config is
hyprlang rather than the shared JSON, so scripts/panama-idle generates a
config from the settings store and restarts the daemon. The generated
file lives under XDG_STATE_HOME for the same symlink reason, with a
systemd drop-in pointing hypridle at it. Management is a real state and
the page says which one you are in rather than showing sliders that
quietly do nothing. Zero means never for all three timers, which a naive
template would render as "immediately".

Date & Time. Deliberately not stored in Panama's settings: the timezone
and network time belong to the machine and are shared with sessions that
never see this file. Storing a copy would create a second answer to a
question the system already answers. Reads and writes timedatectl
directly; a cancelled polkit prompt surfaces as an error rather than as
a value that appears to have been accepted.

Accessibility. Pointer size and text scale have to agree across three
consumers with no shared configuration -- the compositor, GTK, and the
shell -- so the store is the source of truth and the values are pushed
outward to gsettings and hyprctl setcursor.

Search now indexes the schema instead of the twelve page labels. "gaps",
"wallpaper", and "screenshot" previously found nothing on an app that
has all three, which is the clearest way a settings app feels smaller
than it is. Shortcuts are indexed by what they do. A contract asserts
every non-internal schema label is reachable, so a new setting cannot be
added in an undiscoverable state.

The GNOME delegation allow-list was widened to the panel names
gnome-control-center actually reports; the previous list contained
"users", which is not one of them and so opened nothing.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:42:13 -04:00
Gabriel Brown fe7c85e471 Build the settings vocabulary and generate the keymap
Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and
TextRow. A row names a schema key and needs nothing else: label, detail,
range, and unit come from PreferenceSchema, and writes go through
SystemSettings.commitPreference, which routes compositor-backed keys
through apply-and-verify and local keys straight to the store. The page
scaffold that was copy-pasted eleven times is now one component.

Rebuild Appearance around a live preview of the real desktop, scaled by
the ratio between the preview and the actual monitor so a 10px gap on a
4500px display looks as small as it is. Rebuild Desktop & Dock and Input
& Shortcuts on the shared rows, replacing the read-only text that stood
in for controls that were merely expensive to add.

Generate the shortcut list from hyprctl binds. The page held a
hand-typed nineteen entries against a real keymap of a hundred and
thirteen; it could not show the rest and went stale whenever a bind
changed. Every bind now carries its own description -- backfilled for
the twenty-nine that lacked one -- and keybinds-contract.sh fails if any
bind lacks one, since undescribed binds are dropped from the page.

Make Restore defaults span every store Panama owns. Resetting only the
schema store left the Home accessory arrangement customised while
claiming to restore defaults, which is worse than no reset because it is
silent. Done through HomePreferences' existing public aliases rather
than a new API.

Four defects found while building:

cursor:inactive_timeout is answered by getoption as float, not int. A
wrong readAs does not fail loudly; it makes every write to that key look
rejected, and the user saw an error for a change that worked.
schema-hypr-shape-contract.sh now checks all 23 mapped options against
the running compositor.

The Settings window is tiled, so implicitWidth is only a hint and rows
must survive roughly 400px. SliderRow stacks its control under the label
below 520px.

Binding an anchor to undefined to switch layouts does not reliably
release it. Both row layouts are positioned explicitly.

Concurrent compositor writes are queued and merged rather than refused.
The startup replay of every compositor-backed preference routinely
overlaps a UI change, and refusing left the store and the compositor
disagreeing.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:24:51 -04:00
161 changed files with 15688 additions and 1313 deletions
+63
View File
@@ -44,6 +44,30 @@ Validate any change without leaving your session:
Hyprland --verify-config
```
## Generated configuration
Two ecosystem tools cannot read the shared settings file, and their configs live
in this directory — which is a symlink into the Panama repository, so writing to
them at runtime would put machine state into a tracked file. Both are therefore
generated elsewhere:
| Tool | Generated to | Pointed there by |
|---|---|---|
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
```sh
~/.config/quickshell/scripts/panama-idle remove
```
## Settings: one file, both sides
`~/.config/panama/settings.json` is shared with the Quickshell side. The
@@ -67,6 +91,45 @@ wrong-typed settings file costs you your customisations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states.
### Adding a keybind: use `bind`, not `hl.bind`
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
Only the **chord** is ever taken from settings — the action is always the Lua
value written here. A stored override can therefore move a shortcut but can
never make one do something else, which is what makes reading overrides from a
file the user can edit safe.
Overrides are keyed by the **shipped chord**, not the description. Descriptions
are not unique: "Calculator" is both `SUPER + C` and the `XF86Calculator`
hardware key, and keying by description moved both onto the same new chord,
silently costing the hardware key.
An override whose value is not a plausible chord is ignored in favour of the
shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
### Keybind descriptions are required
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the
Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently.
```lua
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
The page groups shortcuts by what the description says they do, so a new bind
lands in the right section with no change to the UI.
### Never use `hyprctl keyword`
On a Lua-configured Hyprland it refuses the write, prints
+136 -95
View File
@@ -11,6 +11,8 @@
-- SUPER + CTRL -> change layout structure (split, float, swap)
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
local mod = "SUPER"
-- Programs, matched to the GNOME custom keybindings and media-key settings.
@@ -28,114 +30,153 @@ local sysmonitor = "flatpak run io.missioncenter.MissionCenter"
-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
-- window against the already-running server (started in autostart.lua).
local launcher = "vicinae toggle"
local osd = function(action)
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
end
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
-- ── Applications ────────────────────────────────────────────────────────────
hl.bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
hl.bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
hl.bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
hl.bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
hl.bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
hl.bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
hl.bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
hl.bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── User rebinding ──────────────────────────────────────────────────────────
-- Every bind below goes through `bind` rather than `hl.bind` directly, so a
-- chord can be replaced from Panama Settings without this file changing.
--
-- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken
-- from settings -- the action is always the Lua value written here. A stored
-- override can therefore move a shortcut but can never make one do something
-- else, which is the property that makes reading them from a JSON file the
-- user can edit safe.
--
-- Keyed by chord rather than by description because chords are unique and
-- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator
-- hardware key, and keying by description moved both of them onto the same new
-- chord, silently costing the hardware key.
--
-- An override whose chord is not a plausible chord is ignored and the shipped
-- one is used, so a hand-edited settings file cannot cost you a keymap.
local overrides = prefs.get("keybindOverrides", {})
local function valid_chord(chord)
if type(chord) ~= "string" or chord == "" or #chord > 64 then
return false
end
-- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272"
return chord:match("^[%w_+%s:]+$") ~= nil
end
local function bind(chord, action, opts)
local override = overrides[chord]
if valid_chord(override) then
chord = override
end
return hl.bind(chord, action, opts)
end
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ────────────────────────────────────────────────────────────────
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
-- emoji and window switching, so a separate run dialog and app grid are gone.
hl.bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
hl.bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
hl.bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- Emergency fallback launcher. Vicinae runs as a systemd user service and the
-- bar is Quickshell; if either fails to come up, this is how you start an
-- application without dropping to a TTY. Depends on nothing but wofi itself.
hl.bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
-- Clipboard history and emoji, straight into the relevant launcher view.
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
hl.bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
{ description = "Clipboard history" })
hl.bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
{ description = "Emoji picker" })
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
-- SUPER+S was GNOME's quick settings; kept.
hl.bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
-- Start a 45-minute focus session on the current workspace, or reveal its
-- Signal Glass controls if one is already running.
hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a
-- modifier misfires when you're fast with SUPER+key combos, so it lives on a
-- real chord instead. SUPER+grave was Forge's "cycle windows of same app",
-- which Hyprland has no equivalent for.
hl.bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
-- Notification centre.
hl.bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
-- Screenshot / screen record. One key, then pick screen / window / region and
-- whether to capture or record -- reproducing GNOME's Print-screen UI.
hl.bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
hl.bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
hl.bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
-- Local OCR and QR/barcode recognition through the same region picker. This
-- opens directly in Selection + Read mode; Print still exposes every mode.
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
{ description = "Screen Intelligence" })
-- Colour picker: copies the hex under the cursor to the clipboard.
hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
-- ── Window management ───────────────────────────────────────────────────────
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
hl.bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
-- Forge: window-toggle-float / window-toggle-always-float.
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
-- is the nearest useful thing -- the window floats above every workspace.
hl.bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
hl.bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical.
hl.bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
hl.bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
hl.bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
-- Forge: window-shrink / window-expand / window-reset-sizes.
hl.bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
hl.bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
hl.bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge.
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
hl.bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" }))
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" }))
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" }))
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" }))
bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
-- Move (Forge: window-move-*).
hl.bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
hl.bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
hl.bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
hl.bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
-- Swap (Forge: window-swap-*).
hl.bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
hl.bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
hl.bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
--
@@ -145,24 +186,24 @@ hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descrip
-- 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
hl.bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
hl.bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
hl.bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
hl.bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
hl.bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
hl.bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
hl.bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
hl.bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "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" })
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).
hl.bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
hl.bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
-- Jump back to the previously focused window.
hl.bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
-- Mouse: drag to move, right-drag to resize.
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
-- ── Workspaces ──────────────────────────────────────────────────────────────
-- ALT is the workspace modifier, matching the GNOME setup.
@@ -170,27 +211,27 @@ hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
-- 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.
hl.bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
hl.bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
hl.bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
hl.bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
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" })
bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
-- GNOME also had these on CTRL+ALT+Up/Down.
hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }))
hl.bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }))
bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
-- Direct jump. ALT+0 is workspace 10.
for i = 1, 10 do
local key = i % 10
hl.bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
hl.bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
end
-- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
-- (Scrolling the workspace indicator in the bar does the same; that's handled
-- in quickshell/modules/bar/Workspaces.qml.)
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }))
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
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.
--
@@ -202,42 +243,42 @@ hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
--
-- 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.
hl.bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
hl.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 minimised)" })
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
-- ── Session ─────────────────────────────────────────────────────────────────
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
-- CTRL+ALT+L -- the other binding most people already have in muscle memory.
hl.bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
-- ── Media and volume ────────────────────────────────────────────────────────
-- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting.
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
hl.bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true })
hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true })
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true })
bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
-- Hardware keys GNOME mapped that have obvious equivalents.
hl.bind("XF86Tools", hl.dsp.exec_cmd(settings))
hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator))
hl.bind("XF86Explorer", hl.dsp.exec_cmd(files))
hl.bind("XF86WWW", hl.dsp.exec_cmd(browser))
hl.bind("XF86Mail", hl.dsp.exec_cmd(mail))
hl.bind("XF86Search", hl.dsp.exec_cmd(launcher))
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
return true
+10 -2
View File
@@ -29,7 +29,11 @@ hl.config({
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means
-- something if exactly one window on screen is wearing it.
inactive_border = "rgba(3b426199)",
-- Follows the colour scheme: a dark neutral is invisible against a
-- light desktop. services/ColorScheme.qml applies changes live;
-- this is the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)",
},
resize_on_border = true,
@@ -115,7 +119,11 @@ hl.config({
disable_hyprland_logo = true,
disable_splash_rendering = true,
font_family = "Adwaita Sans",
-- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree
-- about the interface font and nobody would notice until a tooltip
-- rendered in a different typeface.
font_family = prefs.get("interfaceFont", "Adwaita Sans"),
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
-- content type is "video" or "game" -- the tag rules.lua applies to
+97 -2
View File
@@ -17,11 +17,88 @@
-- overrides.lua) and read the notes in that file first.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- Per-output overrides written by Panama Settings, keyed by output name:
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
--
-- Only mode, scale, and transform are read. Colour management and bit depth
-- stay here, because those are the settings with a documented reason attached
-- (see the header) rather than preferences, and a settings page has no way to
-- explain the screencopy tradeoff at the moment you would be changing it.
local displays = prefs.get("displays", {})
if type(displays) ~= "table" then
displays = {}
end
local function mode_dimensions(mode)
if type(mode) ~= "string" then
return nil, nil
end
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
if width == nil then
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
end
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
if width == nil or height == nil or refresh == nil
or width <= 0 or height <= 0 or refresh <= 0 then
return nil, nil
end
return width, height
end
local function valid_mode(mode)
local width = mode_dimensions(mode)
return width ~= nil
end
local function valid_scale(mode, scale)
local width, height = mode_dimensions(mode)
if width == nil or type(scale) ~= "number" or scale ~= scale
or scale <= 0 or scale > 4 then
return false
end
local logical_width = width / scale
local logical_height = height / scale
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
end
local function valid_transform(transform)
return type(transform) == "number"
and transform == math.floor(transform)
and transform >= 0
and transform <= 3
end
local function display_entry(output)
if type(output) ~= "string" or output == ""
or output:match("^[%w_.-]+$") == nil then
return nil
end
local entry = displays[output]
if type(entry) ~= "table" then
return nil
end
if not valid_mode(entry.mode)
or not valid_scale(entry.mode, entry.scale)
or not valid_transform(entry.transform) then
return nil
end
return entry
end
local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5
local shipped_transform = 0
local dp2 = display_entry("DP-2")
hl.monitor({
output = "DP-2",
mode = "4500x3000@60",
mode = dp2 and dp2.mode or shipped_mode,
position = "0x0",
scale = 1.5,
scale = dp2 and dp2.scale or shipped_scale,
transform = dp2 and dp2.transform or shipped_transform,
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
@@ -32,6 +109,24 @@ hl.monitor({
cm = "auto",
})
-- Other connected outputs use the same validated per-output store. They keep
-- automatic placement and the compositor's normal colour policy; DP-2 alone
-- carries the panel-specific 10-bit policy documented above.
for output, _ in pairs(displays) do
if output ~= "DP-2" then
local entry = display_entry(output)
if entry ~= nil then
hl.monitor({
output = output,
mode = entry.mode,
position = "auto",
scale = entry.scale,
transform = entry.transform,
})
end
end
end
-- Any monitor not named above: sane defaults rather than nothing.
hl.monitor({
output = "",
@@ -79,6 +79,14 @@ Singleton {
persistTimer.restart();
}
// Re-read the file from disk. Used after something outside the shell has
// rewritten it -- restoring a snapshot, or a hand edit -- so the running
// desktop reflects the new contents without waiting for the next change.
function reload(): void {
preferencesFile.reload();
root.load();
}
function load(): void {
let parsed = {};
try {
@@ -82,6 +82,14 @@ Singleton {
values.initialized = true;
}
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) {
@@ -15,7 +15,7 @@ pragma Singleton
//
// Entry fields
// key unique identifier; also the JSON key on disk
// type "bool" | "int" | "real" | "string" | "enum"
// type "bool" | "int" | "real" | "string" | "enum" | "json"
// def shipped default, used when the file is absent or a value is invalid
// min/max inclusive bounds for int and real; values outside are clamped
// step UI increment for int and real
@@ -25,6 +25,13 @@ pragma Singleton
// detail one line explaining what changing it does
// internal true for state the shell keeps but the user never edits directly
// pattern for "string": a regular expression the value must match in full
//
// "json" holds a structured value -- a list or an object -- that the schema
// stores and resets but does not validate field by field. It exists so that
// settings like the dock's pinned applications live in the same file, and are
// covered by the same reset, as everything else rather than growing a fourth
// preference store. The service that owns such a value is responsible for
// validating it; see services/Dock-related consumers.
// hypr present when the setting maps onto an Hyprland option:
// path the hl.config table path, e.g. ["decoration","blur","size"]
// option the getoption path used to read the value back
@@ -86,12 +93,14 @@ Singleton {
},
{
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
unit: "ms",
group: "dock",
label: "Reveal delay",
detail: "Zero reveals the Dock the instant the pointer reaches the edge"
},
{
key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
unit: "ms",
group: "dock",
label: "Hide delay",
detail: "Prevents flicker when crossing between icons"
@@ -100,6 +109,7 @@ Singleton {
// ── Focus ───────────────────────────────────────────────────────────
{
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min",
group: "focus",
label: "Focus session length",
detail: "How long a focus session runs before it ends itself"
@@ -143,6 +153,7 @@ Singleton {
// the Hyprland config still stands on its own.
{
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
unit: "px",
group: "windows",
label: "Inner gaps",
detail: "Space between neighbouring tiled windows",
@@ -150,6 +161,7 @@ Singleton {
},
{
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
unit: "px",
group: "windows",
label: "Outer gaps",
detail: "Space between the tiled area and the screen edge",
@@ -157,6 +169,7 @@ Singleton {
},
{
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
unit: "px",
group: "windows",
label: "Border width",
detail: "Thickness of the gradient border on the focused window",
@@ -164,6 +177,7 @@ Singleton {
},
{
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
unit: "px",
group: "windows",
label: "Corner radius",
detail: "Matches the shell's popover radius so windows and panels agree",
@@ -206,6 +220,7 @@ Singleton {
},
{
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
unit: "px",
group: "effects",
label: "Shadow size",
detail: "How far the shadow spreads from the window edge",
@@ -219,6 +234,7 @@ Singleton {
},
{
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
unit: "px",
group: "effects",
label: "Glow size",
detail: "Kept small deliberately: the gradient border is the signature",
@@ -249,6 +265,7 @@ Singleton {
},
{
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
unit: "ms",
group: "input",
label: "Repeat delay",
detail: "How long a key is held before it starts repeating",
@@ -256,6 +273,7 @@ Singleton {
},
{
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
unit: "/s",
group: "input",
label: "Repeat rate",
detail: "How many characters a second a held key produces",
@@ -281,10 +299,15 @@ Singleton {
},
{
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s",
group: "input",
label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "int" }
// Reported as a float even though it is only ever set to whole
// seconds. `readAs` describes what getoption answers with, not what
// the setting means -- getting this wrong makes every write to it
// look rejected.
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
},
// ── Night light ─────────────────────────────────────────────────────
@@ -300,11 +323,332 @@ Singleton {
},
{
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
unit: "K",
group: "nightLight",
label: "Color temperature",
detail: "Lower is warmer"
},
// ── Desktop background ──────────────────────────────────────────────
// Applied through hyprpaper's IPC. Not an hl.config option, so it has
// no `hypr` block; services/Wallpaper.qml owns applying it.
{
key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
// Reaches hyprpaper as the "<output>,<path>" argument form, so a
// comma would split it into a different request. Absolute paths
// only, no commas, no newlines.
pattern: "^(|/[^,\\n]+)$",
label: "Wallpaper",
detail: "Shown on every output"
},
// ── Idle, lock, and sleep ───────────────────────────────────────────
// Written into a generated hypridle config; see scripts/panama-idle.
// Zero means never for all three.
{
key: "screenBlankMinutes", type: "int", def: 5, min: 0, max: 120, step: 1,
unit: "min", group: "idle",
label: "Turn the screen off after",
detail: "Blanks the display; nothing is locked yet"
},
{
key: "lockMinutes", type: "int", def: 10, min: 0, max: 240, step: 1,
unit: "min", group: "idle",
label: "Lock the screen after",
detail: "Counted from when the session went idle, not from blanking"
},
{
key: "suspendMinutes", type: "int", def: 0, min: 0, max: 480, step: 5,
unit: "min", group: "idle",
label: "Suspend after",
detail: "This is a desktop, so Panama ships with automatic suspend off"
},
{
key: "lockOnSleep", type: "bool", def: true, group: "idle",
label: "Lock before sleeping",
detail: "Requires your password when the machine wakes"
},
// ── Night light schedule ────────────────────────────────────────────
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
// is normal here and is what the shipped values do: on at 17:00, off at
// 10:00 the following morning.
{
key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
group: "nightLight",
label: "Turns on at",
detail: "Only used when Night Light follows a schedule"
},
{
key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
group: "nightLight",
label: "Turns off at",
detail: "A time earlier than the start simply means the next morning"
},
// ── Colour scheme ───────────────────────────────────────────────────
// Light is Tokyo Night Day, the official light variant, rather than a
// palette invented to merely not be dark. Both share the same hues at
// different lightness, which is what keeps the Prism identity intact
// across the switch.
//
// services/ColorScheme.qml pushes the choice to GTK and to the
// compositor's border colours, because an application toolbar or a
// window border still wearing the other scheme is more jarring than
// either scheme on its own.
{
key: "colorScheme", type: "enum", def: "dark", group: "appearance",
label: "Appearance",
detail: "Light and dark share one identity, not two themes",
options: [
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" }
]
},
// ── Typography ──────────────────────────────────────────────────────
// The single largest thing in this desktop that used to be changeable
// only by editing Theme.qml.
//
// interfaceFont is every piece of text a person reads. iconFont is used
// ONLY to draw glyphs -- workspace pills, the status cluster, search
// icons -- so it must carry Nerd Font glyphs; a plain monospace family
// there replaces every icon in the shell with tofu, which is why the
// picker offers them as a separate, filtered list.
{
key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography",
// A family name, matched by fontconfig. Constrained because it also
// reaches hl.config as a string in hypr/looks.lua.
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
label: "Interface font",
detail: "Used for every piece of text in the shell"
},
{
key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
label: "Icon font",
detail: "Draws the shell's glyphs, so it must be a Nerd Font"
},
{
key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1,
unit: "px", group: "typography",
label: "Interface text size",
detail: "The base size the rest of the shell's type scales from"
},
// ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and
// pushed to the compositor as well where it has its own notion.
{
key: "cursorSize", type: "int", def: 24, min: 16, max: 64, step: 4,
unit: "px", group: "accessibility",
label: "Pointer size",
detail: "Applies to the compositor and to applications"
},
{
key: "textScale", type: "real", def: 1.0, min: 0.75, max: 2.0, step: 0.05,
group: "accessibility",
label: "Text size",
detail: "Scales interface text everywhere; 1.00 is the design size"
},
// ── Weather ─────────────────────────────────────────────────────────
{
key: "temperatureUnit", type: "enum", def: "fahrenheit", group: "weather",
label: "Temperature unit",
detail: "Choose Fahrenheit or Celsius for the weather card",
options: [
{ value: "fahrenheit", label: "Fahrenheit" },
{ value: "celsius", label: "Celsius" }
]
},
{
key: "weatherRefreshMinutes", type: "int", def: 20, min: 5, max: 120, step: 5,
unit: "min", group: "weather",
label: "Weather refresh",
detail: "How often Panama updates the current conditions"
},
// ── Which GPU the vitals readout tracks ─────────────────────────────
// A sysfs path rather than a card number, because the number is neither
// stable across machines nor meaningful. Constrained to the one shape
// that can be read for utilisation; VitalsWidget hides itself when the
// path is unreadable, so a stale value degrades to no readout rather
// than a wrong one.
{
key: "gpuBusyPath", type: "string",
def: "/sys/class/drm/card1/device/gpu_busy_percent",
group: "vitals", internal: true,
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
label: "Graphics device",
detail: "Which GPU the graphics readout in the bar measures"
},
// ── Weather location ────────────────────────────────────────────────
// Coordinates rather than a place name, because that is what Open-Meteo
// takes and it needs no API key. weatherLocation is only the label shown
// in the UI; it is never sent anywhere, so it can say whatever makes the
// reading recognisable.
{
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
group: "weather", internal: true,
label: "Latitude",
detail: "Set by choosing a location"
},
{
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
group: "weather", internal: true,
label: "Longitude",
detail: "Set by choosing a location"
},
{
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
internal: true,
// Display only -- never sent to the weather service.
pattern: "^[^\\n]{1,64}$",
label: "Weather location",
detail: "The place the weather reading is for"
},
// ── Vitals refresh ──────────────────────────────────────────────────
{
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
unit: "ms", group: "vitals",
label: "Vitals refresh",
detail: "How often processor, memory, and graphics usage update"
},
// ── Notifications ───────────────────────────────────────────────────
{
key: "notificationTimeoutMs", type: "int", def: 5000, min: 1000, max: 30000, step: 500,
unit: "ms", group: "notifications",
label: "Notification duration",
detail: "How long ordinary notification banners remain visible"
},
{
key: "notificationTimeoutCriticalMs", type: "int", def: 0, min: 0, max: 60000, step: 1000,
unit: "ms", group: "notifications",
label: "Critical notification duration",
detail: "Zero keeps critical notification banners visible until dismissed"
},
{
key: "notificationHistoryLimit", type: "int", def: 100, min: 10, max: 500, step: 10,
group: "notifications",
label: "Notification history",
detail: "Maximum notifications retained in the notification center"
},
{
key: "maxVisibleToasts", type: "int", def: 4, min: 1, max: 8, step: 1,
group: "notifications",
label: "Visible banners",
detail: "Maximum notification banners shown at once"
},
// ── Capture ─────────────────────────────────────────────────────────
// Directories and encoder arguments are enums rather than free text:
// both are handed to a recorder process, and an arbitrary string there
// is a much larger surface than a settings page needs to expose.
{
key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
label: "Screenshot folder",
detail: "Folder under your home directory for screenshots",
options: [
{ value: "Pictures/Screenshots", label: "Pictures / Screenshots" },
{ value: "Pictures", label: "Pictures" },
{ value: "Desktop", label: "Desktop" }
]
},
{
key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
label: "Recording folder",
detail: "Folder under your home directory for screen recordings",
options: [
{ value: "Videos/Recordings", label: "Videos / Recordings" },
{ value: "Videos", label: "Videos" },
{ value: "Desktop", label: "Desktop" }
]
},
{
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
group: "capture",
label: "Recording encoder",
detail: "Hardware encoding keeps recording off the processor while gaming",
options: [
{ value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
{ value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
{ value: "-c libx264", label: "CPU x264" }
]
},
// ── Dock contents ───────────────────────────────────────────────────
// A "json" value: the ordered list of desktop entry ids pinned to the
// dock. Kept in the shared store so that reordering the dock is covered
// by Restore defaults like everything else, rather than living in its
// own file. The shipped order is the GNOME dash it replaced, with one
// deliberate substitution: Panama Settings takes the first slot rather
// than GNOME Settings. Panama now covers what GNOME Settings did for
// this desktop and delegates the remainder to it by name, so pinning
// the thing it delegates TO put the fallback in front of the real one.
// GNOME Settings stays installed and searchable in the launcher.
{
key: "dockPinned", type: "json", group: "dock",
label: "Pinned applications",
detail: "Applications that stay in the Dock whether or not they are running",
def: [
"panama-settings", "kitty", "org.gnome.Nautilus",
"com.bitwarden.desktop", "org.gnome.Software", "helium",
"org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk",
"io.podman_desktop.PodmanDesktop", "claude-desktop",
"codex-desktop", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam"
]
},
// ── Keyboard shortcut overrides ─────────────────────────────────────
// { "<bind description>": "<chord>" }. Only the chord is stored: the
// action always comes from hypr/keybinds.lua, so an override can move a
// shortcut but can never make one do something else. The Lua validates
// each chord and falls back to the shipped one, so a hand-edited file
// cannot cost you a keymap.
//
// Edited through the Input & Shortcuts page rather than as a row, hence
// internal.
{
key: "keybindOverrides", type: "json", def: ({}), group: "input",
internal: true,
label: "Keyboard shortcut overrides",
detail: "Shortcuts you have moved from their shipped chord"
},
// ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform } }, applied by
// hypr/monitors.lua on top of the shipped values. Colour management and
// bit depth are deliberately not here: those carry a documented
// screencopy tradeoff that a settings page cannot explain at the moment
// you would be changing it.
{
key: "displays", type: "json", def: ({}), group: "display",
internal: true,
label: "Display configuration",
detail: "Resolution, scale, and rotation per connected display"
},
// ── Per-application notification rules ──────────────────────────────
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
//
// Absent means "no rule", which is not the same as a rule that allows
// everything: a new application must be able to notify without needing
// an entry written for it first. services/Notifs.qml treats a missing
// entry as permissive and Do Not Disturb remains an override on top,
// rather than being duplicated per application.
{
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
internal: true,
label: "Application notification rules",
detail: "Per-application notification and lock-screen visibility preferences"
},
// ── Internal ────────────────────────────────────────────────────────
{
key: "lastPage", type: "string", def: "home", group: "internal",
@@ -382,6 +726,13 @@ Singleton {
case "enum":
return entry.options.some(option => option.value === value) ? value : undefined;
case "json":
// Accepted as-is. Anything JSON.parse produced is representable,
// and per-field meaning belongs to the owning service rather than
// here. A scalar is rejected so a corrupt file falls back to the
// default instead of handing a list-shaped consumer a number.
return (typeof value === "object") ? value : undefined;
case "string": {
const text = typeof value === "string" ? value : String(value);
// A constrained string is rejected rather than sanitised. Several
+17 -34
View File
@@ -22,39 +22,39 @@ Singleton {
// ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already
// resolved the location. Uses Open-Meteo, which needs no API key.
readonly property real latitude: 27.7375
readonly property real longitude: -82.6861
readonly property real latitude: DesktopPreferences.get("weatherLatitude")
readonly property real longitude: DesktopPreferences.get("weatherLongitude")
// Open-Meteo returns coordinates but no friendly place name. Keep the
// label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them.
readonly property string weatherLocation: "Local weather"
readonly property string temperatureUnit: "fahrenheit"
readonly property int weatherRefreshMinutes: 20
readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
// ── Vitals ──────────────────────────────────────────────────────────────
// The GNOME Vitals extension showed processor usage, memory usage and GPU
// usage, in that order. Same here.
readonly property int vitalsIntervalMs: 2000
readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu")
// amdgpu exposes utilisation here. Verified present on this machine; the
// widget hides itself if the path is missing rather than showing zeros.
readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent"
readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath")
// ── Night light ─────────────────────────────────────────────────────────
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
readonly property real nightLightFrom: 17.0
readonly property real nightLightTo: 10.0
readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ───────────────────────────────────────────────────────
readonly property int notificationTimeoutMs: 5000
readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire
readonly property int notificationHistoryLimit: 100
readonly property int maxVisibleToasts: 4
readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and
@@ -63,24 +63,7 @@ Singleton {
// ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites.
readonly property list<string> dockPinned: [
"org.gnome.Settings",
"kitty",
"org.gnome.Nautilus",
"com.bitwarden.desktop",
"org.gnome.Software",
"helium",
"org.mozilla.thunderbird_esr",
"com.slack.Slack",
"app.bluebubbles.BlueBubbles",
"rustdesk",
"io.podman_desktop.PodmanDesktop",
"claude-desktop",
"codex-desktop",
"md.obsidian.Obsidian",
"com.obsproject.Studio",
"steam"
]
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
// Dash-to-Dock was set to intellihide against all windows: the dock hides
// when any window would overlap it, and comes back on hover.
@@ -96,9 +79,9 @@ Singleton {
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
// ── Capture ─────────────────────────────────────────────────────────────
readonly property string screenshotDir: "Pictures/Screenshots"
readonly property string recordingDir: "Videos/Recordings"
readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
readonly property string recordingDir: DesktopPreferences.get("recordingDir")
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming.
readonly property string recorderArgs: "-c h264_vaapi -d /dev/dri/renderD128"
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
}
+49 -32
View File
@@ -18,36 +18,47 @@ import QtQuick
Singleton {
id: root
// ── Colour scheme ───────────────────────────────────────────────────────
// Tokyo Night ships an official light variant (Day), so light mode is that
// rather than a palette invented to merely not be dark. The two share the
// same hues at different lightness, which is what lets the Prism identity
// survive the switch: blue still leads into orchid, it is simply a darker
// blue on a lighter ground.
//
// Every token below is a binding on this, so flipping it repaints the whole
// shell without anything needing to know it happened.
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// ── Palette ─────────────────────────────────────────────────────────────
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
readonly property color bg: "#222436"
readonly property color bgDark: "#1e2030"
readonly property color bgHighlight: "#2f334d"
readonly property color bgPanel: "#2e2f3d" // dock glass surface
readonly property color bgPopover: "#21212f" // Openbar submenu background
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
readonly property color fg: "#c8d3f5"
readonly property color fgDim: "#828bb8"
readonly property color fgMuted: "#636da6"
readonly property color gutter: "#3b4261"
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
// The pair. `accent` is the primary and carries every state meaning
// (focused, active, on). `accentSecondary` is the orchid from the tmux
// theme — it never appears alone, only as the far end of a gradient. That
// restraint is the whole point: the two colours meeting is the signature,
// so the pink stops being special the moment it's used as a flat fill.
readonly property color accent: "#82aaff" // blue
readonly property color accentSecondary: "#b172b0" // orchid, from tmux
readonly property color accentAlt: "#65bcff" // blue1, a lighter blue
readonly property color cyan: "#86e1fc"
readonly property color teal: "#4fd6be"
readonly property color green: "#c3e88d"
readonly property color yellow: "#ffc777"
readonly property color orange: "#ff966c"
readonly property color red: "#ff757f"
readonly property color redDeep: "#c53b53"
readonly property color magenta: "#c099ff"
readonly property color pink: "#fca7ea"
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
readonly property color green: root.dark ? "#c3e88d" : "#587539"
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
// Semantic aliases — prefer these in widgets so intent survives a repaint.
readonly property color ok: green
@@ -59,11 +70,14 @@ Singleton {
// The dock sits on compositor blur, so its fill is deliberately very
// translucent. Popovers are near-opaque because they carry text that must
// stay legible. The bar itself has no surface or alpha token.
readonly property real dockAlpha: 0.34
readonly property real popoverAlpha: 0.92
readonly property real overlayAlpha: 0.55
readonly property real hoverAlpha: 0.14
readonly property real activeAlpha: 0.24
// Light surfaces need more opacity for the same sense of a solid panel: the
// same 0.34 that reads as glass over a dark desktop reads as haze over a
// light one, and text on it stops being legible.
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
// ── Geometry ────────────────────────────────────────────────────────────
readonly property int barHeight: 36
@@ -90,12 +104,12 @@ Singleton {
// Adwaita Sans for everything the user reads as text. No exceptions: a
// monospaced clock or percentage reads as a terminal readout pasted into a
// UI, which is the opposite of the intent here.
readonly property string fontFamily: "Adwaita Sans"
readonly property string fontFamily: DesktopPreferences.get("interfaceFont")
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
// pragmatic alternative to freedesktop symbolic icons, which ship with a
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
readonly property string fontMono: "VictorMono Nerd Font"
readonly property string fontMono: DesktopPreferences.get("iconFont")
// Apply to any text whose digits change in place — clocks, percentages,
// elapsed timers, dimension readouts. Tabular figures all share one
@@ -107,10 +121,13 @@ Singleton {
readonly property var tabularFigures: ({
"tnum": 1
})
readonly property int fontSize: 13
readonly property int fontSizeSmall: 11
readonly property int fontSizeLarge: 16
readonly property int fontSizeTitle: 20
// The four sizes are a scale, not four independent numbers: they move
// together so the relationship between body, caption, heading and title
// survives a change to the base rather than drifting apart.
readonly property int fontSize: DesktopPreferences.get("interfaceFontSize")
readonly property int fontSizeSmall: Math.max(9, root.fontSize - 2)
readonly property int fontSizeLarge: root.fontSize + 3
readonly property int fontSizeTitle: root.fontSize + 7
// ── Motion ──────────────────────────────────────────────────────────────
// Event-driven only. Nothing in this shell animates while idle — no pulse,
@@ -0,0 +1,28 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
IpcHandler {
target: "connectivity-test"
function status(): string {
return JSON.stringify({
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
networks: Connectivity.networks.length,
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
adapter: Connectivity.adapter ? true : false,
btDevices: Connectivity.bluetoothDevices.length
});
}
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
function setActive(on: bool): void { Connectivity.active = on; }
}
}
@@ -0,0 +1,75 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "displays-test"
function status(): string {
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
return JSON.stringify({
count: Displays.monitors.length,
name: monitor ? monitor.name : "",
width: monitor ? monitor.width : 0,
height: monitor ? monitor.height : 0,
refresh: monitor ? monitor.refreshRate : 0,
mode: monitor ? monitor.mode : "",
scale: monitor ? monitor.scale : 0,
transform: monitor ? monitor.transform : -1,
modes: monitor ? monitor.modes.length : 0,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
secondsLeft: Displays.secondsLeft,
lastError: Displays.lastError,
overridden: monitor ? Displays.isOverridden(monitor.name) : false
});
}
function applyScale(scale: real): bool {
const monitor = Displays.monitors[0];
if (!monitor) return false;
const mode = monitor.mode;
return Displays.apply(monitor.name, mode, scale, monitor.transform);
}
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
"[email protected]",
"[email protected]"
]);
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
return JSON.stringify({
count: modes.length,
modes: modes.map(mode => mode.mode),
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
});
}
function applyBad(kind: string): bool {
const monitor = Displays.monitors[0];
if (!monitor) return false;
const mode = monitor.mode;
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
if (kind === "dirtyScale") {
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
}
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
return false;
}
function confirmChange(): bool { return Displays.confirm(); }
function revertChange(): void { Displays.revert(); }
function forget(): void {
const monitor = Displays.monitors[0];
if (monitor) Displays.forget(monitor.name);
}
function refresh(): void { Displays.refresh(); }
}
}
@@ -0,0 +1,35 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
// DesktopEntries populates asynchronously. Reading `applications.values`
// here rather than calling byId() blind is the documented way to wait for
// it -- and is exactly the trap noted in modules/settings/README.md.
readonly property var ids: {
const out = {};
for (const entry of DesktopEntries.applications.values)
out[entry.id] = entry;
return out;
}
IpcHandler {
target: "dock-pin-test"
function count(): int { return DesktopEntries.applications.values.length; }
function resolve(): string {
const pinned = DesktopPreferences.get("dockPinned");
const missing = [];
let first = null;
for (const id of pinned) {
const entry = DesktopEntries.byId(id);
if (!entry) missing.push(id);
if (first === null)
first = { id: id, found: !!entry, name: entry ? entry.name : "", icon: entry ? entry.icon : "" };
}
return JSON.stringify({ total: pinned.length, first: first, missing: missing });
}
}
}
@@ -13,6 +13,7 @@ ShellRoot {
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); }
function reset(): void { HomePreferences.resetHomeDefaults(); }
function status(): string {
return JSON.stringify({
initialized: HomePreferences.initialized,
@@ -0,0 +1,58 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "keybinds-test"
function rebind(current: string, next: string): bool {
return Keybinds.rebind(current, next);
}
function resetBind(current: string): bool { return Keybinds.resetBind(current); }
function resetAll(): void { Keybinds.resetAll(); }
function seedResetCollision(shipped: string, current: string, occupantShipped: string): void {
const overrides = {};
overrides[shipped] = current;
overrides[occupantShipped] = shipped;
DesktopPreferences.set("keybindOverrides", overrides);
}
function chordFor(description: string): string {
const found = Keybinds.binds.find(bind => bind.description === description);
return found ? found.luaChord : "";
}
function overrideState(): string {
return JSON.stringify({
overrides: Keybinds.overrides,
count: Object.keys(Keybinds.overrides).length,
lastError: Keybinds.lastError
});
}
function status(): string {
const grouped = Keybinds.grouped();
let groupedCount = 0;
for (const group of grouped)
groupedCount += group.binds.length;
const superT = Keybinds.binds.find(bind => bind.description === "Terminal");
return JSON.stringify({
loaded: Keybinds.loaded,
count: Keybinds.binds.length,
groupedCount: groupedCount,
groups: grouped.map(group => group.name),
sample: superT ? superT.chord : "",
emptyDescriptions: Keybinds.binds.filter(bind => !bind.description).length,
lastError: Keybinds.lastError
});
}
}
}
+177
View File
@@ -0,0 +1,177 @@
// Compact, click-through system feedback. Repeated hardware-key presses update
// this one surface in place, avoiding a stack of notification cards.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
PanelWindow {
id: root
property var modelData: null
readonly property var presentation: OsdState.state
readonly property bool belongsHere: !OsdState.monitorName || !root.modelData
|| root.modelData.name === OsdState.monitorName
readonly property bool requestedVisible: OsdState.active && root.belongsHere
readonly property int horizontalPadding: 18
readonly property int contentGap: 14
readonly property int progressWidth: 156
readonly property int progressLabelWidth: 48
readonly property int messageWidth: Math.min(300, Math.max(52, Math.ceil(labelMetrics.advanceWidth)))
readonly property int desiredWidth: root.horizontalPadding * 2 + 24 + root.contentGap
+ (root.presentation.progress
? root.progressWidth + root.contentGap + root.progressLabelWidth
: root.messageWidth)
screen: root.modelData
anchors.bottom: true
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
implicitWidth: root.desiredWidth
implicitHeight: 64
color: "transparent"
mask: Region {}
WlrLayershell.namespace: "qs-popover-osd"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
property bool mapped: false
visible: root.mapped
onRequestedVisibleChanged: {
if (root.requestedVisible) {
unmapTimer.stop();
root.mapped = true;
entrance.restart();
} else if (root.mapped) {
unmapTimer.restart();
}
}
Component.onCompleted: {
if (root.requestedVisible)
root.mapped = true;
}
Timer {
id: unmapTimer
interval: Theme.durNormal
onTriggered: root.mapped = false
}
Rectangle {
id: surface
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, 0.9)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
opacity: root.requestedVisible ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
}
transform: Translate {
id: slide
y: root.requestedVisible ? 0 : 8
Behavior on y {
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
}
}
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Row {
anchors.centerIn: parent
spacing: root.contentGap
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 24
icon: root.presentation.icon
iconFallback: "dialog-information-symbolic"
tint: Theme.fg
}
Item {
visible: root.presentation.progress
anchors.verticalCenter: parent.verticalCenter
width: visible ? root.progressWidth : 0
height: 8
Rectangle {
anchors.fill: parent
radius: height / 2
color: Theme.alpha(Theme.fg, 0.14)
}
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * root.presentation.ratio
radius: height / 2
color: Theme.accent
Behavior on width {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
}
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: root.presentation.progress ? root.progressLabelWidth : root.messageWidth
text: root.presentation.label
color: Theme.fg
elide: Text.ElideRight
maximumLineCount: 1
horizontalAlignment: root.presentation.progress ? Text.AlignRight : Text.AlignLeft
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
}
}
TextMetrics {
id: labelMetrics
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
text: root.presentation.label
}
ParallelAnimation {
id: entrance
NumberAnimation {
target: surface
property: "opacity"
from: 0
to: 1
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
NumberAnimation {
target: slide
property: "y"
from: 8
to: 0
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
}
@@ -0,0 +1,87 @@
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function finiteNumber(value, fallback) {
var parsed = Number(value);
return isFinite(parsed) ? parsed : fallback;
}
function iconFor(kind, ratio) {
var name = String(kind || "").toLowerCase();
if (name === "volume-muted")
return "audio-volume-muted-symbolic";
if (name === "volume") {
if (ratio <= 0)
return "audio-volume-muted-symbolic";
if (ratio < 0.34)
return "audio-volume-low-symbolic";
if (ratio < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
if (name === "microphone-muted")
return "microphone-sensitivity-muted-symbolic";
if (name === "microphone")
return "audio-input-microphone-symbolic";
if (name === "brightness")
return "display-brightness-symbolic";
if (name === "media-play" || name === "media-playing")
return "media-playback-start-symbolic";
if (name === "media-pause" || name === "media-paused")
return "media-playback-pause-symbolic";
if (name === "media-next")
return "media-skip-forward-symbolic";
if (name === "media-previous")
return "media-skip-backward-symbolic";
if (name === "media-stop")
return "media-playback-stop-symbolic";
return name || "dialog-information-symbolic";
}
function normalizedDuration(value) {
var parsed = finiteNumber(value, 1400);
return Math.max(0, Math.round(parsed));
}
function progressState(kind, rawValue, rawMaximum, rawLabel, rawDuration) {
var maximum = Math.max(1, finiteNumber(rawMaximum, 100));
var value = clamp(finiteNumber(rawValue, 0), 0, maximum);
var ratio = value / maximum;
var label = String(rawLabel || "");
if (!label)
label = Math.round(ratio * 100) + "%";
return {
kind: String(kind || ""),
value: value,
maximum: maximum,
ratio: ratio,
label: label,
icon: iconFor(kind, ratio),
duration: normalizedDuration(rawDuration),
progress: true
};
}
function messageState(kind, rawLabel, rawDuration) {
return {
kind: String(kind || ""),
value: 0,
maximum: 100,
ratio: 0,
label: String(rawLabel || ""),
icon: iconFor(kind, 0),
duration: normalizedDuration(rawDuration),
progress: false
};
}
if (typeof module !== "undefined") {
module.exports = {
clamp: clamp,
iconFor: iconFor,
progressState: progressState,
messageState: messageState
};
}
+2
View File
@@ -0,0 +1,2 @@
module qs.modules.osd
Osd 1.0 Osd.qml
@@ -6,6 +6,7 @@ import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
import qs.services
Item {
id: root
@@ -16,23 +17,12 @@ Item {
implicitHeight: list.implicitHeight
readonly property var nodes: {
return Pipewire.nodes.values.filter(n => {
if (n.isStream)
return false;
// Sources have to be filtered on the type flags: !isSink also
// matches video nodes (webcams show up here otherwise).
return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource;
});
}
readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs
readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource
readonly property var current: AudioDevices.current(root.output)
function select(node): void {
if (root.output)
Pipewire.preferredDefaultAudioSink = node;
else
Pipewire.preferredDefaultAudioSource = node;
AudioDevices.select(root.output, node)
}
ScrollColumn {
@@ -52,7 +42,7 @@ Item {
implicitHeight: 38
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-symbolic"
label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name
label: AudioDevices.label(nodeRow.modelData)
selected: nodeRow.modelData === root.current
onClicked: root.select(nodeRow.modelData)
}
@@ -3,39 +3,49 @@ import Quickshell
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "A curated Hyprland desktop built around focus, speed, and good taste."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsPage {
title: "About Panama"
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
SettingsCard {
title: "Panama Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
TextRow {
label: "Hyprland"
value: SystemSettings.hyprlandVersion || "Detecting…"
}
TextRow {
label: "Quickshell"
value: SystemSettings.quickshellVersion
}
TextRow {
label: "Display"
value: SystemSettings.monitorName || "Detecting…"
}
TextRow {
label: "Configuration"
detail: Quickshell.shellDir
value: "Local"
divider: false
}
}
SettingsCard {
title: "Design principles"
SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" }
SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" }
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false }
TextRow {
label: "Curated by default"
detail: "Strong choices instead of an incoherent matrix of switches"
}
TextRow {
label: "Quiet while idle"
detail: "No continuous decorative repaint loops"
}
TextRow {
label: "Real system boundaries"
detail: "Every control either works or clearly hands off to its owner"
divider: false
}
}
}
@@ -0,0 +1,65 @@
// Accessibility.
//
// Pointer size and text scale have to agree across three consumers that share
// no configuration system -- the compositor, GTK applications, and the shell.
// Panama's store is the source of truth and services/Accessibility.qml pushes
// the value to the other two.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Accessibility"
lede: "Make the desktop easier to see and easier to hit."
SettingsCard {
title: "Pointer"
subtitle: "Applied to the compositor and to applications at the same time."
SliderRow { setting: "cursorSize" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard {
title: "Text"
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected."
SliderRow { setting: "textScale"; divider: false }
}
SettingsCard {
title: "Motion"
subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
ToggleRow { setting: "animationsEnabled"; divider: false }
}
SettingsCard {
title: "Contrast"
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read."
SliderRow { setting: "inactiveOpacity"; divider: false }
}
SettingsCard {
title: "System accessibility"
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack."
ActionRow {
label: "GNOME accessibility settings"
detail: "Opens in GNOME Settings"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("universal-access")
}
}
SettingsCard {
visible: Accessibility.lastError !== ""
title: "Could not apply"
subtitle: Accessibility.lastError
}
}
@@ -0,0 +1,34 @@
// A row whose trailing control is a button rather than a setting.
//
// ActionRow {
// label: "Keyboard"
// detail: "Use Fedora's hardware-backed input panels"
// action: "Open keyboard"
// onTriggered: SystemSettings.openGnomePanel("keyboard")
// }
//
// Not schema-bound: these hand off to GNOME, launch an application, or run a
// one-shot like restoring defaults. There is no key to name.
import QtQuick
import qs.config
SettingRow {
id: root
property string action: ""
property bool enabled: true
signal triggered
controlWidth: Math.max(110, button.implicitWidth + 8)
SettingsButton {
id: button
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.action
enabled: root.enabled
onClicked: root.triggered()
}
}
@@ -1,61 +1,192 @@
// Appearance — the page the rest of the settings vocabulary was built for.
//
// Before Stage 3 this page adjusted a clock format and three vitals toggles,
// and its "Theme" card was two rows of text pretending to be controls. It now
// drives the compositor directly: every row here is applied through
// `hyprctl eval` and confirmed by reading the value back before it is stored.
//
// The preview is pinned above the controls rather than sitting inline, because
// the numbers are meaningless on their own -- "outer gaps 24" only means
// something once you have watched the windows move apart by that much, at the
// scale of the display you actually use.
import QtQuick
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
title: "Appearance"
lede: "Drag anything below. The preview above is your real geometry, to scale."
header: Component {
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
spacing: 9
Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
DesktopPreview {
width: parent.width
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: SystemSettings.lastError !== ""
? SystemSettings.lastError
: "Live preview — the focused window wears the prism border"
color: SystemSettings.lastError !== "" ? Theme.warn : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
title: "Background"
subtitle: Wallpaper.lastError !== ""
? Wallpaper.lastError
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperPicker {
id: wallpapers
width: parent.width
}
ActionRow {
visible: wallpapers.hidden > 0 || wallpapers.expanded
label: wallpapers.expanded ? "Showing every image" : wallpapers.hidden + " more available"
detail: "The grid is kept short so the rest of this page stays reachable"
action: wallpapers.expanded ? "Show fewer" : "Show all"
onTriggered: wallpapers.expanded = !wallpapers.expanded
}
ActionRow {
label: "Look for new images"
detail: Wallpaper.scanning
? "Scanning…"
: Wallpaper.available.length + " image" + (Wallpaper.available.length === 1 ? "" : "s") + " found"
action: "Rescan"
divider: false
enabled: !Wallpaper.scanning
onTriggered: Wallpaper.rescan()
}
}
SettingsCard {
title: "Colour scheme"
subtitle: ColorScheme.lastError !== ""
? ColorScheme.lastError
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
ChoiceRow { setting: "colorScheme"; divider: false }
}
SettingsCard {
title: "Typography"
subtitle: Fonts.lastError !== ""
? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name."
SliderRow { setting: "interfaceFontSize" }
TextRow {
label: "Interface font"
detail: Fonts.interfaceMissing
? "Not installed on this machine — fontconfig is substituting something else"
: "Used for all shell text"
value: Fonts.interfaceFont
}
FontPicker {
width: parent.width
families: Fonts.interfaceFonts
current: Fonts.interfaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No interface fonts found"
onPicked: family => Fonts.setInterface(family)
}
TextRow {
label: "Icon font"
detail: Fonts.iconMissing
? "Not installed — the shell's glyphs will not draw correctly"
: "Draws the shell's glyphs, so only Nerd Fonts are offered"
value: Fonts.iconFont
}
FontPicker {
width: parent.width
families: Fonts.iconFonts
current: Fonts.iconFont
emptyText: "No Nerd Fonts installed"
onPicked: family => Fonts.setIcon(family)
}
}
SettingsCard {
title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
SliderRow { setting: "windowRounding" }
SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" }
SliderRow { setting: "inactiveOpacity"; divider: false }
}
SettingsCard {
title: "Effects"
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
ToggleRow { setting: "blurEnabled" }
SliderRow { setting: "blurSize" }
SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false }
}
SettingsCard {
title: "Clock"
SettingRow {
label: "24-hour time"
detail: "Use 18:30 instead of 6:30 PM"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("use24Hour"); onToggled: value => DesktopPreferences.set("use24Hour", value) }
}
SettingRow {
label: "Show seconds"
detail: "Keep a precise clock in the center of the bar"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showSeconds"); onToggled: value => DesktopPreferences.set("showSeconds", value) }
}
SettingRow {
label: "Show weekday"
detail: "Include the abbreviated weekday before the date"
divider: false
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showWeekday"); onToggled: value => DesktopPreferences.set("showWeekday", value) }
}
ToggleRow { setting: "use24Hour" }
ToggleRow { setting: "showSeconds" }
ToggleRow { setting: "showWeekday"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator."
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showCpu"); onToggled: value => DesktopPreferences.set("showCpu", value) } }
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showMemory"); onToggled: value => DesktopPreferences.set("showMemory", value) } }
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showGpu"); onToggled: value => DesktopPreferences.set("showGpu", value) } }
ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
// Only worth asking when there is a choice to make.
ChoiceGrid {
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
width: parent.width
label: "Graphics device"
detail: GraphicsDevices.selectionMissing
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
: "Which GPU the graphics readout measures."
options: GraphicsDevices.devices.map(device => ({
value: device.path,
label: GraphicsDevices.shortName(device.name)
}))
current: GraphicsDevices.selectedPath
divider: false
onPicked: value => GraphicsDevices.select(value)
}
}
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
}
}
@@ -0,0 +1,217 @@
// Applications and session startup.
import Quickshell
import QtQuick
import qs.services
SettingsPage {
id: root
objectName: "applications"
title: "Applications"
lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: ""
readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] }
]
function desktopId(entry: var): string {
const entryId = String(entry?.id ?? "");
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
}
function displayName(entry: var): string {
return String(entry?.name || entry?.genericName || root.desktopId(entry));
}
function currentHandler(role: string): string {
return String(DefaultApps.handlers[role] ?? "");
}
function currentEntry(role: string): var {
const handler = root.currentHandler(role);
return root.applications.find(entry => root.desktopId(entry) === handler) ?? null;
}
function matchesRole(entry: var, role: var): bool {
const rawCategories = Array.isArray(entry.categories)
? entry.categories
: [String(entry.categories ?? "")];
const categories = [];
for (const rawCategory of rawCategories) {
for (const value of String(rawCategory).split(";")) {
const category = value.trim().toLowerCase();
if (category !== "")
categories.push(category);
}
}
const metadata = [entry.name, entry.genericName]
.map(value => String(value ?? "").toLowerCase())
.join(" ");
return role.categorySets.some(set => set.every(category => categories.includes(category)))
|| role.terms.some(term => metadata.includes(term));
}
function choicesForRole(role: var): var {
const choices = root.applications.filter(entry => root.matchesRole(entry, role));
const currentEntry = root.currentEntry(role.key);
if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry)))
choices.push(currentEntry);
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
}
TextRow {
visible: DefaultApps.lastError !== ""
label: "Application settings need attention"
detail: DefaultApps.lastError
value: ""
divider: false
}
SettingsCard {
title: "Default applications"
subtitle: "Open a row to choose from applications that advertise the matching role."
Repeater {
model: root.roles
delegate: Column {
id: roleBlock
required property var modelData
required property int index
readonly property var choices: root.choicesForRole(roleBlock.modelData)
readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key)
width: parent.width
SettingRow {
label: roleBlock.modelData.label
detail: roleBlock.modelData.detail
value: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
onActivated: {
root.expandedRole = root.expandedRole === roleBlock.modelData.key
? ""
: roleBlock.modelData.key;
}
}
Column {
width: parent.width
visible: root.expandedRole === roleBlock.modelData.key
Repeater {
model: roleBlock.choices
delegate: SettingRow {
id: candidateRow
required property var modelData
required property int index
readonly property string candidateId: root.desktopId(candidateRow.modelData)
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
label: root.displayName(candidateRow.modelData)
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
value: candidateRow.selected ? "Current" : ""
activatable: !candidateRow.selected && !DefaultApps.busy
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
onActivated: {
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
root.expandedRole = "";
}
}
}
}
}
}
}
SettingsCard {
title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
label: "No user autostart entries"
detail: "Applications can add entries to ~/.config/autostart."
value: ""
divider: false
}
Repeater {
model: DefaultApps.autostartEntries
delegate: SettingRow {
id: autostartRow
required property var modelData
required property int index
label: autostartRow.modelData.name
detail: autostartRow.modelData.id
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
activatable: !DefaultApps.busy
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
}
}
}
SettingsCard {
title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
label: "No compositor entries found"
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua."
value: ""
divider: false
}
Repeater {
model: DefaultApps.luaAutostartEntries
delegate: TextRow {
id: luaRow
required property var modelData
required property int index
label: luaRow.modelData.name
detail: luaRow.modelData.command
value: "Hyprland"
divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1
}
}
}
SettingsCard {
title: "Refresh"
ActionRow {
label: "Reload application settings"
detail: "Re-read desktop entries, defaults, and user autostart files"
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy
divider: false
onTriggered: DefaultApps.refresh()
}
}
}
@@ -0,0 +1,72 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
import qs.widgets
SettingRow {
id: root
property var node: null
label: "Balance"
detail: "Adjust the left and right channels"
controlWidth: 270
visible: root.available
divider: false
PwObjectTracker {
objects: root.node ? [root.node] : []
}
function channelIndex(channel): int {
if (!root.node?.audio)
return -1;
const channels = root.node.audio.channels;
for (let index = 0; index < channels.length; index++) {
if (channels[index] === channel)
return index;
}
return -1;
}
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
readonly property bool available: root.node?.audio
&& root.leftIndex >= 0 && root.rightIndex >= 0
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
readonly property real position: {
if (!root.available)
return 0.5;
const left = root.node.audio.volumes[root.leftIndex];
const right = root.node.audio.volumes[root.rightIndex];
const level = Math.max(left, right);
if (level <= 0.001)
return 0.5;
return right >= left ? 0.5 + (1 - left / level) * 0.5
: 0.5 - (1 - right / level) * 0.5;
}
function setBalance(value: real): void {
if (!root.available)
return;
const next = Array.from(root.node.audio.volumes);
const level = Math.max(next[root.leftIndex], next[root.rightIndex], 0.001);
if (value < 0.5) {
next[root.leftIndex] = level;
next[root.rightIndex] = level * value * 2;
} else {
next[root.leftIndex] = level * (1 - value) * 2;
next[root.rightIndex] = level;
}
root.node.audio.volumes = next;
}
ValueSlider {
anchors.fill: parent
value: root.position
icon: "audio-speakers-symbolic"
onMoved: value => root.setBalance(value)
}
}
@@ -0,0 +1,95 @@
// Bluetooth, at page size.
//
// Paired devices first, because reconnecting to something you already own is
// what you are here for nine times out of ten; discovered devices follow.
// Battery is shown where BlueZ reports it, which is the one thing people
// routinely open a terminal for.
import QtQuick
import Quickshell
import Quickshell.Bluetooth
import qs.config
import qs.services
Column {
id: root
spacing: 0
function primaryAction(device: var): void {
if (device.connected) {
device.disconnect();
return;
}
if (device.paired) {
device.connect();
return;
}
device.pair();
}
function stateLabel(device: var): string {
if (device.pairing)
return "Pairing…";
if (device.connected)
return device.batteryAvailable
? `Connected · ${Math.round(device.battery * 100)}% battery`
: "Connected";
if (device.paired)
return "Paired";
return device.address || "Not paired";
}
Repeater {
model: Connectivity.bluetoothDevices
SettingRow {
id: entry
required property var modelData
required property int index
width: parent.width
label: entry.modelData.name || entry.modelData.address || "Unknown device"
detail: root.stateLabel(entry.modelData)
divider: entry.index < Connectivity.bluetoothDevices.length - 1
controlWidth: 200
activatable: !entry.modelData.pairing
onActivated: root.primaryAction(entry.modelData)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
enabled: !entry.modelData.pairing
text: entry.modelData.connected
? "Disconnect"
: (entry.modelData.paired ? "Connect" : "Pair")
onClicked: root.primaryAction(entry.modelData)
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.paired
text: "Forget"
onClicked: entry.modelData.forget()
}
}
}
}
SettingRow {
width: parent.width
visible: Connectivity.bluetoothDevices.length === 0
label: !Connectivity.adapter
? "No Bluetooth adapter"
: (Connectivity.adapter.enabled ? "Looking for devices…" : "Bluetooth is off")
detail: Connectivity.adapter && !Connectivity.adapter.enabled
? "Turn it on above to discover devices"
: "Put the device into pairing mode to make it appear"
divider: false
}
}
@@ -0,0 +1,118 @@
// A row of choices that wraps, for options that do not fit a segmented control.
//
// ChoiceRow puts two or three options on one line. Scales and rotations are
// more numerous and their labels are wider, so they wrap into a grid rather
// than shrinking to illegibility on a narrow, tiled window.
//
// Unlike ChoiceRow this is not schema-bound: it reports a value and lets the
// caller decide what to do with it, because a display change has to go through
// an apply-then-confirm cycle rather than straight into the store.
import QtQuick
import qs.config
Column {
id: root
property string label: ""
property string detail: ""
property var options: []
property var current: null
property bool enabled: true
property bool divider: true
signal picked(var value)
spacing: 9
bottomPadding: 12
Column {
width: parent.width
spacing: 3
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
visible: root.detail !== ""
text: root.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Flow {
width: parent.width
spacing: 7
Repeater {
model: root.options
Rectangle {
id: option
required property var modelData
readonly property bool selected: root.current === option.modelData.value
implicitWidth: Math.max(78, caption.implicitWidth + 26)
implicitHeight: 32
radius: 9
opacity: root.enabled ? 1 : 0.45
color: option.selected ? "transparent" : Theme.alpha(Theme.fg, hover.hovered && root.enabled ? 0.11 : 0.06)
border.width: option.selected ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
// The prism marks the selection here as everywhere else.
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: option.selected
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
}
}
Text {
id: caption
anchors.centerIn: parent
text: option.modelData.label
color: option.selected ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
font.weight: option.selected ? Font.DemiBold : Font.Normal
}
HoverHandler {
id: hover
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: root.enabled && !option.selected
onTapped: root.picked(option.modelData.value)
}
}
}
}
Rectangle {
width: parent.width
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
}
@@ -0,0 +1,98 @@
// An enum setting, bound to a schema key by name.
//
// ChoiceRow { setting: "vrrPolicy" }
//
// The options are the schema's, so a row can never offer a value the store
// would reject. Rendered as a segmented control rather than a dropdown: every
// choice here has two or three options, and showing them all is both faster to
// use and self-documenting.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
readonly property var current: DesktopPreferences.get(root.setting)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: Math.max(120, root.options.length * 92)
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
implicitWidth: segments.implicitWidth + 4
implicitHeight: 30
radius: 9
color: Theme.alpha(Theme.fg, 0.07)
border.width: 0
Row {
id: segments
anchors.centerIn: parent
spacing: 0
Repeater {
model: root.options
Rectangle {
id: segment
required property var modelData
readonly property bool selected: root.current === segment.modelData.value
implicitWidth: Math.max(70, caption.implicitWidth + 24)
implicitHeight: 26
radius: 7
border.width: 0
color: "transparent"
// The selected segment is the only place the prism appears
// in a row: blue leads into orchid, never orchid alone.
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: segment.selected
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.30) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.30) }
}
}
Rectangle {
anchors.fill: parent
radius: parent.radius
border.width: 0
visible: !segment.selected && hover.hovered
color: Theme.alpha(Theme.fg, 0.06)
}
Text {
id: caption
anchors.centerIn: parent
text: segment.modelData.label
color: segment.selected ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: segment.selected ? Font.DemiBold : Font.Normal
}
HoverHandler { id: hover }
TapHandler {
onTapped: SystemSettings.commitPreference(root.setting, segment.modelData.value)
}
}
}
}
}
}
@@ -1,88 +1,124 @@
// Network & Devices.
//
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
// the founding requirement for this desktop: never having to drop to a terminal
// to join a network.
//
// Scanning follows this page being on screen. Wi-Fi scanning and especially
// Bluetooth discovery hold the radio, and doing either for a list nobody is
// looking at is battery and airtime spent on nothing.
import QtQuick
import Quickshell
import Quickshell.Networking
import Quickshell.Bluetooth
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
SettingsPage {
id: root
readonly property var wifiDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi)
return device;
}
return null;
}
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
title: "Network & Devices"
lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
// Drive the scanners only while this page is the one being shown.
Component.onCompleted: Connectivity.active = true
Component.onDestruction: Connectivity.active = false
SettingsCard {
title: "Wi‑Fi"
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
title: "Wired"
visible: Connectivity.wiredDevice !== null
TextRow {
label: "Ethernet"
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
divider: false
}
}
SettingsCard {
title: "Wi-Fi"
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
// contradiction; with no radio the card simply does not belong.
visible: Connectivity.wifiDevice !== null
subtitle: "Networks are re-scanned while this page is open."
SettingRow {
label: "Wi‑Fi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
label: "Wi-Fi"
detail: Connectivity.wifiEnabled ? "On" : "Off"
controlWidth: 48
divider: Connectivity.wifiEnabled
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
checked: Connectivity.wifiEnabled
enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value
}
}
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
SettingRow {
label: "Advanced network settings"
detail: "VPN, wired profiles, DNS, and connection details"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
WifiPanel {
width: parent.width
visible: Connectivity.wifiEnabled
}
}
SettingsCard {
title: "Bluetooth"
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
visible: Connectivity.adapter !== null
subtitle: "Discovery runs while this page is open."
SettingRow {
label: "Bluetooth"
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
detail: Connectivity.adapter
? (Connectivity.adapter.enabled ? "On" : "Off")
: "Unavailable"
controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.bluetoothAdapter?.enabled ?? false
enabled: root.bluetoothAdapter !== null
onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; }
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
enabled: Connectivity.adapter !== null
onToggled: value => {
if (Connectivity.adapter)
Connectivity.adapter.enabled = value;
}
}
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
SettingRow {
label: "Advanced Bluetooth settings"
detail: "Device details and system-level options"
}
BluetoothPanel {
width: parent.width
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
}
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
ActionRow {
label: "Network connections"
detail: "VPN, proxies, and per-connection settings"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("network")
}
ActionRow {
label: "Printers"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("printers")
}
ActionRow {
label: "Online accounts"
action: "Open"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") }
}
}
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
}
@@ -0,0 +1,116 @@
// Date & Time.
//
// These belong to the machine rather than to Panama, so nothing here is stored
// in Panama's settings file -- it would be a second answer to a question the
// system already answers. Timezone and network time are read from and written
// to timedatectl directly; the clock's presentation lives on Appearance,
// 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
// that appears to have been accepted.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.modules.clipboard
SettingsPage {
id: root
title: "Date & Time"
lede: "Timezone and network time, shared with the whole machine."
SettingsCard {
title: "Clock"
TextRow {
label: "Current time"
detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone
value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP")
}
SettingRow {
label: "Set automatically"
detail: DateTime.ntpEnabled
? (DateTime.ntpSynchronised ? "Synchronised with a time server" : "Waiting to synchronise")
: "The clock is set by hand"
controlWidth: 48
divider: false
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: DateTime.ntpEnabled
enabled: !DateTime.busy
onToggled: value => DateTime.setNtp(value)
}
}
}
SettingsCard {
title: "Timezone"
subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone)
+ ". Type to narrow the list."
SearchField {
id: zoneSearch
width: parent.width
placeholder: "Search timezones"
}
Repeater {
model: root.matchingZones
TextRow {
required property var modelData
required property int index
label: DateTime.cityOf(modelData)
detail: DateTime.regionOf(modelData)
value: modelData === DateTime.timezone ? "Current" : ""
controlWidth: 90
divider: index < root.matchingZones.length - 1
activatable: true
onActivated: DateTime.setTimezone(modelData)
}
}
TextRow {
visible: root.matchingZones.length === 0
label: "No timezone matches that"
detail: "Try a city or a region, such as \"Denver\" or \"Europe\""
divider: false
}
}
SettingsCard {
visible: DateTime.lastError !== ""
title: "The system did not accept that"
subtitle: DateTime.lastError
}
// Bounded so a blank search does not try to lay out six hundred rows. The
// current zone is always included, so the card never looks empty when the
// field is untouched.
readonly property var matchingZones: {
const needle = zoneSearch.text.trim().toLowerCase();
const all = DateTime.zones;
if (needle === "") {
// The zone in effect goes first. Listing a region alphabetically
// means the current setting is usually off the bottom of the card,
// which makes the list look like it does not know what is set.
const current = DateTime.timezone;
const nearby = all
.filter(zone => zone !== current && DateTime.regionOf(zone) === DateTime.regionOf(current))
.slice(0, 11);
return current === "" ? nearby : [current].concat(nearby);
}
return all.filter(zone => zone.toLowerCase().replace(/_/g, " ").indexOf(needle) >= 0).slice(0, 40);
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
}
@@ -1,55 +1,124 @@
// Desktop & Dock.
//
// The dock timings used to be shown here as text -- "Instant", "250 ms" -- even
// though they were already stored, mutable integers. They are controls now.
// The window-layout card keeps text rows because those really are facts about
// how Panama tiles rather than settings: the adjustable parts of window
// appearance live on the Appearance page, next to the preview that explains
// them.
import QtQuick
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Desktop & Dock"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Keep the shell instant, spatial, and out of your way."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
title: "Desktop & Dock"
lede: "Keep the shell instant, spatial, and out of your way."
SettingsCard {
title: "Dock"
SettingRow {
label: "Automatically hide the Dock"
detail: "Reveal it at the bottom edge when a workspace is occupied"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("dockAutohide"); onToggled: value => DesktopPreferences.set("dockAutohide", value) }
ToggleRow { setting: "dockAutohide" }
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
}
SettingsCard {
title: "Pinned applications"
subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
DockPinsEditor {
id: pins
width: parent.width
}
}
SettingsCard {
title: "Pin another application"
DockAppPicker {
width: parent.width
pinned: pins.pinned
onPicked: id => pins.add(id)
}
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.get("dockRevealDelayMs") === 0 ? "Instant" : `${DesktopPreferences.get("dockRevealDelayMs")} ms` }
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.get("dockHideDelayMs")} ms`; divider: false }
}
SettingsCard {
title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" }
SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" }
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
TextRow {
label: "Layout"
detail: "Dwindle with preserved split direction"
value: "Tiling"
}
TextRow {
label: "Workspace movement"
detail: "Alt+H / Alt+L, add Shift to move a window"
value: "Dynamic"
}
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance"
divider: false
onTriggered: ShellState.openSettings("appearance")
}
}
SettingsCard {
title: "Focus"
SliderRow { setting: "focusDurationMinutes"; divider: false }
}
SettingsCard {
title: "Snapshots"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable."
ActionRow {
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No snapshots yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first"
action: "Back up now"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
onTriggered: SettingsBackup.save()
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
ActionRow {
required property var modelData
required property int index
label: modelData.when
detail: modelData.keys + " settings"
action: "Restore"
enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1
onTriggered: SettingsBackup.restore(modelData.name)
}
}
}
SettingsCard {
title: "Reset"
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults."
SettingRow {
label: "Desktop preferences"
detail: "Your pinned applications and files are not changed"
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow {
label: "Restore Panama defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
controlWidth: 122
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
}
}
onTriggered: SystemSettings.restoreDefaults()
}
}
}
@@ -0,0 +1,217 @@
// A miniature of the real desktop that redraws as you change Appearance.
//
// The point is that "outer gaps 24" and "radius 9" mean nothing as numbers. This
// shows two tiled windows at the settings currently in effect: the focused one
// wearing the prism border and its glow, the unfocused one carrying the
// inactive-opacity setting, both inside real gaps at a real corner radius.
//
// Geometry is scaled by the ratio between this preview's width and the actual
// monitor's, so proportions are honest rather than decorative -- a 10px gap on
// a 4500px display genuinely is almost invisible, and the preview says so.
//
// Everything here is driven by bindings on DesktopPreferences, so the preview
// shows what is actually stored and applied. It has no state of its own and
// nothing animates while idle.
import QtQuick
import QtQuick.Effects
import qs.config
import qs.services
Rectangle {
id: root
implicitHeight: 232
radius: Theme.cardRadius
clip: true
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
// Falls back to a sane width if the monitor has not been read yet, so the
// preview is never wrong by a factor of ten on first paint.
readonly property real monitorWidth: SystemSettings.monitorWidth > 0 ? SystemSettings.monitorWidth : 3000
readonly property real scale: width > 0 ? width / root.monitorWidth : 0.25
readonly property int gapsIn: DesktopPreferences.get("gapsIn")
readonly property int gapsOut: DesktopPreferences.get("gapsOut")
readonly property int borderSize: DesktopPreferences.get("borderSize")
readonly property int rounding: DesktopPreferences.get("windowRounding")
readonly property real inactiveOpacity: DesktopPreferences.get("inactiveOpacity")
readonly property bool shadowOn: DesktopPreferences.get("shadowEnabled")
readonly property int shadowRange: DesktopPreferences.get("shadowRange")
readonly property bool glowOn: DesktopPreferences.get("glowEnabled")
readonly property int glowRange: DesktopPreferences.get("glowRange")
// Scaled geometry, floored so a small-but-nonzero setting stays visible
// rather than rounding away to nothing.
function px(value: real): real {
return value <= 0 ? 0 : Math.max(1, value * root.scale);
}
// Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page.
// Follows the colour scheme. A preview that stays dark while the shell
// around it is light does not read as "your desktop" -- it reads as a
// screenshot of someone else's.
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
}
// The bar, so the preview reads as this desktop and not a generic one.
Rectangle {
id: miniBar
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 20
color: Theme.alpha(Theme.bgDark, 0.4)
border.width: 0
Row {
anchors.left: parent.left
anchors.leftMargin: 9
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Repeater {
model: 3
Rectangle {
required property int index
width: index === 0 ? 12 : 5
height: 5
radius: 2.5
border.width: 0
color: index === 0 ? Theme.accent : Theme.alpha(Theme.fg, 0.3)
anchors.verticalCenter: parent.verticalCenter
}
}
}
Text {
anchors.centerIn: parent
text: "Panama"
color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
Row {
id: tiles
anchors.top: miniBar.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: root.px(root.gapsOut)
spacing: root.px(root.gapsIn) * 2
MiniWindow {
width: (tiles.width - tiles.spacing) / 2
height: tiles.height
focused: true
}
MiniWindow {
width: (tiles.width - tiles.spacing) / 2
height: tiles.height
focused: false
}
}
component MiniWindow: Item {
id: win
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
// gradient, and the prism border is the whole signature here.
Rectangle {
id: frame
anchors.fill: parent
radius: root.px(root.rounding)
border.width: 0
visible: win.focused && root.borderSize > 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.accent }
GradientStop { position: 1.0; color: Theme.accentSecondary }
}
}
Rectangle {
id: body
anchors.fill: parent
anchors.margins: win.focused ? root.px(root.borderSize) : 0
radius: Math.max(0, root.px(root.rounding) - (win.focused ? root.px(root.borderSize) : 0))
color: Theme.alpha(Theme.bgDark, 0.92)
opacity: win.focused ? 1.0 : root.inactiveOpacity
border.width: win.focused ? 0 : 1
border.color: Theme.alpha(Theme.gutter, 0.6)
clip: true
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 13
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 22
anchors.margins: 10
spacing: 5
Repeater {
model: [1.0, 0.74, 0.52]
Rectangle {
required property real modelData
width: parent.width * modelData
height: 4
radius: 2
border.width: 0
color: Theme.alpha(Theme.fg, win.focused ? 0.2 : 0.13)
}
}
}
Text {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: 8
text: win.focused ? "focused" : "unfocused"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
// Shadow and glow both come from MultiEffect. Only the focused window
// gets the glow, matching looks.lua where glow.color_inactive is fully
// transparent.
MultiEffect {
anchors.fill: body
source: body
visible: root.shadowOn || (win.focused && root.glowOn)
z: -1
shadowEnabled: true
shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha(Theme.dark ? "#15161e" : "#6172b0", Theme.dark ? 0.85 : 0.45)
shadowBlur: win.focused && root.glowOn
? Math.min(1.0, root.px(root.glowRange) / 12)
: Math.min(1.0, root.px(root.shadowRange) / 24)
shadowVerticalOffset: win.focused && root.glowOn ? 0 : root.px(4)
shadowHorizontalOffset: 0
}
}
}
@@ -0,0 +1,120 @@
// The resolution list for one display.
//
// Grouped by resolution with refresh rates beside it, rather than a flat list
// of "[email protected]" strings: this panel reports 35 modes, many of which
// differ only in refresh-rate rounding, and a flat list of those is a wall of
// near-identical text rather than a choice.
import QtQuick
import qs.config
import qs.services
Column {
id: root
property var monitor: null
property bool enabled: true
spacing: 0
readonly property var grouped: {
if (!root.monitor)
return [];
const buckets = {};
const order = [];
for (const mode of root.monitor.modes) {
const key = mode.label;
if (!buckets[key]) {
buckets[key] = { label: key, width: mode.width, height: mode.height, rates: [] };
order.push(key);
}
buckets[key].rates.push(mode);
}
return order.map(key => buckets[key]);
}
Repeater {
model: root.grouped
SettingRow {
id: resolution
required property var modelData
required property int index
readonly property bool isCurrent: root.monitor
&& root.monitor.width === resolution.modelData.width
&& root.monitor.height === resolution.modelData.height
label: resolution.modelData.label
detail: resolution.isCurrent ? "Current resolution" : ""
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
divider: resolution.index < root.grouped.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: resolution.modelData.rates
Rectangle {
id: rate
required property var modelData
readonly property bool selected: resolution.isCurrent
&& Displays.modeIsCurrent(root.monitor, rate.modelData)
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
implicitHeight: 30
radius: 9
opacity: root.enabled ? 1 : 0.45
color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06)
border.width: rate.selected ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: rate.selected
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
}
}
Text {
id: rateCaption
anchors.centerIn: parent
text: rate.modelData.refreshLabel
color: rate.selected ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: rate.selected ? Font.DemiBold : Font.Normal
}
HoverHandler {
id: rateHover
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: root.enabled && !rate.selected
onTapped: Displays.apply(
root.monitor.name,
rate.modelData.mode,
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
root.monitor.transform)
}
}
}
}
}
}
}
@@ -1,97 +1,232 @@
// Displays.
//
// Resolution, refresh rate, scale, and rotation, plus the gaming display
// policy that was already here.
//
// Every geometry change goes through an apply-then-confirm countdown. This is
// the one page where a wrong value can leave the screen unreadable or blank,
// and no other control in the app can undo it once that happens. Confirming is
// what writes the choice to the settings store; letting the countdown run
// leaves nothing behind.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
title: "Displays"
lede: SystemSettings.monitorDescription || "Reading the active display…"
property string selectedOutput: ""
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
readonly property string currentMode: root.monitor
? root.monitor.mode
: ""
function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
}
Component.onCompleted: root.syncSelectedOutput()
Connections {
target: Displays
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
}
// The confirmation sits above everything, because while it is counting down
// it is the only thing that matters on this page.
header: Component {
Rectangle {
visible: Displays.awaitingConfirmation
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
radius: Theme.cardRadius
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.4)
Row {
id: confirmRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 16
spacing: 14
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
width: parent.width - keepButton.width - revertButton.width - 28
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
Text {
width: parent.width
text: "Keep this display setting?"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
+ " if you do nothing. If you cannot read this, just wait."
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
SettingsCard {
title: SystemSettings.monitorName || "Active display"
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
SettingRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
SettingRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
SettingsButton {
id: revertButton
anchors.verticalCenter: parent.verticalCenter
text: "Revert now"
onClicked: Displays.revert()
}
SettingsButton {
id: keepButton
anchors.verticalCenter: parent.verticalCenter
text: "Keep"
enabled: Displays.canConfirm
onClicked: Displays.confirm()
}
}
}
}
SettingsCard {
title: "Gaming display policy"
subtitle: "These values apply immediately and are restored when Panama starts."
SettingRow {
label: "Game-aware HDR"
detail: "Enter HDR only for fullscreen content that requests it"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
}
SettingRow {
label: "Content-aware VRR"
detail: "Enable variable refresh only for game and video content"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
}
SettingRow {
label: "Direct scanout for games"
detail: "Bypass compositing only for windows classified as games"
visible: Displays.monitors.length > 1
title: "Connected display"
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
ChoiceGrid {
width: parent.width
label: "Display"
options: Displays.monitors.map(monitor => ({
value: monitor.name,
label: monitor.description || monitor.name
}))
current: root.monitor ? root.monitor.name : ""
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
onPicked: value => root.selectedOutput = value
}
}
SettingsCard {
title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display")
subtitle: root.monitor
? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale`
: "Reading the active display…"
TextRow {
label: "Color mode"
detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background."
value: root.monitor
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
: "Detecting"
}
TextRow {
label: "Variable refresh"
detail: root.monitor && root.monitor.vrr
? "Active on this output for current fullscreen content"
: "This output is ready when game or video content requests it"
value: root.monitor && root.monitor.vrr ? "Active" : "Standby"
divider: Displays.isOverridden(root.monitor ? root.monitor.name : "")
}
ActionRow {
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
label: "Using a custom display setting"
detail: "Forget it to go back to the resolution and scale Panama ships"
action: "Forget"
divider: false
onTriggered: Displays.forget(root.monitor.name)
}
}
SettingsCard {
visible: root.monitor !== null
title: "Resolution"
subtitle: "Applied straight away, then reverted automatically unless you confirm."
DisplayModePicker {
width: parent.width
monitor: root.monitor
enabled: !Displays.awaitingConfirmation && !Displays.busy
}
}
SettingsCard {
visible: root.monitor !== null
title: "Scale and rotation"
ChoiceGrid {
width: parent.width
label: "Scale"
detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered."
options: Displays.scalesForMode(root.currentMode)
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
current: root.monitor ? root.monitor.scale : 1
enabled: !Displays.awaitingConfirmation && !Displays.busy
onPicked: value => root.applyWith({ scale: value })
}
ChoiceGrid {
width: parent.width
label: "Rotation"
options: Displays.transforms
current: root.monitor ? root.monitor.transform : 0
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
onPicked: value => root.applyWith({ transform: value })
}
}
SettingsCard {
title: "Night Light"
SettingRow {
label: "Warm display colors"
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
}
SettingRow {
label: "Color temperature"
detail: `${NightLight.temperature} K`
divider: false
controlWidth: 230
ValueSlider {
anchors.fill: parent
value: (6500 - NightLight.temperature) / 4000
icon: "weather-clear-night-symbolic"
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
}
}
subtitle: NightLight.active
? "On now, warming the display to reduce blue light."
: "Warms the display in the evening to reduce blue light."
ToggleRow { setting: "nightLightEnabled" }
ToggleRow { setting: "nightLightAutomatic" }
TimeOfDayRow { setting: "nightLightFrom" }
TimeOfDayRow { setting: "nightLightTo" }
SliderRow { setting: "nightLightTemperature"; divider: false }
}
Rectangle {
width: parent.width
height: warningText.implicitHeight + 30
radius: Theme.cardRadius
color: Theme.alpha(Theme.warn, 0.085)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.22)
Text {
id: warningText
anchors.fill: parent
anchors.margins: 15
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
color: Theme.mix(Theme.fg, Theme.warn, 0.25)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
SettingsCard {
title: "Gaming display policy"
subtitle: "Applied immediately and restored when Panama starts."
ToggleRow { setting: "autoHdr" }
ChoiceRow { setting: "vrrPolicy" }
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
}
SettingsCard {
visible: Displays.lastError !== ""
title: "Display problem"
subtitle: Displays.lastError
}
}
// Applies a change to one field, keeping the others at what is in effect.
function applyWith(change: var): void {
if (!root.monitor)
return;
const mode = change.mode ?? root.currentMode;
const requestedScale = change.scale ?? root.monitor.scale;
Displays.apply(
root.monitor.name,
mode,
Displays.isScaleClean(mode, requestedScale)
? requestedScale
: Displays.nearestCleanScale(mode, requestedScale),
change.transform ?? root.monitor.transform);
}
}
@@ -0,0 +1,71 @@
// Adds an installed application to the Dock.
//
// Filtered rather than a full list: there are several hundred desktop entries
// on a normal system, and rendering them all into a page that is already
// scrolling is both slow and useless. Typing narrows; nothing shows until you
// do, which also keeps the card short when you are not using it.
import QtQuick
import Quickshell
import qs.config
import qs.modules.clipboard
Column {
id: root
spacing: 0
required property var pinned
signal picked(string id)
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
if (root.pinned.indexOf(entry.id) >= 0)
continue;
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
SearchField {
id: search
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
label: candidate.modelData.name
detail: candidate.modelData.id
divider: candidate.index < root.matches.length - 1
controlWidth: 86
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Pin"
onClicked: {
root.picked(candidate.modelData.id);
search.text = "";
}
}
}
}
}
@@ -0,0 +1,112 @@
// The Dock's pinned applications: reorder, remove, and add.
//
// The list was a sixteen-entry literal in Settings.qml, so changing what sits
// in the Dock meant editing a QML file and reloading the shell. It is a plain
// ordered list of desktop entry ids, stored in the shared settings file, which
// means it is covered by Restore defaults like everything else.
//
// Move up / move down rather than drag-and-drop. Dragging inside a Flickable
// that is itself inside a scrolling page is a genuinely hard interaction to get
// right, and it fails in a way the user reads as the app being broken; two
// buttons are unambiguous and keyboard-reachable.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.widgets
Column {
id: root
spacing: 0
readonly property var pinned: {
const stored = DesktopPreferences.get("dockPinned");
return Array.isArray(stored) ? stored : [];
}
// DesktopEntries populates asynchronously, so this must be read as a
// binding rather than looked up inside one -- byId() called during
// evaluation registers no dependency and answers from an empty list.
readonly property var entriesById: {
const index = {};
for (const entry of DesktopEntries.applications.values)
index[entry.id] = entry;
return index;
}
function nameFor(id: string): string {
const entry = root.entriesById[id];
return entry ? entry.name : id;
}
function commit(next: var): void {
DesktopPreferences.set("dockPinned", next);
}
function move(from: int, to: int): void {
if (to < 0 || to >= root.pinned.length)
return;
const next = root.pinned.slice();
const moved = next.splice(from, 1)[0];
next.splice(to, 0, moved);
root.commit(next);
}
function remove(index: int): void {
const next = root.pinned.slice();
next.splice(index, 1);
root.commit(next);
}
function add(id: string): void {
if (root.pinned.indexOf(id) >= 0)
return;
root.commit(root.pinned.concat([id]));
}
Repeater {
model: root.pinned
SettingRow {
id: pin
required property var modelData
required property int index
label: root.nameFor(pin.modelData)
detail: pin.modelData
divider: pin.index < root.pinned.length - 1
controlWidth: 132
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
SettingsButton {
text: "↑"
enabled: pin.index > 0
onClicked: root.move(pin.index, pin.index - 1)
}
SettingsButton {
text: "↓"
enabled: pin.index < root.pinned.length - 1
onClicked: root.move(pin.index, pin.index + 1)
}
SettingsButton {
text: "Unpin"
onClicked: root.remove(pin.index)
}
}
}
}
SettingRow {
visible: root.pinned.length === 0
label: "Nothing is pinned"
detail: "The Dock will only show running applications"
divider: false
}
}
@@ -0,0 +1,80 @@
// Choosing a font family.
//
// Each candidate is rendered IN the font it names. A list of family names set
// in the current font tells you nothing about what you are choosing, and the
// whole point of picking a typeface is seeing it.
import QtQuick
import qs.config
import qs.modules.clipboard
Column {
id: root
spacing: 0
property var families: []
property string current: ""
property string emptyText: "No fonts found"
signal picked(string family)
// Filtered rather than listing all 139: a scrolling wall of family names
// inside a page that is already scrolling is worse than a search box.
readonly property var matches: {
const needle = filter.text.trim().toLowerCase();
const list = root.families.filter(family =>
needle === "" ? true : family.toLowerCase().indexOf(needle) >= 0);
// Always show what is currently selected, even when it does not match.
if (needle === "" && list.indexOf(root.current) >= 0)
return [root.current].concat(list.filter(f => f !== root.current)).slice(0, 12);
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: "Search installed fonts"
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData === root.current
label: candidate.modelData
detail: candidate.selected ? "Currently in use" : ""
controlWidth: 210
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 200
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
// The sample is drawn in the candidate family, which is the
// entire reason this is a list rather than a text field.
text: "Handgloves 0123"
font.family: candidate.modelData
font.pixelSize: Theme.fontSize + 1
color: candidate.selected ? Theme.accent : Theme.fgDim
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
@@ -9,93 +9,61 @@ Rectangle {
required property string sourceName
required property int index
required property bool featured
required property bool canMoveEarlier
required property bool canMoveLater
signal aliasCommitted(string id, string alias)
signal removeRequested(string id)
signal moveRequested(string id, int targetIndex)
readonly property bool dragging: dragHandler.active
implicitHeight: 108
radius: Theme.cardRadius
color: root.dragging
? Theme.mix(Theme.bgDark, Theme.accent, 0.09)
: Theme.alpha(Theme.bgDark, 0.7)
border.width: root.dragging ? 2 : 1
border.color: root.dragging
? Theme.alpha(Theme.accent, 0.82)
: Theme.alpha(Theme.fg, 0.07)
z: root.dragging ? 10 : 0
transform: Translate {
x: root.dragging ? dragHandler.translation.x : 0
y: root.dragging ? dragHandler.translation.y : 0
}
color: Theme.alpha(Theme.bgDark, 0.7)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: root.radius
opacity: root.dragging ? 0.82 : 0.2
opacity: 0.2
}
Rectangle {
id: dragHandle
Column {
id: reorderControls
anchors.left: parent.left
anchors.leftMargin: 11
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 42
radius: 9
activeFocusOnTab: true
color: root.dragging || activeFocus
? Theme.alpha(Theme.accent, 0.14)
: (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045))
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06)
spacing: 4
Text {
anchors.centerIn: parent
text: "⠿"
color: root.dragging ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 16
SettingsButton {
width: 30
height: 29
text: "↑"
enabled: root.canMoveEarlier
activeFocusOnTab: enabled
onClicked: root.moveRequested(root.favorite.id, root.index - 1)
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
}
MouseArea {
id: handleMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.SizeAllCursor
}
DragHandler {
id: dragHandler
target: null
onActiveChanged: {
if (!active)
root.commitDrag();
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
root.moveRequested(root.favorite.id, Math.max(0, root.index - 1));
event.accepted = true;
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
const grid = root.GridView.view;
const lastIndex = grid ? grid.count - 1 : root.index;
root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1));
event.accepted = true;
}
SettingsButton {
width: 30
height: 29
text: "↓"
enabled: root.canMoveLater
activeFocusOnTab: enabled
onClicked: root.moveRequested(root.favorite.id, root.index + 1)
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
}
}
Rectangle {
id: aliasFrame
anchors.left: dragHandle.right
anchors.left: reorderControls.right
anchors.leftMargin: 10
anchors.right: removeButton.left
anchors.rightMargin: 12
@@ -188,16 +156,4 @@ Rectangle {
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
}
function commitDrag(): void {
const grid = root.GridView.view;
if (!grid || grid.count <= 0)
return;
const centerX = root.x + dragHandler.translation.x + root.width / 2;
const centerY = root.y + dragHandler.translation.y + root.height / 2;
const modelCount = grid.count;
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column));
}
}
@@ -2,54 +2,32 @@ import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
readonly property int openedHour: new Date().getHours()
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text {
text: `${root.greeting}, Gabriel`
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
text: "Your Panama desktop is configured and ready."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
title: `${root.greeting}, Gabriel`
lede: "Your Panama desktop is configured and ready."
SettingsCard {
title: SystemSettings.monitorDescription || "Active display"
subtitle: SystemSettings.monitorName || "Detecting your display…"
Row {
Grid {
id: monitorLayout
width: parent.width
height: 164
spacing: 28
columns: width >= 620 ? 2 : 1
columnSpacing: 28
rowSpacing: 16
Item {
width: parent.width * 0.47
height: parent.height
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: 164
Rectangle {
width: Math.min(parent.width - 24, 260)
@@ -85,8 +63,10 @@ Item {
}
Column {
width: parent.width * 0.47
anchors.verticalCenter: parent.verticalCenter
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: monitorLayout.columns === 2 ? 164 : implicitHeight
spacing: 13
Text {
@@ -120,12 +100,41 @@ Item {
}
}
Row {
SettingsCard {
title: "Weather"
subtitle: "Local conditions in the date menu"
TextRow {
label: "Location"
detail: "Only the search term is sent; the name below is a label kept on this machine"
value: Settings.weatherLocation
}
LocationPicker {
width: parent.width
spacing: 16
}
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
SettingsCard {
width: (parent.width - parent.spacing) / 2
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid {
id: summaryCards
width: parent.width
columns: width >= 720 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Quiet focus"
subtitle: "Notifications and focused work"
@@ -144,11 +153,13 @@ Item {
}
SettingsCard {
width: (parent.width - parent.spacing) / 2
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Desktop services"
subtitle: "The essentials are running"
SettingRow {
TextRow {
label: "Sync & remote access"
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
divider: false
@@ -157,5 +168,3 @@ Item {
}
}
}
}
}
@@ -2,9 +2,11 @@ import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
property string lightQuery: searchInput.text.trim().toLowerCase()
@@ -41,40 +43,200 @@ Item {
return "Home Assistant is unavailable";
}
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text {
text: "Home & Phone"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
function saveHomeAssistantConfig(): void {
HomeAssistantConfig.save(
homeUrlInput.text,
homeEntitiesInput.text,
homeTokenInput.text
);
}
Text {
text: "Choose what appears in Control Center and keep phone continuity close at hand."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
Connections {
target: HomeAssistantConfig
function onConfigurationSaved(): void {
homeTokenInput.clear();
homeUrlInput.text = HomeAssistantConfig.url;
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
}
}
SettingsCard {
title: "Home Assistant"
subtitle: root.homeStatus()
SettingRow {
label: "Connection"
detail: HomeAssistantConfig.tokenConfigured
? "A long-lived access token is stored privately"
: "Paste a long-lived access token to connect"
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
}
SettingRow {
label: "Server URL"
detail: "The local or remote address of Home Assistant"
controlWidth: 330
Rectangle {
anchors.fill: parent
radius: Theme.pillRadius
color: Theme.alpha(Theme.fg, 0.07)
border.width: homeUrlInput.activeFocus ? 2 : 1
border.color: homeUrlInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : "transparent"
TextInput {
id: homeUrlInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
activeFocusOnTab: true
text: HomeAssistantConfig.url
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
verticalAlignment: TextInput.AlignVCenter
clip: true
Text {
anchors.fill: parent
visible: homeUrlInput.text === ""
text: "https://homeassistant.local:8123"
color: Theme.fgMuted
font: homeUrlInput.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
SettingRow {
label: "Access token"
detail: HomeAssistantConfig.tokenConfigured
? "Stored · leave blank to keep it"
: "Create one in your Home Assistant profile"
controlWidth: 330
PasswordField {
id: homeTokenInput
anchors.fill: parent
placeholder: HomeAssistantConfig.tokenConfigured
? "Stored token" : "Long-lived access token"
onAccepted: root.saveHomeAssistantConfig()
}
}
Column {
width: parent.width
spacing: 7
topPadding: 10
bottomPadding: 12
Text {
width: parent.width
text: "Light entities"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
text: "Comma-separated entity IDs. These define the discoverable light catalog."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
height: 72
radius: 10
color: Theme.alpha(Theme.fg, 0.055)
border.width: homeEntitiesInput.activeFocus ? 2 : 1
border.color: homeEntitiesInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
TextEdit {
id: homeEntitiesInput
anchors.fill: parent
anchors.margins: 10
activeFocusOnTab: true
text: HomeAssistantConfig.entities.join(", ")
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: TextEdit.Wrap
clip: true
Text {
anchors.fill: parent
visible: homeEntitiesInput.text === ""
text: "light.living_room, light.kitchen"
color: Theme.fgMuted
font: homeEntitiesInput.font
wrapMode: Text.WordWrap
}
}
}
}
Text {
width: parent.width
visible: HomeAssistantConfig.lastError !== ""
text: HomeAssistantConfig.lastError
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
bottomPadding: 9
}
SettingRow {
label: "Private configuration"
detail: "Saved with owner-only permissions in Panama's private environment file"
controlWidth: 216
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: clearTokenButton
text: "Clear token"
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistantConfig.clearToken()
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
}
SettingsButton {
id: saveHomeConfigButton
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
tone: "accent"
enabled: !HomeAssistantConfig.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : "transparent"
onClicked: root.saveHomeAssistantConfig()
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
}
}
}
SettingRow {
label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper."
@@ -150,6 +312,8 @@ Item {
})
sourceName: modelData.sourceName
featured: index < 4
canMoveEarlier: index > 0
canMoveLater: index < favoritesGrid.count - 1
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
@@ -312,5 +476,3 @@ Item {
}
}
}
}
}
@@ -0,0 +1,53 @@
// Choosing where the weather reading is for.
//
// A search box rather than latitude and longitude fields: nobody knows their
// own coordinates, and a control that demands them is one nobody ever uses. The
// coordinates are what actually get stored -- the name is only a label.
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
spacing: 0
SearchField {
id: query
width: parent.width
placeholder: "Search for a town or city"
onTextChanged: Geocoding.search(query.text)
}
Repeater {
model: Geocoding.results
SettingRow {
id: place
required property var modelData
required property int index
label: place.modelData.name
detail: [place.modelData.admin, place.modelData.country].filter(part => !!part).join(", ")
value: place.modelData.latitude.toFixed(2) + ", " + place.modelData.longitude.toFixed(2)
controlWidth: 150
divider: place.index < Geocoding.results.length - 1
activatable: true
onActivated: {
if (Geocoding.choose(place.modelData))
query.text = "";
}
}
}
SettingRow {
width: parent.width
visible: Geocoding.searching || Geocoding.lastError !== ""
label: Geocoding.searching ? "Searching…" : "No result"
detail: Geocoding.searching ? "" : Geocoding.lastError
divider: false
}
}
@@ -2,69 +2,169 @@ import QtQuick
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Notifications & Focus"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsPage {
title: "Notifications & Focus"
lede: "Control interruptions without losing useful history."
SettingsCard {
title: "Notifications"
SettingRow {
label: "Do Not Disturb"
detail: "Keep notifications in the center but suppress banners"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value }
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
SettingRow { label: "Notification history"; detail: "Live notifications retained by Panama"; value: `${Notifs.history.length} items` }
SettingRow {
}
TextRow {
label: "Notification history"
detail: "Live notifications retained by Panama"
value: `${Notifs.history.length} items`
}
ActionRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
divider: false
controlWidth: 94
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
action: "Clear all"
enabled: Notifs.history.length > 0
onTriggered: Notifs.dismissAll()
}
}
SettingsCard {
title: "Banner behavior"
SliderRow { setting: "notificationTimeoutMs" }
SliderRow {
setting: "notificationTimeoutCriticalMs"
zeroLabel: "Never"
}
SliderRow { setting: "notificationHistoryLimit" }
SliderRow { setting: "maxVisibleToasts"; divider: false }
}
SettingsCard {
title: "Application rules"
subtitle: "Apps appear here after they send a notification."
TextRow {
visible: Notifs.applications.length === 0
label: "No applications remembered yet"
detail: "Application controls will appear after the first notification arrives."
divider: false
}
Repeater {
model: Notifs.applications
Column {
required property var modelData
readonly property var app: modelData
width: parent.width
SettingRow {
label: app.name
detail: app.id
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).enabled
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
}
}
SettingRow {
label: "Show on lock screen"
detail: "Allow this app's notifications on the lock screen"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).showOnLockScreen
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
}
}
SettingRow {
label: "Show content on lock screen"
detail: "Show message details when this app is visible there"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).showContentOnLockScreen
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
}
}
}
}
}
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
label: "Keep the screen awake"
detail: Caffeine.enabled
? "The display will not blank or lock while this is on"
: "Idle timings on Power & Lock apply normally"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Caffeine.enabled
onToggled: value => Caffeine.enabled = value
}
}
SettingRow {
id: durationRow
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
controlWidth: 264
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: [25, 45, 60, 90]
SettingsButton {
required property int modelData
text: `${modelData}m`
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: DesktopPreferences.set("focusDurationMinutes", modelData)
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
}
}
}
}
SettingRow {
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
divider: false
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
@@ -75,5 +175,3 @@ Item {
}
}
}
}
}
@@ -0,0 +1,106 @@
// A password entry with a reveal toggle.
//
// Deliberately not the clipboard popover's SearchField with different text: a
// Wi-Fi key typed into a field that echoes it is readable by anyone behind you,
// and a search glyph in front of a password prompt is simply wrong. Masked by
// default, revealable while held, because the reason people want to see it is
// to check a character they just typed.
import QtQuick
import qs.config
Rectangle {
id: root
property alias text: input.text
property string placeholder: "Password"
property bool revealed: false
signal accepted
implicitHeight: 32
radius: Theme.pillRadius
color: Theme.alpha(Theme.fg, 0.07)
// Not left at 0 so the focus ring has something to animate. See the note in
// modules/clipboard/SearchField.qml.
border.width: 1
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
Behavior on border.color {
ColorAnimation { duration: Theme.durFast }
}
function grab(): void {
input.forceActiveFocus();
}
function clear(): void {
input.text = "";
root.revealed = false;
}
TextInput {
id: input
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: revealButton.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
echoMode: root.revealed ? TextInput.Normal : TextInput.Password
passwordCharacter: "•"
clip: true
onAccepted: root.accepted()
Text {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
visible: input.text === ""
text: root.placeholder
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
}
Rectangle {
id: revealButton
anchors.right: parent.right
anchors.rightMargin: 5
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 24
radius: 7
color: revealHover.hovered ? Theme.alpha(Theme.fg, 0.12) : "transparent"
border.width: 0
visible: input.text !== ""
Text {
anchors.centerIn: parent
// Nerd Font eye / eye-slash. fontMono is used for icon glyphs only.
text: root.revealed ? "\u{F070}" : "\u{F06E}"
font.family: Theme.fontMono
font.pixelSize: 12
color: root.revealed ? Theme.accent : Theme.fgMuted
}
HoverHandler {
id: revealHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.revealed = !root.revealed
}
}
}
@@ -0,0 +1,83 @@
// Power & Lock.
//
// hypridle has no IPC for reconfiguration, so these values reach it by
// regenerating its config and restarting the daemon (services/IdleLock.qml).
// That only happens when Panama manages the daemon, and the card below says
// plainly which state you are in rather than presenting sliders that silently
// do nothing.
import QtQuick
import Quickshell
import qs.config
import qs.services
SettingsPage {
id: root
title: "Power & Lock"
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
SettingsCard {
title: "Idle behaviour"
subtitle: IdleLock.managed
? "Panama is managing hypridle. Changes take effect immediately."
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable."
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
SliderRow { setting: "suspendMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
}
// Only shown when the numbers are actually contradictory, rather than as a
// permanent warning nobody reads.
SettingsCard {
visible: IdleLock.lockBeforeBlank
title: "Lock happens before the screen turns off"
subtitle: "The session will lock at " + IdleLock.lockMinutes
+ " minutes and the display will not blank until " + IdleLock.blankMinutes
+ ". That works, but the screen stays lit on the lock screen for the difference."
}
SettingsCard {
title: "Management"
subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place."
SettingRow {
label: "Let Panama manage idle timings"
detail: IdleLock.serviceState === "active"
? "hypridle is running"
: "hypridle is " + IdleLock.serviceState
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: IdleLock.managed
enabled: !IdleLock.busy
onToggled: value => IdleLock.setManaged(value)
}
}
ActionRow {
label: "Lock the screen now"
detail: "Same as the Super+L shortcut"
action: "Lock"
divider: false
onTriggered: Quickshell.execDetached(["loginctl", "lock-session"])
}
}
SettingsCard {
visible: IdleLock.lastError !== ""
title: "Idle configuration problem"
subtitle: IdleLock.lastError
ActionRow {
label: "Read the idle configuration again"
action: "Retry"
divider: false
onTriggered: IdleLock.refresh()
}
}
}
@@ -0,0 +1,109 @@
# Panama Settings
The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
such rather than half-reimplemented.
## Adding a setting
One schema entry. That is the whole job.
```qml
// config/PreferenceSchema.qml
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
unit: "px", group: "effects",
label: "Blur radius",
detail: "Larger is softer and costs more frame time",
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
}
```
```qml
// the page
SliderRow { setting: "blurSize" }
```
Persistence, validation, clamping, reset, search indexing, and — with a `hypr`
block — live application to the compositor and the startup replay all derive
from that entry. There is nothing else to register.
If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
file.
## The rows
| Component | For |
|---|---|
| `SettingsPage` | The page scaffold: title, lede, optional pinned `header` |
| `ToggleRow { setting }` | A boolean |
| `SliderRow { setting }` | A number; `zeroLabel` renders 0 as "Never"/"Instant"/"None" |
| `ChoiceRow { setting }` | An enum, as a segmented control |
| `ActionRow` | A button: opens a GNOME panel, runs a one-shot |
| `TextRow` | A genuinely read-only fact |
`TextRow` is for facts, not for settings that were merely expensive to wire.
Before Stage 3 more than half of all rows were static text standing in for
controls; that is the failure this vocabulary exists to prevent.
Rows write through `SystemSettings.commitPreference(key, value)`, which routes
compositor-backed keys through apply-and-verify and local keys straight to the
store. A row never needs to know which kind it holds.
## Things that will bite you
**`readAs` describes the answer, not the setting.** `hyprctl getoption` returns
the value in a different JSON field per type — `int`, `bool`, `float`, `str`,
and `css` for gaps (a four-value box). Declaring the wrong one does not fail
loudly: it makes every write to that key look *rejected*, and the user sees an
error for a change that worked. `tests/quickshell/schema-hypr-shape-contract.sh`
asks the compositor for the real shape of every mapped option.
**Never trust an exit code from `hyprctl`.** `keyword` refuses to work on a
Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
0 on syntax and runtime errors too. The only trustworthy signal that a write
landed is reading the value back.
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
and it ranges from a half-screen split to the full display. `SliderRow` stacks
its control under the label below 520px. Test narrow.
**Binding an anchor to `undefined` does not reliably release it.** Switching
layouts that way left a slider anchored to both edges with the label squeezed
into what was left. Position explicitly instead.
**Inside a `SettingsCard`, `parent` is the card's internal Column.** So
`parent.modelData` in a nested `Repeater` is undefined and the rows silently
never appear — you get a card with a heading and nothing under it. Address the
outer model through an explicit `id`.
**A `TapHandler` declared as a child of `SettingRow` lands in the trailing
slot**, because that is the row's default property, so only the right-hand edge
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
target.
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives the Shell ID from config *content*, not path, so
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
single distinct `.qml` file are safe; copying the whole directory is not.
## Where state lives
| 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/backups/` | Settings snapshots |
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
## Not stored by Panama
Timezone and network time are read from and written to `timedatectl` directly.
They belong to the machine and are shared with sessions that never see Panama's
file; storing a copy would create a second answer to a question the system
already answers.
@@ -2,9 +2,12 @@ import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
title: "Screen Intelligence"
lede: "Turn text and codes on screen into content you can use."
Component.onCompleted: ScreenIntelligence.refresh()
Timer {
@@ -13,35 +16,6 @@ Item {
onTriggered: Capture.openIntelligence()
}
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text {
text: "Screen Intelligence"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
text: "Turn text and codes on screen into content you can use."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
SettingsCard {
title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
@@ -52,6 +26,7 @@ Item {
detail: "Copy, search, translate, or open detected links"
controlWidth: 160
divider: false
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
@@ -66,21 +41,30 @@ Item {
}
}
SettingsCard {
title: "Capture preferences"
subtitle: "Choose where captures go and how recordings are encoded."
ChoiceRow { setting: "screenshotDir" }
ChoiceRow { setting: "recordingDir" }
ChoiceRow { setting: "recorderArgs"; divider: false }
}
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
SettingRow {
TextRow {
label: "Text recognition"
detail: "Tesseract with the English language model"
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
}
SettingRow {
TextRow {
label: "QR & barcodes"
detail: "ZBar recognizes codes alongside ordinary text"
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
}
SettingRow {
TextRow {
label: "Privacy"
detail: "Only Search, Translate, and Open send the selected result to another application or service"
value: "Local first"
@@ -90,14 +74,15 @@ Item {
SettingsCard {
title: "Shortcut"
SettingRow {
TextRow {
label: "Read a screen selection"
detail: "Also available as Read in the Print-screen picker"
value: "Super + Shift + S"
controlWidth: 190
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
}
SettingRow {
TextRow {
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
label: "Install recognition engines"
detail: "sudo dnf install -y tesseract zbar"
@@ -106,5 +91,3 @@ Item {
}
}
}
}
}
@@ -2,89 +2,132 @@ import QtQuick
import qs.config
import qs.services
Item {
function status(active: bool): string { return active ? "Running" : "Stopped"; }
SettingsPage {
title: "Startup & Services"
lede: "A clear view of the background tools that make the desktop feel complete."
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
function status(active: bool): string {
return active ? "Running" : "Stopped";
}
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Item {
width: parent.width
implicitHeight: refresh.implicitHeight
Row {
width: parent.width
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() }
SettingsButton {
id: refresh
anchors.right: parent.right
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
enabled: !SystemSettings.busy
onClicked: SystemSettings.refresh()
}
}
Text { text: "A clear view of the background tools that make the desktop feel complete."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsCard {
title: "Your services"
SettingRow {
label: "Nextcloud"
detail: "File synchronization and tray status"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") }
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.nextcloudActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("nextcloud")
}
}
}
SettingRow {
label: "RustDesk"
detail: "Remote access through the enabled system service"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") }
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.rustdeskActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("rustdesk")
}
}
}
SettingRow {
label: "KDE Connect"
detail: "Phone pairing, clipboard, files, and remote controls"
divider: false
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") }
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.kdeconnectActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("kdeconnect")
}
}
}
}
SettingsCard {
title: "Desktop foundation"
SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) }
SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) }
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
TextRow {
label: "Hyprpaper"
detail: "Wallpaper service"
value: status(SystemSettings.hyprpaperActive)
}
TextRow {
label: "Hypridle"
detail: "Idle and lock policy"
value: status(SystemSettings.hypridleActive)
}
TextRow {
label: "Vicinae"
detail: "Spotlight-style launcher daemon"
value: status(SystemSettings.vicinaeActive)
divider: false
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
SettingRow {
ActionRow {
label: "Network, Bluetooth, printers, users, and accounts"
detail: "GNOME Settings remains searchable from the launcher too"
divider: false
controlWidth: 122
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") }
}
}
action: "Open network"
onTriggered: SystemSettings.openGnomePanel("network")
}
}
}
@@ -12,6 +12,12 @@ Item {
property bool divider: true
property int controlWidth: 150
// Rows are inert by default. A row that represents a choice -- picking a
// timezone from a list, say -- opts into being clickable across its whole
// width, which is a much larger target than the trailing control alone.
property bool activatable: false
signal activated
width: parent ? parent.width : 620
implicitHeight: Math.max(56, copy.implicitHeight + 20)
@@ -85,4 +91,27 @@ Item {
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
// Declared in this component's own body, so it is a child of the row rather
// than of the trailing slot that `trailingData` routes external children to.
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 1
radius: 8
z: -1
visible: root.activatable && rowHover.hovered
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
HoverHandler {
id: rowHover
enabled: root.activatable
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: root.activatable
onTapped: root.activated()
}
}
@@ -0,0 +1,94 @@
// The scaffold every settings page shares: a scrolling column with a title, a
// one-line lede, and consistent margins.
//
// This existed eleven times as copy-pasted Flickable/Column/x:34/y:30 blocks,
// which is a large part of why several pages settled for read-only text instead
// of real controls -- adding a page was expensive enough that the cheap thing
// won. Pages are now just their content:
//
// SettingsPage {
// title: "Appearance"
// lede: "Tokyo Night Moon, tuned for clarity and quiet motion."
//
// SettingsCard { title: "Windows"; ToggleRow { setting: "blurEnabled" } }
// }
import QtQuick
import qs.config
Item {
id: root
default property alias content: column.data
property string title: ""
property string lede: ""
// Anything that should sit above the title and stay put while the rest
// scrolls -- the Appearance page pins its live preview here.
property Component header: null
Loader {
id: pinnedHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
active: root.header !== null
sourceComponent: root.header
z: 1
}
Flickable {
id: pageScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
anchors.bottom: parent.bottom
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
clip: true
contentWidth: width
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
boundsBehavior: Flickable.StopAtBounds
Column {
id: layout
width: parent.width - 68
x: 34
y: pinnedHeader.implicitHeight > 0 ? 0 : 30
spacing: 16
Text {
width: parent.width
visible: root.title !== ""
text: root.title
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
width: parent.width
visible: root.lede !== ""
text: root.lede
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
bottomPadding: 6
}
Column {
id: column
width: parent.width
spacing: 16
}
}
}
}
@@ -99,6 +99,10 @@ Rectangle {
case "notifications": return notificationsPage;
case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage;
case "accessibility": return accessibilityPage;
case "power": return powerPage;
case "datetime": return dateTimePage;
case "applications": return applicationsPage;
case "services": return servicesPage;
case "about": return aboutPage;
default: return homePage;
@@ -136,6 +140,10 @@ Rectangle {
}
Component { id: homePage; HomePage {} }
Component { id: applicationsPage; ApplicationsPage {} }
Component { id: accessibilityPage; AccessibilityPage {} }
Component { id: powerPage; PowerPage {} }
Component { id: dateTimePage; DateTimePage {} }
Component { id: appearancePage; AppearancePage {} }
Component { id: displaysPage; DisplaysPage {} }
Component { id: connectivityPage; ConnectivityPage {} }
@@ -1,5 +1,6 @@
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
@@ -8,6 +9,18 @@ Rectangle {
property string query: searchInput.text.trim().toLowerCase()
signal pageRequested(string page)
readonly property var results: SettingsSearch.search(root.query)
onQueryChanged: {
if (sidebarScroll)
sidebarScroll.contentY = 0;
}
function pageLabel(page: string): string {
const found = root.destinations.find(item => item.page === page);
return found ? found.label : "Settings";
}
readonly property var destinations: [
{ page: "home", label: "Home", icon: "\u{F02DC}" },
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
@@ -19,6 +32,10 @@ Rectangle {
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
]
@@ -28,8 +45,15 @@ Rectangle {
border.width: 0
Column {
anchors.fill: parent
anchors.margins: 18
id: sidebarHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 18
height: implicitHeight
spacing: 12
Text {
@@ -91,13 +115,117 @@ Rectangle {
onClicked: searchInput.forceActiveFocus()
}
}
}
Flickable {
id: sidebarScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: sidebarHeader.bottom
anchors.bottom: healthFooter.top
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 12
anchors.bottomMargin: 12
contentWidth: width
contentHeight: scrollContent.implicitHeight
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: scrollContent
width: sidebarScroll.width
// ── Search results ──────────────────────────────────────────────
// Typing searches the settings themselves, not page names.
Column {
id: searchResults
width: parent.width
spacing: 4
spacing: 3
visible: root.query !== ""
Text {
width: parent.width
leftPadding: 4
bottomPadding: 4
text: root.results.length === 0
? "Nothing matches"
: root.results.length + (root.results.length === 1 ? " result" : " results")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Repeater {
model: root.destinations.filter(item => root.query === "" || item.label.toLowerCase().includes(root.query))
model: root.results
Rectangle {
id: hit
required property var modelData
width: parent.width
height: 44
radius: 10
color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent"
border.width: 0
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
width: parent.width
text: hit.modelData.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
width: parent.width
text: hit.modelData.kind === "shortcut"
? hit.modelData.detail
: root.pageLabel(hit.modelData.page)
color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
MouseArea {
id: hitMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.pageRequested(hit.modelData.page);
searchInput.text = "";
}
}
}
}
}
Column {
id: navigationList
width: parent.width
spacing: 4
visible: root.query === ""
Repeater {
model: root.destinations
Rectangle {
id: navItem
@@ -160,8 +288,11 @@ Rectangle {
}
}
}
}
Rectangle {
id: healthFooter
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
@@ -0,0 +1,132 @@
// Captures a key chord for rebinding.
//
// Shown in place of a shortcut's chord while it is being changed. It takes
// keyboard focus, waits for a non-modifier key, and reports the chord in the
// form hypr/keybinds.lua uses.
//
// Modifier-only presses are ignored rather than accepted, because every press
// of a chord passes through them: holding Super to type Super+K would otherwise
// be captured as "SUPER" the moment the modifier went down.
//
// Escape cancels. Not every Qt key has a keysym name Hyprland would accept, so
// an unmapped key is refused with a message rather than written as something
// that would silently fail to bind.
import QtQuick
import qs.config
FocusScope {
id: root
signal captured(string chord)
signal cancelled
property string message: ""
implicitWidth: 230
implicitHeight: 30
// Qt key codes to the keysym names Hyprland expects. Letters and digits
// fall out of the ASCII range; these are the rest that come up in practice.
readonly property var namedKeys: ({
0x01000000: "Escape",
0x01000001: "Tab",
0x01000004: "Return",
0x01000005: "Return",
0x01000003: "BackSpace",
0x01000006: "Insert",
0x01000007: "Delete",
0x01000010: "Home",
0x01000011: "End",
0x01000016: "Page_Up",
0x01000017: "Page_Down",
0x01000012: "left",
0x01000013: "up",
0x01000014: "right",
0x01000015: "down",
0x20: "space",
0x2c: "comma",
0x2e: "period",
0x2f: "slash",
0x3b: "semicolon",
0x27: "apostrophe",
0x5b: "bracketleft",
0x5d: "bracketright",
0x5c: "backslash",
0x60: "grave",
0x2d: "minus",
0x3d: "equal",
0x01000009: "Print"
})
function keysymFor(key: int): string {
if (key >= 0x41 && key <= 0x5a) // A-Z
return String.fromCharCode(key);
if (key >= 0x30 && key <= 0x39) // 0-9
return String.fromCharCode(key);
if (key >= 0x01000030 && key <= 0x0100003b) // F1-F12
return "F" + (key - 0x01000030 + 1);
return root.namedKeys[key] ?? "";
}
function isModifierOnly(key: int): bool {
return key === 0x01000020 || key === 0x01000021 || key === 0x01000022
|| key === 0x01000023 || key === 0x01000024;
}
Rectangle {
anchors.fill: parent
radius: 9
color: Theme.alpha(Theme.accent, 0.14)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
anchors.centerIn: parent
width: parent.width - 16
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.message !== "" ? root.message : "Press a shortcut… Esc to cancel"
color: root.message !== "" ? Theme.warn : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Keys.onPressed: event => {
event.accepted = true;
if (event.key === 0x01000000) { // Escape
root.cancelled();
return;
}
if (root.isModifierOnly(event.key))
return;
const keysym = root.keysymFor(event.key);
if (keysym === "") {
root.message = "That key cannot be used";
return;
}
const parts = [];
if (event.modifiers & Qt.MetaModifier) parts.push("SUPER");
if (event.modifiers & Qt.ControlModifier) parts.push("CTRL");
if (event.modifiers & Qt.AltModifier) parts.push("ALT");
if (event.modifiers & Qt.ShiftModifier) parts.push("SHIFT");
if (parts.length === 0 && keysym.length === 1) {
// A bare letter or digit would swallow ordinary typing.
root.message = "Add a modifier";
return;
}
parts.push(keysym);
root.captured(parts.join(" + "));
}
onActiveFocusChanged: {
if (!activeFocus)
root.cancelled();
}
}
@@ -1,73 +1,184 @@
// Input & Shortcuts.
//
// The shortcut list is generated from `hyprctl binds -j` rather than typed out
// here. The previous version was a hand-maintained array of nineteen entries
// against a real keymap of a hundred and thirteen: it could not show the rest,
// and it went stale the moment a bind changed. Every bind now carries its own
// 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;
// device-specific configuration stays with GNOME.
import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
readonly property var shortcuts: [
{ key: "Super + T", action: "Terminal" },
{ key: "Super + N", action: "Neovim in the current directory" },
{ key: "Super + W", action: "Web browser" },
{ key: "Super + F", action: "Files" },
{ key: "Super + I", action: "Panama Settings" },
{ key: "Super + Space", action: "Launcher" },
{ key: "Super + Grave", action: "Continuum overview" },
{ key: "Super + V", action: "Clipboard history" },
{ key: "Super + S", action: "Quick Settings" },
{ key: "Super + B", action: "Notifications" },
{ key: "Super + Shift + S", action: "Screen Intelligence" },
{ key: "Super + Shift + F", action: "Focus session" },
{ key: "Alt + H / L", action: "Previous / next workspace" },
{ key: "Alt + Shift + H / L", action: "Move window between workspaces" },
{ key: "Super + H / J / K / L", action: "Focus a tiled window" },
{ key: "Super + Shift + H / J / K / L", action: "Move a tiled window" },
{ key: "Super + Shift + X", action: "Send window to scratchpad" },
{ key: "Super + X", action: "Toggle scratchpad" },
{ key: "Print", action: "Capture, record, or read" }
]
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
// The chord of the bind currently being re-recorded, in the Lua form; empty
// when nothing is being captured. Held here rather than per row so that
// starting a new capture cancels any other.
property string capturingChord: ""
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Input & Shortcuts"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "The Forge mental model, carried forward into native tiling."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
title: "Input & Shortcuts"
lede: "The Forge mental model, carried forward into native tiling."
SettingsCard {
title: "Panama shortcuts"
Repeater {
model: root.shortcuts
SettingRow {
required property var modelData
required property int index
label: modelData.action
value: modelData.key
controlWidth: 210
divider: index < root.shortcuts.length - 1
title: "Keyboard"
TextRow {
label: "Keyboard layout"
detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly."
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us"
}
SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" }
ToggleRow { setting: "numlockByDefault"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "pointerSensitivity" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard {
title: "Hardware input"
SettingRow {
ActionRow {
label: "Mouse, touchpad, and keyboard devices"
detail: "Use Fedora's hardware-backed input panels"
detail: "Device-specific settings stay with Fedora's hardware-backed panels"
action: "Open keyboard"
divider: false
controlWidth: 126
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") }
onTriggered: SystemSettings.openGnomePanel("keyboard")
}
}
// One card per group, built from what the compositor actually has bound.
//
// Both Repeaters address their model through an explicit id. Inside a
// SettingsCard the surrounding `parent` is the card's internal Column, not
// the card, so `parent.modelData` is undefined there and the rows silently
// never appear -- the cards render with their heading and nothing under it.
Repeater {
model: Keybinds.grouped()
SettingsCard {
id: groupCard
required property var modelData
title: groupCard.modelData.name
subtitle: groupCard.modelData.binds.length === 1
? "1 shortcut"
: `${groupCard.modelData.binds.length} shortcuts`
Repeater {
id: bindRows
model: groupCard.modelData.binds
SettingRow {
id: bindRow
required property var modelData
required property int index
readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
label: bindRow.modelData.description
detail: bindRow.overridden
? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
: ""
controlWidth: 300
divider: bindRow.index < bindRows.count - 1
Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 300
height: 30
ShortcutCapture {
anchors.right: parent.right
width: 230
height: 30
visible: bindRow.capturing
focus: bindRow.capturing
onCaptured: chord => {
Keybinds.rebind(bindRow.modelData.luaChord, chord);
root.capturingChord = "";
}
onCancelled: root.capturingChord = ""
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !bindRow.capturing
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: bindRow.modelData.chord
color: bindRow.overridden ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Change"
enabled: !Keybinds.reloading && !bindRow.modelData.mouse
onClicked: root.capturingChord = bindRow.modelData.luaChord
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: bindRow.overridden
text: "Reset"
enabled: !Keybinds.reloading
onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
}
}
}
}
}
}
}
SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
ActionRow {
label: "Restore every shipped shortcut"
detail: Object.keys(Keybinds.overrides).length
+ (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
action: "Restore all"
divider: false
enabled: !Keybinds.reloading
onTriggered: Keybinds.resetAll()
}
}
SettingsCard {
visible: Keybinds.lastError !== ""
title: "Shortcuts unavailable"
subtitle: Keybinds.lastError
ActionRow {
label: "Read the keymap again"
detail: "Shortcuts are read from the running compositor"
action: "Retry"
divider: false
onTriggered: Keybinds.refresh()
}
}
}
@@ -0,0 +1,193 @@
// A numeric setting, bound to a schema key by name.
//
// SliderRow { setting: "windowRounding" }
//
// Range, step, label, explanation, and unit all come from PreferenceSchema.
//
// Three behaviours 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
// split to the full 4500px display, and `implicitWidth` is only a hint.
// Below a usable inline width the slider moves onto its own line under the
// label instead of squeezing the explanation into a five-line column.
// * The readout follows the drag immediately, but the value is only committed
// after a short quiet period. Compositor-backed settings are applied and
// verified one batch at a time, and a slider fires dozens of changes per
// second -- committing each would spend the whole drag rejecting
// overlapping writes.
// * Between commits the row shows what you are dragging; once settled it
// shows what is actually stored. If the compositor refuses a value the row
// falls back to the stored one rather than displaying a value nothing
// accepted.
//
// This does not extend SettingRow: that component fixes the control to a
// trailing column of a set width, which is the layout this row needs to be able
// to abandon. The label, explanation, and divider match it exactly.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Item {
id: root
required property string setting
property bool divider: true
property string zeroLabel: ""
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property string label: root.spec ? root.spec.label : root.setting
readonly property string detail: root.spec ? root.spec.detail : ""
readonly property real minimum: root.spec && root.spec.min !== undefined ? root.spec.min : 0
readonly property real maximum: root.spec && root.spec.max !== undefined ? root.spec.max : 100
readonly property real step: root.spec && root.spec.step !== undefined ? root.spec.step : 1
readonly property string unit: root.spec && root.spec.unit !== undefined ? root.spec.unit : ""
readonly property real stored: {
const value = DesktopPreferences.get(root.setting);
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
// Below this the label and a usable slider cannot share a line without one
// of them becoming useless.
readonly property bool inline: width >= 520
// 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
// tiled, so it really can be that wide.
readonly property int controlSpan: 300
width: parent ? parent.width : 620
implicitHeight: root.inline
? Math.max(56, copy.implicitHeight + 20)
: copy.implicitHeight + 32 + 30
function quantise(ratio: real): real {
const raw = root.minimum + ratio * (root.maximum - root.minimum);
const snapped = Math.round(raw / root.step) * root.step;
const clamped = Math.max(root.minimum, Math.min(root.maximum, snapped));
// Steps below 1 are fractional (opacity, pointer speed); rounding to two
// places keeps 0.8500000000000001 out of the readout and the store.
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
}
function display(value: real): string {
if (value === 0 && root.zeroLabel !== "")
return root.zeroLabel;
const text = root.step < 1 ? value.toFixed(2) : String(value);
return root.unit === "" ? text : `${text} ${root.unit}`;
}
// Both children are positioned explicitly rather than by anchors. Binding
// an anchor to `undefined` to switch layouts does not reliably release it,
// which left the slider anchored to both edges and the label squeezed into
// whatever was left.
Column {
id: copy
x: 0
y: root.inline ? (root.height - height) / 2 : 10
width: root.inline ? root.width - root.controlSpan - 20 : root.width
spacing: 3
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.detail !== ""
text: root.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Item {
id: control
width: root.inline ? root.controlSpan : root.width
height: 32
x: root.inline ? root.width - width : 0
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
ValueSlider {
id: slider
anchors.left: parent.left
anchors.right: readout.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
value: root.maximum > root.minimum
? (root.shown - root.minimum) / (root.maximum - root.minimum)
: 0
onMoved: ratio => {
root.pending = root.quantise(ratio);
commitTimer.restart();
}
}
Text {
id: readout
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 58
horizontalAlignment: Text.AlignRight
text: root.display(root.shown)
color: Theme.fgDim
font.family: Theme.fontFamily
// The readout changes digit by digit under the pointer; tabular
// figures stop it twitching sideways as it does.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
Timer {
id: commitTimer
interval: 140
onTriggered: {
if (root.pending < 0)
return;
SystemSettings.commitPreference(root.setting, root.pending);
// Hand the display back to the stored value. If the write was
// refused, the row snaps back to what is really in effect.
releaseTimer.restart();
}
}
Timer {
id: releaseTimer
interval: 160
onTriggered: root.pending = -1
}
}
@@ -0,0 +1,40 @@
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
import qs.services
Column {
id: root
property bool output: true
readonly property var nodes: AudioDevices.nodes(root.output)
readonly property var current: AudioDevices.current(root.output)
width: parent ? parent.width : 620
spacing: 8
Repeater {
model: root.nodes
SoundDeviceRow {
required property var modelData
width: root.width
node: modelData
output: root.output
selected: modelData === root.current
}
}
Text {
width: parent.width
visible: root.nodes.length === 0
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
topPadding: 18
bottomPadding: 18
}
}
@@ -0,0 +1,168 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
import qs.widgets
import qs.services
import qs.modules.quicksettings
Rectangle {
id: root
required property var node
property bool output: true
property bool selected: false
implicitHeight: root.output || !root.selected ? 88 : 105
radius: Theme.cardRadius
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
border.width: 1
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
PwObjectTracker {
objects: root.node ? [root.node] : []
}
PwNodePeakMonitor {
id: inputPeak
node: root.node
enabled: !root.output && root.selected
}
readonly property real volume: root.node?.audio?.volume ?? 0
readonly property bool muted: root.node?.audio?.muted ?? false
function iconName(): string {
if (!root.output)
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
if (root.muted || root.volume <= 0.001)
return "audio-volume-muted-symbolic";
if (root.volume < 0.34)
return "audio-volume-low-symbolic";
if (root.volume < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
Row {
id: heading
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.topMargin: 8
height: 28
spacing: 10
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 20
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-symbolic"
tint: root.selected ? Theme.accent : Theme.fg
}
Column {
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
width: parent.width
text: AudioDevices.label(root.node)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: root.selected ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
text: root.node.nickname ?? ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
SettingsButton {
id: useButton
anchors.verticalCenter: parent.verticalCenter
width: root.selected ? 78 : 64
text: root.selected ? "Default" : "Use"
enabled: !root.selected
onClicked: AudioDevices.select(root.output, root.node)
}
}
IconButton {
id: muteButton
anchors.left: parent.left
anchors.leftMargin: 8
anchors.top: heading.bottom
anchors.topMargin: 7
size: 30
iconSize: 17
icon: root.iconName()
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
onClicked: {
if (root.node?.audio)
root.node.audio.muted = !root.node.audio.muted;
}
}
ValueSlider {
id: volumeSlider
anchors.left: muteButton.right
anchors.leftMargin: 7
anchors.right: volumeText.left
anchors.rightMargin: 10
anchors.verticalCenter: muteButton.verticalCenter
value: root.muted ? 0 : root.volume
onMoved: value => {
if (!root.node?.audio)
return;
root.node.audio.muted = false;
root.node.audio.volume = value;
}
}
Text {
id: volumeText
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: muteButton.verticalCenter
width: 38
text: Math.round(root.volume * 100) + "%"
color: Theme.fgDim
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
}
Rectangle {
anchors.left: volumeSlider.left
anchors.right: volumeText.right
anchors.top: muteButton.bottom
anchors.topMargin: 5
height: 5
radius: height / 2
visible: !root.output && root.selected
color: Theme.alpha(Theme.fg, 0.10)
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
radius: parent.radius
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
}
}
}
@@ -1,53 +1,77 @@
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Sound"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Live PipeWire output, input, and device selection."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsPage {
title: "Sound"
lede: "Live PipeWire output, input, and device selection."
SettingsCard {
title: "Output"
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSink; output: true }
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
AudioDeviceList { width: parent.width; output: true; maxHeight: 190 }
subtitle: AudioDevices.current(true)?.description ?? "No output device"
SoundDeviceList {
width: parent.width
output: true
}
AudioBalance {
width: parent.width
node: AudioDevices.current(true)
}
}
SettingsCard {
title: "Input"
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSource; output: false }
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
AudioDeviceList { width: parent.width; output: false; maxHeight: 160 }
subtitle: AudioDevices.current(false)?.description ?? "No input device"
SoundDeviceList {
width: parent.width
output: false
}
}
SettingsCard {
title: "Sound feedback"
subtitle: "Use the same event preferences as GTK and GNOME applications."
SettingRow {
label: "Event sounds"
detail: "Play alerts and interface event sounds"
controlWidth: 42
SettingsToggle {
anchors.fill: parent
checked: SoundFeedback.eventSounds
enabled: !SoundFeedback.busy
onToggled: checked => SoundFeedback.setEventSounds(checked)
}
}
SettingRow {
label: "Input feedback"
detail: "Play sounds for supported typing and input events"
controlWidth: 42
divider: false
SettingsToggle {
anchors.fill: parent
checked: SoundFeedback.inputFeedback
enabled: !SoundFeedback.busy
onToggled: checked => SoundFeedback.setInputFeedback(checked)
}
}
}
SettingsCard {
title: "Advanced sound"
SettingRow {
ActionRow {
label: "Application volumes and profiles"
detail: "Open Fedora's complete sound panel"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") }
}
}
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("sound")
}
}
}
@@ -0,0 +1,19 @@
// A row that only reports something.
//
// TextRow { label: "Graphics"; detail: "Rendering device"; value: "AMD Radeon" }
//
// Genuinely read-only facts -- the active display's identity, a version string,
// a detected capability -- belong here. A setting the user could reasonably
// change does NOT: before Stage 3 more than half of Panama's settings rows were
// static text standing in for a control that was simply expensive to add, and
// this component exists for the honest remainder rather than to make that easy
// to do again.
import QtQuick
import qs.config
SettingRow {
id: root
controlWidth: 210
}
@@ -0,0 +1,31 @@
// A time of day, stored as a decimal hour.
//
// SliderRow would render 17.5 as "17.50", which is not how anyone reads a
// clock. This is the same control with the readout formatted as a time, so the
// night light schedule says "5:30 PM" rather than a number you have to convert.
//
// Honours the 24-hour clock preference, because a user who has asked for 18:30
// everywhere else should not be shown 6:30 PM here.
import QtQuick
import qs.config
import qs.services
SliderRow {
id: root
// SliderRow renders `unit` after the number; a time needs the whole readout
// replaced, so the formatting is done here instead.
function display(value: real): string {
const hour = Math.floor(value);
const minute = Math.round((value - hour) * 60);
const padded = String(minute).padStart(2, "0");
if (Settings.use24Hour)
return `${String(hour).padStart(2, "0")}:${padded}`;
const suffix = hour < 12 ? "AM" : "PM";
const twelve = hour % 12 === 0 ? 12 : hour % 12;
return `${twelve}:${padded} ${suffix}`;
}
}
@@ -0,0 +1,33 @@
// A boolean setting, bound to a schema key by name.
//
// ToggleRow { setting: "blurEnabled" }
//
// Label, explanation, and validation all come from PreferenceSchema, so a row
// cannot drift from the setting it edits, and the writing side does not care
// whether the key is stored locally or applied to the compositor first.
// Override `label` or `detail` only when a page needs different wording than
// the schema's default.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property bool checked: DesktopPreferences.get(root.setting) === true
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.checked
onToggled: value => SystemSettings.commitPreference(root.setting, value)
}
}
@@ -0,0 +1,164 @@
// The wallpaper grid.
//
// A thumbnail grid rather than a file path field: choosing a background is the
// one setting where the value IS the picture, and typing a path to something
// you cannot see is the worst possible version of it.
//
// Images are loaded asynchronously and at a fraction of their real size --
// several of the candidates here are 8-12 MB, and decoding them at full
// resolution to draw a 150px tile would cost more memory than the rest of the
// shell put together.
import QtQuick
import QtQuick.Effects
import qs.config
import qs.services
Item {
id: root
implicitHeight: grid.implicitHeight
// Sixty tiles is two screens of wallpaper on a page that also holds
// typography, window geometry and effects -- everything below it becomes
// unreachable without a long scroll past pictures. Two rows by default,
// all of them on request.
property bool expanded: false
readonly property int collapsedRows: 2
readonly property var shown: root.expanded
? Wallpaper.available
: Wallpaper.available.slice(0, root.columns * root.collapsedRows)
readonly property int hidden: Wallpaper.available.length - root.shown.length
readonly property int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
Grid {
id: grid
width: parent.width
columns: root.columns
spacing: 10
Repeater {
model: root.shown
Rectangle {
id: tile
required property var modelData
readonly property bool current: Wallpaper.active === tile.modelData
width: root.cellWidth
height: Math.round(root.cellWidth * 9 / 16)
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.6)
clip: true
border.width: 0
Image {
id: thumbnail
anchors.fill: parent
source: "file://" + tile.modelData
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
// Decode to roughly the size actually drawn. Without this a
// grid of 12MB photographs decodes at full resolution.
sourceSize.width: 400
sourceSize.height: 240
// Several of these are 8-12MB originals, so a tile can sit
// empty for a second or two. Fading in on ready makes that
// read as loading rather than as a broken image, and the
// fade runs once per tile rather than continuously.
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutQuad }
}
}
Text {
anchors.centerIn: parent
visible: thumbnail.status === Image.Loading
text: "Loading…"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
anchors.centerIn: parent
width: parent.width - 20
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
visible: thumbnail.status === Image.Error
text: "Could not read this image"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
// The prism marks the active wallpaper, the same way it marks
// the focused window and the selected sidebar entry.
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: tile.current
color: "transparent"
border.width: 2
border.color: Theme.accent
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 26
visible: tile.current || hover.hovered
border.width: 0
gradient: Gradient {
GradientStop { position: 0.0; color: Theme.alpha(Theme.bgDark, 0.0) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.bgDark, 0.88) }
}
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 7
text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData)
color: tile.current ? Theme.accent : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: tile.current ? Font.DemiBold : Font.Normal
elide: Text.ElideRight
}
}
HoverHandler { id: hover }
TapHandler {
onTapped: Wallpaper.set(tile.modelData)
}
}
}
}
Text {
anchors.centerIn: parent
visible: Wallpaper.available.length === 0 && !Wallpaper.scanning
width: parent.width - 40
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
@@ -0,0 +1,177 @@
// Wi-Fi, at page size.
//
// The quick settings version is a popover: a compact list you glance at. This
// is the one you sit in front of when a network is not behaving, so each row
// carries what you would otherwise open a terminal to find out — signal,
// security, and whether it is a network this machine already knows.
//
// Joining a secured network reveals an inline password field rather than
// failing silently, which is the one interaction the popover already got right
// and is worth keeping identical.
import QtQuick
import Quickshell
import Quickshell.Networking
import qs.config
import qs.services
import qs.widgets
Column {
id: root
spacing: 0
// SSID whose password field is open, and the last failure.
property string passwordFor: ""
property string failedSsid: ""
property string failedText: ""
function activate(network: var): void {
root.failedSsid = "";
if (network.connected)
return;
if (network.known || !Connectivity.isSecured(network)) {
root.passwordFor = "";
network.connect();
return;
}
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
}
Repeater {
model: Connectivity.networks
Column {
id: entry
required property var modelData
required property int index
width: parent.width
SettingRow {
width: parent.width
label: entry.modelData.name || "Hidden network"
detail: {
const bits = [];
if (entry.modelData.connected)
bits.push("Connected");
else if (entry.modelData.known)
bits.push("Saved");
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
bits.push(Connectivity.securityLabel(entry.modelData));
return bits.join(" · ");
}
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name
controlWidth: 190
activatable: !entry.modelData.connected
onActivated: root.activate(entry.modelData)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Text {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: "Connected"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: "Disconnect"
onClicked: entry.modelData.disconnect()
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: !entry.modelData.connected
text: entry.modelData.known ? "Connect" : "Join"
onClicked: root.activate(entry.modelData)
}
}
}
// The password field for this network, when it is the one being
// joined. Inline rather than a dialog: a dialog over a tiled window
// is a worse place to type than the row you just clicked.
Item {
width: parent.width
height: root.passwordFor === entry.modelData.name ? 54 : 0
visible: height > 0
clip: true
onVisibleChanged: {
if (visible)
password.grab();
else
password.clear();
}
PasswordField {
id: password
anchors.left: parent.left
anchors.right: joinButton.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
placeholder: "Password for " + (entry.modelData.name || "network")
onAccepted: joinButton.join()
}
SettingsButton {
id: joinButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Join"
function join(): void {
entry.modelData.connect(password.text);
root.passwordFor = "";
password.text = "";
}
onClicked: joinButton.join()
}
}
Text {
width: parent.width
visible: root.failedSsid === entry.modelData.name
leftPadding: 2
bottomPadding: 8
text: root.failedText
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Connections {
target: entry.modelData
function onConnectionFailed(reason): void {
root.failedSsid = entry.modelData.name;
root.failedText = Connectivity.connectionFailureText(reason);
root.passwordFor = entry.modelData.name;
}
}
}
}
SettingRow {
width: parent.width
visible: Connectivity.networks.length === 0
label: !Connectivity.wifiDevice
? "No Wi-Fi adapter"
: (Connectivity.wifiEnabled ? "Looking for networks…" : "Wi-Fi is off")
detail: Connectivity.wifiDevice && !Connectivity.wifiEnabled
? "Turn it on above to see what is nearby"
: ""
divider: false
}
}
@@ -20,3 +20,29 @@ SettingsToggle 1.0 SettingsToggle.qml
SettingsWindow 1.0 SettingsWindow.qml
ShortcutsPage 1.0 ShortcutsPage.qml
SoundPage 1.0 SoundPage.qml
SettingsPage 1.0 SettingsPage.qml
ToggleRow 1.0 ToggleRow.qml
SliderRow 1.0 SliderRow.qml
ChoiceRow 1.0 ChoiceRow.qml
ActionRow 1.0 ActionRow.qml
TextRow 1.0 TextRow.qml
DesktopPreview 1.0 DesktopPreview.qml
PowerPage 1.0 PowerPage.qml
DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml
ApplicationsPage 1.0 ApplicationsPage.qml
DockPinsEditor 1.0 DockPinsEditor.qml
DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.qml
WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml
PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml
SoundDeviceRow 1.0 SoundDeviceRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
+375
View File
@@ -0,0 +1,375 @@
// A living reference for Panama's shared visual language. This deliberately
// renders the production widgets instead of lookalike mock components, so a
// change to a control or Theme token is visible here immediately.
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.config
import qs.widgets
ShellRoot {
FloatingWindow {
id: galleryWindow
title: "Panama Prism Gallery"
visible: true
implicitWidth: 1120
implicitHeight: 760
minimumSize: Qt.size(920, 640)
color: Theme.bg
Rectangle {
anchors.fill: parent
color: Theme.bg
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: 0
}
RowLayout {
anchors.fill: parent
anchors.margins: 34
spacing: 34
// Quiet editorial rail: enough context to make screenshots
// self-explanatory without competing with the components.
ColumnLayout {
Layout.preferredWidth: 222
Layout.fillHeight: true
spacing: 0
Rectangle {
Layout.preferredWidth: 38
Layout.preferredHeight: 38
radius: 12
color: Theme.alpha(Theme.fg, 0.07)
ThemedIcon {
anchors.centerIn: parent
size: 20
icon: "applications-graphics-symbolic"
tint: Theme.accent
}
}
Text {
Layout.topMargin: 20
text: "PRISM"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 26
font.weight: Font.DemiBold
font.letterSpacing: 1.4
}
Text {
Layout.topMargin: 5
Layout.maximumWidth: 210
text: "Panama's living interface reference. Calm by default, vivid with purpose."
color: Theme.fgDim
wrapMode: Text.WordWrap
lineHeight: 1.25
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Item { Layout.preferredHeight: 36 }
Repeater {
model: [
{ label: "Tokyo Night Moon", color: Theme.bgHighlight },
{ label: "Signal blue", color: Theme.accent },
{ label: "Prism orchid", color: Theme.accentSecondary }
]
RowLayout {
required property var modelData
Layout.topMargin: 10
spacing: 10
Rectangle {
Layout.preferredWidth: 12
Layout.preferredHeight: 12
radius: 6
color: modelData.color
}
Text {
text: modelData.label
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Item { Layout.fillHeight: true }
Text {
text: "REAL COMPONENTS · LIVE TOKENS"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Flickable {
anchors.fill: parent
anchors.margins: 30
contentWidth: width
contentHeight: content.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ColumnLayout {
id: content
width: parent.width
spacing: 28
RowLayout {
Layout.fillWidth: true
ColumnLayout {
spacing: 4
Text {
text: "Interface inventory"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
text: "Production controls in their canonical states"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Item { Layout.fillWidth: true }
Pill {
interactive: false
horizontalPadding: 12
ThemedIcon {
size: 14
icon: "emblem-ok-symbolic"
tint: Theme.green
}
Text {
text: "TOKYO NIGHT MOON"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 0.8
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "QUICK CONTROLS"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
RowLayout {
Layout.fillWidth: true
spacing: 12
Toggle {
id: wifiToggle
Layout.fillWidth: true
icon: "network-wireless-signal-excellent-symbolic"
label: "Wi-Fi"
sublabel: active ? "Connected" : "Off"
active: true
onToggled: active = !active
}
Toggle {
id: bluetoothToggle
Layout.fillWidth: true
icon: "bluetooth-active-symbolic"
label: "Bluetooth"
sublabel: active ? "2 devices" : "Off"
active: false
onToggled: active = !active
}
Toggle {
id: focusToggle
Layout.fillWidth: true
icon: "weather-clear-night-symbolic"
label: "Focus"
sublabel: active ? "Until 5:30" : "Available"
active: false
onToggled: active = !active
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "CONTINUOUS VALUES"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 126
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.045)
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
RowLayout {
Layout.fillWidth: true
Text {
text: "System audio"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Item { Layout.fillWidth: true }
Text {
text: Math.round(volumeSlider.value * 100) + "%"
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
}
ValueSlider {
id: volumeSlider
Layout.fillWidth: true
value: 0.68
icon: "audio-volume-high-symbolic"
onMoved: newValue => volumeSlider.value = newValue
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "BAR MATERIAL"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 74
radius: Theme.cardRadius
color: Theme.alpha(Theme.bg, 0.62)
Row {
anchors.centerIn: parent
spacing: 8
Pill {
ThemedIcon {
size: 15
icon: "view-grid-symbolic"
tint: Theme.accent
}
Text {
text: "1 2 3"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Pill {
ThemedIcon {
size: 15
icon: "edit-copy-symbolic"
tint: Theme.fgDim
}
Text {
text: "Clipboard"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Pill {
ThemedIcon {
size: 15
icon: "appointment-soon-symbolic"
tint: Theme.fgDim
}
Text {
text: "Tue Aug 18 · 4:46 AM"
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
}
}
}
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# One stable command boundary for launcher actions. Vicinae scripts stay tiny,
# while the actual mapping to Quickshell IPC remains testable in one place.
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
action="${1:-}"
helper_dir="${PANAMA_ACTION_HELPER_DIR:-$script_dir}"
confirm_attempts="${PANAMA_ACTION_CONFIRM_ATTEMPTS:-20}"
confirm_delay="${PANAMA_ACTION_CONFIRM_DELAY:-0.1}"
kill_command="${PANAMA_ACTION_KILL_COMMAND:-/usr/bin/kill}"
osd_command=("$helper_dir/panama-osd")
gallery_command=("$helper_dir/panama-prism-gallery")
report_failure() {
local status=$?
trap - EXIT
if (( status != 0 )) && command -v notify-send >/dev/null 2>&1; then
notify-send -a Panama -i dialog-error-symbolic \
"Panama action failed" \
"The “${action//-/ }” action could not be completed." || true
fi
exit "$status"
}
trap report_failure EXIT
show_state() {
local state="$1" on_icon="$2" off_icon="$3" label="$4"
case "$state" in
true) PANAMA_OSD_STRICT=1 "${osd_command[@]}" message "$on_icon" "$label On" ;;
false) PANAMA_OSD_STRICT=1 "${osd_command[@]}" message "$off_icon" "$label Off" ;;
*)
printf 'Panama action returned an invalid state: %s\n' "$state" >&2
return 1
;;
esac
}
state_is_active() {
local target="$1"
case "$target" in
caffeine)
systemd-inhibit --list --no-pager --no-legend 2>/dev/null \
| awk '$1 == "Panama" && $(NF - 1) == "Caffeine" && $NF == "block" { found = 1 } END { exit !found }'
;;
night-light)
pgrep -u "$(id -u)" -x hyprsunset >/dev/null 2>&1
;;
*) return 2 ;;
esac
}
release_caffeine_inhibitors() {
local inhibitors pid uid
uid="$(id -u)"
inhibitors="$(systemd-inhibit --list --no-pager --no-legend 2>/dev/null \
| awk -v uid="$uid" '$1 == "Panama" && $2 == uid && $(NF - 1) == "Caffeine" && $NF == "block" { print $4 }')"
while IFS= read -r pid; do
[[ $pid =~ ^[0-9]+$ ]] || continue
"$kill_command" "$pid" 2>/dev/null || true
done <<<"$inhibitors"
}
wait_for_confirmed_state() {
local target="$1" expected="$2" attempt
for ((attempt = 0; attempt < confirm_attempts; attempt++)); do
if [[ $expected == true ]] && state_is_active "$target"; then
return 0
fi
if [[ $expected == false ]] && ! state_is_active "$target"; then
return 0
fi
sleep "$confirm_delay"
done
printf 'Panama action could not confirm %s reached state %s\n' "$target" "$expected" >&2
return 1
}
toggle_state() {
local target="$1" on_icon="$2" off_icon="$3" label="$4" state
state="$(qs ipc call "$target" toggle)"
case "$state" in true|false) ;; *) show_state "$state" "$on_icon" "$off_icon" "$label"; return ;; esac
if [[ $target == caffeine && $state == false ]]; then
release_caffeine_inhibitors
fi
wait_for_confirmed_state "$target" "$state"
show_state "$state" "$on_icon" "$off_icon" "$label"
}
wait_for_shell() {
local attempt
for ((attempt = 0; attempt < confirm_attempts; attempt++)); do
if qs ipc show >/dev/null 2>&1; then
return 0
fi
sleep "$confirm_delay"
done
printf 'Panama shell did not become ready after restart\n' >&2
return 1
}
case "$action" in
control-center) qs ipc call quicksettings open ;;
notifications) qs ipc call notifications open ;;
calendar) qs ipc call calendar-agenda open ;;
clipboard) qs ipc call clipboard open ;;
overview) qs ipc call overview open ;;
settings) qs ipc call settings open ;;
dnd)
state="$(qs ipc call notifications dnd)"
show_state "$state" notifications-disabled-symbolic notifications-symbolic "Do Not Disturb"
;;
caffeine)
toggle_state caffeine weather-clear-symbolic weather-clear-night-symbolic Caffeine
;;
night-light)
toggle_state night-light night-light-symbolic night-light-disabled-symbolic "Night Light"
;;
focus-start)
state="$(qs ipc call focus start)"
[[ $state == true ]]
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message preferences-system-time-symbolic "Focus Session Started"
;;
focus-end)
state="$(qs ipc call focus end)"
if [[ $state == true ]]; then
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message media-playback-stop-symbolic "Focus Session Ended"
else
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message dialog-information-symbolic "No Focus Session Running"
fi
;;
capture) qs ipc call capture open ;;
intelligence) qs ipc call screen-intelligence open ;;
screenshot) qs ipc call capture screenNow ;;
microphone) PANAMA_OSD_STRICT=1 "${osd_command[@]}" microphone toggle ;;
gallery) "${gallery_command[@]}" ;;
restart-shell)
qs kill >/dev/null 2>&1 || true
quickshell --daemonize
wait_for_shell
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message view-refresh-symbolic "Panama Restarted"
;;
*)
printf 'Usage: panama-action {%s}\n' \
'control-center|notifications|calendar|clipboard|overview|settings|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
exit 2
;;
esac
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Read and update freedesktop defaults for Panama's settings page."""
from __future__ import annotations
import ast
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
ROLE_TARGETS = {
"browser": ("settings", "default-web-browser"),
"mail": ("mime", "x-scheme-handler/mailto"),
"files": ("mime", "inode/directory"),
"terminal": ("mime", "x-scheme-handler/terminal"),
"music": ("mime", "audio/mpeg"),
"images": ("mime", "image/png"),
"video": ("mime", "video/mp4"),
}
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
class BoundaryError(RuntimeError):
"""A user-visible validation or command failure."""
def xdg_data_roots() -> list[Path]:
data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share"))
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]:
desktop_ids: set[str] = set()
for root in xdg_data_roots():
applications = root / "applications"
if not applications.is_dir():
continue
for path in applications.rglob("*.desktop"):
if not path.is_file():
continue
relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts))
return desktop_ids
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
if not DESKTOP_ID.fullmatch(desktop_id) or desktop_id not in discovered:
raise BoundaryError("That application is not available.")
def run(command: list[str]) -> str:
completed = subprocess.run(command, check=False, capture_output=True, text=True)
if completed.returncode != 0:
detail = completed.stderr.strip()
raise BoundaryError(detail or "The system default could not be updated.")
return completed.stdout.strip()
def query_handlers() -> dict[str, str]:
handlers: dict[str, str] = {}
for role, (kind, target) in ROLE_TARGETS.items():
command = (
["xdg-settings", "get", target]
if kind == "settings"
else ["xdg-mime", "query", "default", target]
)
output = run(command)
handlers[role] = output.splitlines()[0] if output else ""
return handlers
def parse_desktop_entry(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
section = ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as error:
raise BoundaryError(f"Could not read {path.name}.") from error
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1]
continue
if section != "Desktop Entry" or "=" not in line or stripped.startswith("#"):
continue
key, value = line.split("=", 1)
values.setdefault(key.strip(), value.strip())
return values
def autostart_directory() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return config_home / "autostart"
def user_autostart_entries() -> list[dict[str, object]]:
directory = autostart_directory()
if not directory.is_dir():
return []
entries: list[dict[str, object]] = []
for path in directory.glob("*.desktop"):
if path.is_symlink() or not path.is_file():
continue
values = parse_desktop_entry(path)
entries.append(
{
"id": path.name,
"name": values.get("Name", path.stem),
"enabled": values.get("Hidden", "false").lower() != "true",
}
)
return sorted(entries, key=lambda entry: (str(entry["name"]).casefold(), str(entry["id"])))
def hypr_autostart_path() -> Path:
override = os.environ.get("PANAMA_HYPR_AUTOSTART")
if override:
return Path(override)
return Path(__file__).resolve().parents[2] / "hypr" / "autostart.lua"
def lua_autostart_entries() -> list[dict[str, object]]:
path = hypr_autostart_path()
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return []
commands: list[str] = []
in_start_handler = False
for line in lines:
if not in_start_handler:
in_start_handler = bool(re.search(r'hl\.on\(\s*"hyprland\.start"', line))
continue
if line.strip() == "end)":
break
match = EXEC_CMD.search(line)
if match:
try:
commands.append(ast.literal_eval(match.group(1)))
except (SyntaxError, ValueError):
continue
return [
{
"id": f"hyprland:{index}",
"name": command.split()[0].rsplit("/", 1)[-1],
"command": command,
"enabled": True,
"readOnly": True,
"source": "config/dot/hypr/autostart.lua",
}
for index, command in enumerate(commands, start=1)
]
def snapshot() -> dict[str, object]:
return {
"handlers": query_handlers(),
"autostartEntries": user_autostart_entries(),
"luaAutostartEntries": lua_autostart_entries(),
}
def set_default(role: str, desktop_id: str) -> None:
target = ROLE_TARGETS.get(role)
if target is None:
raise BoundaryError("That default application role is not supported.")
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
kind, setting = target
command = (
["xdg-settings", "set", setting, desktop_id]
if kind == "settings"
else ["xdg-mime", "default", desktop_id, setting]
)
run(command)
def update_hidden(path: Path, *, hidden: bool) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
lines = original.splitlines()
output: list[str] = []
section = ""
found_section = False
wrote_hidden = False
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
if section == "Desktop Entry" and not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
section = stripped[1:-1]
found_section = found_section or section == "Desktop Entry"
output.append(line)
continue
if section == "Desktop Entry" and line.split("=", 1)[0].strip() == "Hidden":
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
continue
output.append(line)
if not found_section:
raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
mode = path.stat().st_mode
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary:
temporary.write("\n".join(output) + "\n")
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
temporary_path.chmod(mode)
os.replace(temporary_path, path)
except OSError as error:
if "temporary_path" in locals():
temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error
def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or false.")
if not DESKTOP_ID.fullmatch(desktop_id):
raise BoundaryError("That autostart entry is not available.")
directory = autostart_directory()
path = directory / desktop_id
try:
resolved_directory = directory.resolve(strict=True)
resolved_path = path.resolve(strict=True)
except OSError as error:
raise BoundaryError("That autostart entry is not available.") from error
if path.is_symlink() or resolved_path.parent != resolved_directory or not resolved_path.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(resolved_path, hidden=enabled_text == "false")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
elif len(arguments) == 3 and arguments[0] == "set-default":
set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false"
)
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Lists installed font families, split by what they are usable for.
#
# Two separate questions get asked of fonts here, and they have different
# answers:
#
# interface proportional families suitable for reading UI text
# monospace fixed-pitch families
# icons families carrying Nerd Font glyphs
#
# The icon distinction matters more than it looks. Theme.fontMono is used ONLY
# to draw glyphs -- the workspace pills, the status cluster, the search icon --
# and picking a monospace family without those glyphs replaces every icon in the
# shell with tofu. So they are reported separately rather than lumped in with
# monospace, and the picker can say so.
#
# fc-list reports one line per style, so families are deduplicated here. The
# first comma-separated alias is the canonical name Qt will match.
set -euo pipefail
emit() {
# $1: fontconfig pattern, $2: json key
fc-list "$1" family 2>/dev/null \
| sed 's/,.*//' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
| grep -v '^$' \
| sort -u
}
json_array() {
local first=true
printf '['
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "$first" == true ]] || printf ','
first=false
printf '%s' "$(printf '%s' "$line" | jq -Rs 'rtrimstr("\n")')"
done
printf ']'
}
# spacing=100 is fontconfig's mono flag. Proportional is everything else.
mono="$(emit ':spacing=100')"
all="$(emit ':')"
interface="$(comm -23 <(printf '%s\n' "$all") <(printf '%s\n' "$mono"))"
icons="$(printf '%s\n' "$all" | grep -i 'nerd font' || true)"
printf '{"interface":'
printf '%s\n' "$interface" | json_array
printf ',"monospace":'
printf '%s\n' "$mono" | json_array
printf ',"icons":'
printf '%s\n' "$icons" | json_array
printf '}\n'
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Enumerates GPUs that can report utilisation, with a readable name for each.
#
# Panama's vitals readout needs one specific sysfs file, and the card numbering
# is neither stable across machines nor meaningful to a person: this box has
# card1 and card2, both amdgpu, one discrete and one integrated. Picking a
# number blindly shows whichever the kernel happened to enumerate first.
#
# Names come from lspci where available, because the sysfs device directory
# exposes only numeric vendor/device ids.
set -euo pipefail
first=true
printf '['
for busy in /sys/class/drm/card*/device/gpu_busy_percent; do
[[ -r "$busy" ]] || continue
device_dir="$(dirname "$busy")"
card="$(basename "$(dirname "$device_dir")")"
# The device directory is a symlink into the PCI tree; its target's basename
# is the PCI address lspci wants.
pci="$(basename "$(readlink -f "$device_dir")" 2>/dev/null || true)"
name=""
if [[ -n "$pci" ]] && command -v lspci >/dev/null 2>&1; then
# Strip the leading domain: lspci -s wants 00:02.0, sysfs gives 0000:00:02.0
short="${pci#*:}"
name="$(lspci -s "$short" 2>/dev/null | sed -E 's/^[^ ]+ [^:]+: //' | head -1)"
fi
if [[ -z "$name" ]]; then
driver="$(sed -n 's/^DRIVER=//p' "$device_dir/uevent" 2>/dev/null | head -1)"
name="${driver:-Graphics} ($card)"
fi
reading="$(cat "$busy" 2>/dev/null || printf '')"
[[ "$reading" =~ ^[0-9]+$ ]] || reading=-1
[[ "$first" == true ]] || printf ','
first=false
printf '{"card":"%s","path":"%s","name":%s,"busy":%s}' \
"$card" "$busy" "$(printf '%s' "$name" | jq -Rs .)" "$reading"
done
printf ']\n'
@@ -192,8 +192,17 @@ def resolve_config(
env: Mapping[str, str] | None = None,
legacy: Callable[[], Config] = load_legacy_config,
) -> Config:
# An explicitly supplied environment is the WHOLE environment. Reading the
# user's private env file underneath it makes callers -- tests especially --
# depend on whatever happens to be in that file: adding a real
# PANAMA_HOME_ASSISTANT_ENTITIES to it silently overrode a fixture that was
# asserting the legacy fallback. Production passes env=None and still gets
# the file.
if env is None:
private_env = read_panama_env()
private_env.update(dict(os.environ if env is None else env))
private_env.update(dict(os.environ))
else:
private_env = dict(env)
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Read and atomically update Panama's private Home Assistant settings.
Secret values are accepted only as a single JSON object on stdin and are never
returned. The command line therefore remains safe to inspect with ps(1).
"""
from __future__ import annotations
import fcntl
import json
import os
import pathlib
import re
import shlex
import sys
import tempfile
import urllib.parse
from collections.abc import Mapping, Sequence
from typing import Any
KEY_URL = "PANAMA_HOME_ASSISTANT_URL"
KEY_TOKEN = "PANAMA_HOME_ASSISTANT_TOKEN"
KEY_ENTITIES = "PANAMA_HOME_ASSISTANT_ENTITIES"
TARGET_KEYS = (KEY_URL, KEY_TOKEN, KEY_ENTITIES)
ASSIGNMENT = re.compile(
r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=(?P<value>.*)$"
)
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
DEFAULT_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
class ConfigError(RuntimeError):
"""An error code safe to display without including submitted values."""
def env_path() -> pathlib.Path:
override = os.environ.get("PANAMA_HOME_ASSISTANT_ENV_FILE", "")
return pathlib.Path(override) if override else DEFAULT_ENV
def parse_assignment(raw: str) -> str | None:
try:
parsed = shlex.split(raw, comments=True, posix=True)
except ValueError:
return None
return parsed[0] if len(parsed) == 1 else None
def read_values(path: pathlib.Path) -> dict[str, str]:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return {}
except OSError as error:
raise ConfigError("read-failed") from error
values: dict[str, str] = {}
for line in lines:
match = ASSIGNMENT.match(line)
if not match or match.group("key") not in TARGET_KEYS:
continue
value = parse_assignment(match.group("value"))
if value is not None:
values[match.group("key")] = value
return values
def normalize_url(value: Any) -> str:
if not isinstance(value, str):
raise ConfigError("invalid-url")
normalized = value.strip().rstrip("/")
if not normalized:
return ""
parsed = urllib.parse.urlsplit(normalized)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ConfigError("invalid-url")
return normalized
def normalize_token(value: Any) -> str:
if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
raise ConfigError("invalid-token")
return value.strip()
def normalize_entities(value: Any) -> tuple[str, ...]:
if isinstance(value, str):
candidates: Sequence[Any] = re.split(r"[,\n]", value)
elif isinstance(value, list):
candidates = value
else:
raise ConfigError("invalid-entities")
entities: list[str] = []
for candidate in candidates:
if not isinstance(candidate, str):
raise ConfigError("invalid-entities")
entity_id = candidate.strip()
if not entity_id:
continue
if not ENTITY_ID.fullmatch(entity_id):
raise ConfigError("invalid-entities")
if entity_id not in entities:
entities.append(entity_id)
return tuple(entities)
def public_state(values: Mapping[str, str]) -> dict[str, object]:
try:
url = normalize_url(values.get(KEY_URL, ""))
entities = list(normalize_entities(values.get(KEY_ENTITIES, "")))
error = ""
except ConfigError as config_error:
url = ""
entities = []
error = str(config_error)
token_configured = bool(values.get(KEY_TOKEN, "").strip())
return {
"ok": error == "",
"configured": bool(url and token_configured),
"tokenConfigured": token_configured,
"url": url,
"entities": entities,
"error": error,
}
def render_updated(original: str, updates: Mapping[str, str]) -> str:
lines = original.splitlines(keepends=True)
rendered: list[str] = []
replaced: set[str] = set()
for line in lines:
content = line.rstrip("\r\n")
ending = line[len(content) :]
match = ASSIGNMENT.match(content)
key = match.group("key") if match else ""
if key not in updates:
rendered.append(line)
continue
if key in replaced:
continue
rendered.append(f"export {key}={shlex.quote(updates[key])}{ending or os.linesep}")
replaced.add(key)
if rendered and not rendered[-1].endswith(("\n", "\r")):
rendered[-1] += os.linesep
for key in TARGET_KEYS:
if key in updates and key not in replaced:
rendered.append(f"export {key}={shlex.quote(updates[key])}{os.linesep}")
return "".join(rendered)
def atomic_write(path: pathlib.Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=path.parent)
temporary = pathlib.Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, 0o600)
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
def read_payload() -> dict[str, Any]:
line = sys.stdin.buffer.readline(1_048_577)
if not line or len(line) > 1_048_576:
raise ConfigError("invalid-payload")
try:
payload = json.loads(line)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ConfigError("invalid-payload") from error
if not isinstance(payload, dict):
raise ConfigError("invalid-payload")
return payload
def write_payload(path: pathlib.Path, payload: Mapping[str, Any]) -> dict[str, object]:
allowed = {"url", "token", "entities"}
if not set(payload).issubset(allowed) or not payload:
raise ConfigError("invalid-payload")
updates: dict[str, str] = {}
if "url" in payload:
updates[KEY_URL] = normalize_url(payload["url"])
if "token" in payload:
updates[KEY_TOKEN] = normalize_token(payload["token"])
if "entities" in payload:
updates[KEY_ENTITIES] = ",".join(normalize_entities(payload["entities"]))
lock_path = path.with_name("." + path.name + ".lock")
path.parent.mkdir(parents=True, exist_ok=True)
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
try:
os.fchmod(lock_fd, 0o600)
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
original = path.read_text(encoding="utf-8")
except FileNotFoundError:
original = ""
except OSError as error:
raise ConfigError("read-failed") from error
atomic_write(path, render_updated(original, updates))
finally:
os.close(lock_fd)
result = public_state(read_values(path))
result["ok"] = True
result["error"] = ""
return result
def compact_json(value: Mapping[str, object]) -> str:
return json.dumps(value, separators=(",", ":"))
def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else "status"
if len(sys.argv) > 2 or command not in {"status", "write"}:
raise ConfigError("usage")
path = env_path()
result = public_state(read_values(path)) if command == "status" else write_payload(path, read_payload())
print(compact_json(result))
if __name__ == "__main__":
try:
main()
except ConfigError as error:
print(compact_json({"ok": False, "error": str(error)}))
raise SystemExit(1) from error
except OSError:
print(compact_json({"ok": False, "error": "write-failed"}))
raise SystemExit(1)
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Generates hypridle's configuration from Panama's shared settings.
#
# Why this exists rather than editing hypridle.conf directly: ~/.config/hypr is
# a symlink into the Panama repository, so writing hypridle.conf at runtime
# would dirty a tracked file with machine state. The generated config therefore
# lives under XDG_STATE_HOME, and a systemd drop-in points hypridle at it with
# `-c`. The repository's hypridle.conf remains the shipped default and is what
# runs if this has never been set up.
#
# panama-idle apply regenerate and restart hypridle
# panama-idle status report as JSON what is in effect
# panama-idle install write the systemd drop-in (idempotent)
# panama-idle remove remove the drop-in and fall back to the shipped config
#
# All values are read from the settings store and clamped here as well as in the
# schema, because this script is also reachable from a shell.
set -euo pipefail
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
generated="$state_dir/hypridle.conf"
dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d"
dropin="$dropin_dir/panama.conf"
read_setting() {
local key="$1" fallback="$2"
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
jq -r --arg k "$key" --arg d "$fallback" \
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
"$settings" 2>/dev/null || printf '%s' "$fallback"
}
clamp_int() {
local value="$1" low="$2" high="$3" fallback="$4"
[[ "$value" =~ ^-?[0-9]+$ ]] || { printf '%s' "$fallback"; return; }
(( value < low )) && value="$low"
(( value > high )) && value="$high"
printf '%s' "$value"
}
load() {
blank_min="$(clamp_int "$(read_setting screenBlankMinutes 5)" 0 120 5)"
lock_min="$(clamp_int "$(read_setting lockMinutes 10)" 0 240 10)"
suspend_min="$(clamp_int "$(read_setting suspendMinutes 0)" 0 480 0)"
lock_on_sleep="$(read_setting lockOnSleep true)"
[[ "$lock_on_sleep" == "true" || "$lock_on_sleep" == "false" ]] || lock_on_sleep=true
}
generate() {
load
mkdir -p "$state_dir"
{
printf '# Generated by panama-idle from %s\n' "$settings"
printf '# Do not edit: it is rewritten whenever the idle settings change.\n'
printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n'
printf 'general {\n'
printf ' lock_cmd = pidof hyprlock || hyprlock\n'
if [[ "$lock_on_sleep" == "true" ]]; then
printf ' before_sleep_cmd = loginctl lock-session\n'
fi
printf " after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'\n"
printf ' inhibit_sleep = 2\n'
printf '}\n'
if (( blank_min > 0 )); then
printf '\n# %s minutes -> screen off.\n' "$blank_min"
printf 'listener {\n'
printf ' timeout = %s\n' "$(( blank_min * 60 ))"
printf " on-timeout = hyprctl dispatch 'hl.dsp.dpms({ action = \"off\" })'\n"
printf " on-resume = hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'\n"
printf '}\n'
fi
if (( lock_min > 0 )); then
printf '\n# %s minutes -> lock.\n' "$lock_min"
printf 'listener {\n'
printf ' timeout = %s\n' "$(( lock_min * 60 ))"
printf ' on-timeout = loginctl lock-session\n'
printf '}\n'
fi
if (( suspend_min > 0 )); then
printf '\n# %s minutes -> suspend.\n' "$suspend_min"
printf 'listener {\n'
printf ' timeout = %s\n' "$(( suspend_min * 60 ))"
printf ' on-timeout = systemctl suspend\n'
printf '}\n'
fi
} >"$generated.tmp"
mv "$generated.tmp" "$generated"
}
install_dropin() {
mkdir -p "$dropin_dir"
cat >"$dropin" <<EOF
# Installed by panama-idle. Points hypridle at the configuration Panama
# generates from its settings store, so idle timings are adjustable from
# Panama Settings rather than by editing a file in the Panama repository.
[Service]
ExecStart=
ExecStart=/usr/bin/hypridle -c $generated
EOF
systemctl --user daemon-reload
}
case "${1:-apply}" in
apply)
generate
if [[ -f "$dropin" ]]; then
systemctl --user restart hypridle.service
fi
;;
install)
generate
install_dropin
systemctl --user restart hypridle.service
;;
remove)
rm -f "$dropin"
systemctl --user daemon-reload
systemctl --user restart hypridle.service
;;
status)
load
managed=false
[[ -f "$dropin" ]] && managed=true
printf '{"managed":%s,"active":"%s","blankMinutes":%s,"lockMinutes":%s,"suspendMinutes":%s,"lockOnSleep":%s,"generated":"%s"}\n' \
"$managed" \
"$(systemctl --user is-active hypridle.service 2>/dev/null || printf unknown)" \
"$blank_min" "$lock_min" "$suspend_min" "$lock_on_sleep" "$generated"
;;
*)
printf 'usage: panama-idle [apply|install|remove|status]\n' >&2
exit 2
;;
esac
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
set -u
strict_delivery() {
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
}
show_progress() {
if ! qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1; then
strict_delivery && return 1
fi
return 0
}
show_message() {
if ! qs ipc call osd message "$1" "$2" >/dev/null 2>&1; then
strict_delivery && return 1
fi
return 0
}
volume_state() {
local target="$1" output level percent muted=false
output="$(wpctl get-volume "$target" 2>/dev/null)" || return 1
if [[ $output =~ Volume:[[:space:]]*([0-9]+([.][0-9]+)?) ]]; then
level="${BASH_REMATCH[1]}"
else
return 1
fi
[[ $output == *"[MUTED]"* ]] && muted=true
percent="$(awk -v value="$level" 'BEGIN { printf "%d", value * 100 + 0.5 }')"
printf '%s %s\n' "$percent" "$muted"
}
show_volume() {
local target="$1" kind="$2" state percent muted label
state="$(volume_state "$target")" || return 0
read -r percent muted <<<"$state"
if [[ $muted == true ]]; then
show_progress "${kind}-muted" "$percent" "Muted"
else
label="${percent}%"
show_progress "$kind" "$percent" "$label"
fi
}
adjust_volume() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
case "$action" in
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
down) wpctl set-volume "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
esac
show_volume "$target" volume
}
adjust_microphone() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
case "$action" in
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
down) wpctl set-volume "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
esac
show_volume "$target" microphone
}
adjust_brightness() {
local action="${1:-}" step="${2:-5}" output percent
case "$action" in
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
esac
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 0
show_progress brightness "$percent" "${percent}%"
}
media_action() {
local action="${1:-}" kind label fallback
case "$action" in
play-pause)
playerctl play-pause || return
if [[ $(playerctl status 2>/dev/null) == "Playing" ]]; then
kind="media-play"
fallback="Playing"
else
kind="media-pause"
fallback="Paused"
fi
;;
next) playerctl next || return; kind="media-next"; fallback="Next track" ;;
previous) playerctl previous || return; kind="media-previous"; fallback="Previous track" ;;
stop) playerctl stop || return; kind="media-stop"; fallback="Stopped" ;;
*) printf 'Usage: panama-osd media play-pause|next|previous|stop\n' >&2; return 2 ;;
esac
label="$(playerctl metadata --format '{{ title }} — {{ artist }}' 2>/dev/null)"
[[ -n $label ]] || label="$fallback"
show_message "$kind" "$label"
}
case "${1:-}" in
volume) shift; adjust_volume "$@" ;;
microphone) shift; adjust_microphone "$@" ;;
brightness) shift; adjust_brightness "$@" ;;
media) shift; media_action "$@" ;;
message)
shift
kind="${1:-}"
[[ -n $kind && $# -ge 2 ]] || {
printf 'Usage: panama-osd message ICON LABEL\n' >&2
exit 2
}
shift
show_message "$kind" "$*"
;;
*)
printf 'Usage: panama-osd volume|microphone|brightness|media|message ACTION [value]\n' >&2
exit 2
;;
esac
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
gallery="${XDG_CONFIG_HOME:-$HOME/.config}/quickshell/prism-gallery.qml"
if qs -p "$gallery" ipc show >/dev/null 2>&1; then
qs -p "$gallery" kill
else
qs -p "$gallery" --daemonize
fi
+647
View File
@@ -0,0 +1,647 @@
#!/usr/bin/env python3
"""Crash-safe snapshots of Panama's desktop and Home preference stores.
A restore is a two-file transaction. Its fixed journal and artifacts live at
`$XDG_STATE_HOME/panama/transactions/settings-restore`; they contain no
caller-provided paths. The journal is fsynced before either destination changes
and is removed only after both replacements are durable. Every invocation
recovers an incomplete transaction before doing any other work.
"""
from __future__ import annotations
import json
import os
import re
import stat
import sys
import tempfile
import time
import fcntl
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, NoReturn
HOME = Path(os.environ.get("HOME", str(Path.home())))
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
SETTINGS = CONFIG_ROOT / "panama/settings.json"
HOME_STATE = STATE_ROOT / "panama/panama-home.json"
BACKUP_DIR = STATE_ROOT / "panama/backups"
TRANSACTION_PARENT = STATE_ROOT / "panama/transactions"
TRANSACTION_DIR = TRANSACTION_PARENT / "settings-restore"
JOURNAL = TRANSACTION_DIR / "journal.json"
LOCK_FILE = TRANSACTION_PARENT / "settings-backup.lock"
KEEP = 15
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
class BackupError(RuntimeError):
pass
def fail(message: str) -> NoReturn:
raise BackupError(message)
def fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def ensure_directory(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
if path.is_symlink() or not path.is_dir():
fail(f"{path} is not a safe directory.")
@contextmanager
def process_lock() -> Iterator[None]:
ensure_directory(TRANSACTION_PARENT)
if LOCK_FILE.is_symlink():
fail("The settings transaction lock is a symbolic link.")
descriptor = os.open(
LOCK_FILE,
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
0o600,
)
try:
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
def is_present(path: Path) -> bool:
return path.exists() or path.is_symlink()
def require_regular(path: Path, label: str) -> None:
if path.is_symlink():
fail(f"{label} is a symbolic link and cannot be used safely.")
try:
mode = path.stat().st_mode
except FileNotFoundError:
fail(f"{label} is missing.")
if not stat.S_ISREG(mode):
fail(f"{label} is not a regular file.")
def read_json(path: Path, label: str) -> dict[str, Any]:
require_regular(path, label)
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise BackupError(f"{label} is not valid JSON.") from error
if not isinstance(value, dict):
fail(f"{label} is not a JSON object.")
return value
def valid_home(value: Any) -> bool:
if not isinstance(value, dict):
return False
initialized = value.get("initialized")
favorites = value.get("favorites")
if not isinstance(initialized, bool) or not isinstance(favorites, list):
return False
if not initialized and favorites:
return False
seen: set[str] = set()
for favorite in favorites:
if not isinstance(favorite, dict):
return False
entity_id = favorite.get("id")
alias = favorite.get("alias")
if (
not isinstance(entity_id, str)
or ENTITY_RE.fullmatch(entity_id) is None
or not isinstance(alias, str)
or entity_id in seen
):
return False
seen.add(entity_id)
return True
def validate_home(value: Any, label: str) -> dict[str, Any]:
if not valid_home(value):
fail(f"{label} does not contain valid Home favourites.")
return value
def json_bytes(value: Any) -> bytes:
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
def atomic_write_bytes(path: Path, content: bytes) -> None:
ensure_directory(path.parent)
if path.is_symlink():
fail(f"{path} is a symbolic link and cannot be replaced safely.")
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
fsync_directory(path.parent)
finally:
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
def atomic_write_json(path: Path, value: Any) -> None:
atomic_write_bytes(path, json_bytes(value))
def durable_remove(path: Path) -> None:
if path.exists() or path.is_symlink():
path.unlink()
fsync_directory(path.parent)
def transaction_path(name: str) -> Path:
if name not in {
"journal.json",
"desktop.old",
"desktop.new",
"home.old",
"home.new",
}:
fail("The restore transaction contains an unknown artifact name.")
path = TRANSACTION_DIR / name
resolved_parent = path.parent.resolve(strict=False)
if resolved_parent != TRANSACTION_DIR.resolve(strict=False):
fail("The restore transaction escaped its contained state directory.")
return path
def clean_transaction_artifacts() -> None:
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
return
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
fail("The restore transaction path is not a safe directory.")
for child in list(TRANSACTION_DIR.iterdir()):
if child.name not in {
"journal.json",
"desktop.old",
"desktop.new",
"home.old",
"home.new",
} and not child.name.startswith(".journal.json."):
fail("The restore transaction directory contains an unknown artifact.")
if child.is_dir() and not child.is_symlink():
fail("The restore transaction contains an unexpected directory.")
child.unlink()
fsync_directory(TRANSACTION_DIR)
TRANSACTION_DIR.rmdir()
fsync_directory(TRANSACTION_PARENT)
def clean_stale_atomic_files() -> None:
locations = (
(SETTINGS.parent, (".settings.json.",)),
(HOME_STATE.parent, (".panama-home.json.",)),
(BACKUP_DIR, (".settings-",)),
)
for directory, prefixes in locations:
if not directory.exists():
continue
if directory.is_symlink() or not directory.is_dir():
fail(f"{directory} is not a safe directory.")
changed = False
for child in directory.iterdir():
if not any(child.name.startswith(prefix) for prefix in prefixes):
continue
# Only Panama's hidden atomic-write names are eligible. A matching
# directory is unexpected and is never recursively removed.
if child.is_dir() and not child.is_symlink():
fail("A stale settings temporary path is an unexpected directory.")
child.unlink()
changed = True
if changed:
fsync_directory(directory)
def validate_journal_side(value: Any) -> dict[str, bool]:
if not isinstance(value, dict):
fail("The restore journal is malformed.")
if set(value) != {"touch", "oldPresent", "newPresent"}:
fail("The restore journal is malformed.")
if not all(isinstance(value[key], bool) for key in value):
fail("The restore journal is malformed.")
return value
def read_journal() -> dict[str, Any]:
value = read_json(JOURNAL, "The restore journal")
if set(value) != {"version", "desktop", "home"} or value.get("version") != 1:
fail("The restore journal uses an unsupported format.")
return {
"version": 1,
"desktop": validate_journal_side(value.get("desktop")),
"home": validate_journal_side(value.get("home")),
}
def target_for(store: str) -> Path:
if store == "desktop":
return SETTINGS
if store == "home":
return HOME_STATE
fail("The restore journal names an unknown store.")
def apply_artifact(store: str, generation: str, present: bool) -> None:
target = target_for(store)
if present:
artifact = transaction_path(f"{store}.{generation}")
require_regular(artifact, "A restore transaction artifact")
atomic_write_bytes(target, artifact.read_bytes())
else:
ensure_directory(target.parent)
if target.is_symlink():
fail(f"{target} is a symbolic link and cannot be replaced safely.")
durable_remove(target)
def recover_transaction() -> None:
ensure_directory(TRANSACTION_PARENT)
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
return
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
fail("The restore transaction path is not a safe directory.")
if not JOURNAL.exists() and not JOURNAL.is_symlink():
clean_transaction_artifacts()
return
journal = read_journal()
for store in ("desktop", "home"):
side = journal[store]
if side["touch"]:
apply_artifact(store, "old", side["oldPresent"])
# Journal absence is the durable commit marker for recovery too. If a
# second power loss occurs above, the journal remains and recovery retries.
durable_remove(JOURNAL)
clean_transaction_artifacts()
def is_v2_side(value: Any) -> bool:
return (
isinstance(value, dict)
and isinstance(value.get("present"), bool)
and (not value["present"] or isinstance(value.get("data"), dict))
)
def is_v2_envelope(value: Any) -> bool:
return (
isinstance(value, dict)
and value.get("version") == 2
and is_v2_side(value.get("desktop"))
and is_v2_side(value.get("home"))
)
def validate_snapshot(value: dict[str, Any]) -> tuple[str, dict[str, Any]]:
if not is_v2_envelope(value):
return "legacy", value
if value["home"]["present"]:
validate_home(value["home"]["data"], "That snapshot")
return "versioned", value
def current_store(path: Path, label: str, *, home_store: bool = False) -> tuple[bool, Any]:
if not is_present(path):
return False, None
value = read_json(path, label)
if home_store:
validate_home(value, label)
return True, value
def next_snapshot_path() -> Path:
ensure_directory(BACKUP_DIR)
while True:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S%f")[:18]
candidate = BACKUP_DIR / f"settings-{stamp}.json"
if not is_present(candidate):
return candidate
time.sleep(0.002)
def prune_snapshots() -> None:
snapshots = sorted(
(
path
for path in BACKUP_DIR.iterdir()
if SNAPSHOT_RE.fullmatch(path.name)
and path.is_file()
and not path.is_symlink()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
for old in snapshots[KEEP:]:
durable_remove(old)
def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
try:
desktop_present, desktop = current_store(
SETTINGS, "The current settings file"
)
home_present, home = current_store(
HOME_STATE, "The current Home state file", home_store=True
)
except BackupError:
if validate:
raise
return None
if not desktop_present and not home_present:
if require_any:
fail("No Panama settings exist to back up.")
return None
envelope: dict[str, Any] = {
"version": 2,
"desktop": {"present": desktop_present},
"home": {"present": home_present},
}
if desktop_present:
envelope["desktop"]["data"] = desktop
if home_present:
envelope["home"]["data"] = home
destination = next_snapshot_path()
atomic_write_json(destination, envelope)
prune_snapshots()
return destination
def snapshot_source(name: str) -> Path:
if SNAPSHOT_RE.fullmatch(name) is None:
fail("Not a snapshot name.")
ensure_directory(BACKUP_DIR)
candidate = BACKUP_DIR / name
require_regular(candidate, "That snapshot")
if candidate.resolve(strict=True).parent != BACKUP_DIR.resolve(strict=True):
fail("That snapshot is outside the backup directory.")
return candidate
def stage_artifact(name: str, content: bytes) -> None:
path = transaction_path(name)
if path.exists() or path.is_symlink():
fail("A stale restore transaction artifact was not recovered.")
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
finally:
# fdopen owns the descriptor after construction.
pass
fsync_directory(TRANSACTION_DIR)
def capture_old(store: str, target: Path) -> bool:
if not is_present(target):
return False
require_regular(target, f"The current {store} settings file")
stage_artifact(f"{store}.old", target.read_bytes())
return True
def prepare_transaction(
desktop_present: bool,
desktop_data: dict[str, Any] | None,
home_touch: bool,
home_present: bool,
home_data: dict[str, Any] | None,
) -> dict[str, Any]:
# Cleanup is active before the first artifact is created. A pre-journal
# error removes every staged/rollback file; a process death is recovered as
# stale preparation by the next invocation.
ensure_directory(TRANSACTION_PARENT)
clean_transaction_artifacts()
ensure_directory(TRANSACTION_DIR)
fsync_directory(TRANSACTION_PARENT)
try:
desktop_old = capture_old("desktop", SETTINGS)
if desktop_present:
stage_artifact("desktop.new", json_bytes(desktop_data))
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_FAIL") == "after-desktop-stage":
fail("Injected failure after desktop staging.")
home_old = capture_old("home", HOME_STATE) if home_touch else False
if home_touch and home_present:
stage_artifact("home.new", json_bytes(home_data))
journal = {
"version": 1,
"desktop": {
"touch": True,
"oldPresent": desktop_old,
"newPresent": desktop_present,
},
"home": {
"touch": home_touch,
"oldPresent": home_old,
"newPresent": home_present,
},
}
atomic_write_json(JOURNAL, journal)
return journal
except BaseException:
# SIGKILL/os._exit bypass this block by design; the next invocation
# cleans a pre-journal directory or recovers a journalled transaction.
if not JOURNAL.exists() and not JOURNAL.is_symlink():
clean_transaction_artifacts()
raise
def commit_restore(journal: dict[str, Any]) -> None:
try:
desktop = journal["desktop"]
apply_artifact("desktop", "new", desktop["newPresent"])
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_CRASH") == "after-desktop":
os._exit(86)
home = journal["home"]
if home["touch"]:
apply_artifact("home", "new", home["newPresent"])
# Both targets and their parent directories are durable. Removing and
# fsyncing the journal is the transaction's commit record.
durable_remove(JOURNAL)
clean_transaction_artifacts()
except BaseException:
# Ordinary failures roll back immediately. Process death leaves the
# journal in place and takes this same path on the next invocation.
recover_transaction()
raise
def write_live_home(text: str) -> None:
try:
value = json.loads(text)
except json.JSONDecodeError as error:
raise BackupError("The live Home state is not valid JSON.") from error
validate_home(value, "The live Home state")
atomic_write_json(HOME_STATE, value)
def command_save(arguments: list[str]) -> None:
if arguments:
write_live_home(arguments[0])
destination = save_snapshot(require_any=True, validate=True)
assert destination is not None
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
def snapshot_files() -> list[Path]:
ensure_directory(BACKUP_DIR)
return sorted(
(
path
for path in BACKUP_DIR.iterdir()
if SNAPSHOT_RE.fullmatch(path.name)
and path.is_file()
and not path.is_symlink()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
def command_list() -> None:
output: list[dict[str, Any]] = []
for path in snapshot_files():
try:
value = read_json(path, "A snapshot")
if is_v2_envelope(value):
desktop = value["desktop"]
keys = len(desktop["data"]) if desktop["present"] else 0
else:
keys = len(value)
except BackupError:
keys = 0
raw = path.name.removeprefix("settings-").removesuffix(".json")
pretty = (
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
)
output.append({"name": path.name, "when": pretty, "keys": keys})
print(json.dumps(output, separators=(",", ":")))
def command_restore(arguments: list[str]) -> None:
if not arguments:
fail("Which snapshot?")
name = arguments[0]
source = snapshot_source(name)
snapshot = read_json(source, "That snapshot")
snapshot_format, value = validate_snapshot(snapshot)
if snapshot_format == "versioned":
desktop_present = value["desktop"]["present"]
desktop_data = value["desktop"].get("data")
home_touch = True
home_present = value["home"]["present"]
home_data = value["home"].get("data")
else:
desktop_present = True
desktop_data = value
home_touch = False
home_present = False
home_data = None
# Display geometry is never restored from a snapshot. Applying it requires
# the visible confirmation/recovery flow in Displays.qml; a settings-file
# restore followed by `hyprctl reload` must not bypass that safety boundary.
# Preserve the currently confirmed generation when it is readable, and
# otherwise remove the snapshot's geometry so startup uses shipped policy.
try:
current_desktop = read_json(SETTINGS, "The current settings file") \
if is_present(SETTINGS) else {}
except BackupError:
current_desktop = {}
if isinstance(current_desktop, dict) and "displays" in current_desktop:
# Even a Home-only snapshot must retain the confirmed monitor layout.
# In that case the restored desktop file contains only the protected
# geometry; every ordinary desktop preference remains absent/default.
desktop_present = True
desktop_data = dict(desktop_data) if desktop_data is not None else {}
desktop_data["displays"] = current_desktop["displays"]
elif desktop_present and desktop_data is not None:
desktop_data = dict(desktop_data)
desktop_data.pop("displays", None)
# Restoring remains undoable, but a corrupt current file must not prevent a
# known-good snapshot from recovering the desktop.
save_snapshot(require_any=False, validate=False)
journal = prepare_transaction(
desktop_present,
desktop_data,
home_touch,
home_present,
home_data,
)
commit_restore(journal)
if not home_touch:
home_result: dict[str, Any] = {"preserve": True}
elif home_present:
home_result = {"present": True, "data": home_data}
else:
home_result = {"present": False}
print(
json.dumps(
{"restored": name, "home": home_result},
separators=(",", ":"),
)
)
def main() -> None:
with process_lock():
clean_stale_atomic_files()
recover_transaction()
command = sys.argv[1] if len(sys.argv) > 1 else "list"
arguments = sys.argv[2:]
if command == "save":
command_save(arguments)
elif command == "list":
command_list()
elif command == "restore":
command_restore(arguments)
else:
fail("usage: panama-settings-backup [save|list|restore <name>]")
if __name__ == "__main__":
try:
main()
except BackupError as error:
print(str(error), file=sys.stderr)
raise SystemExit(1) from error
except OSError as error:
print("The settings backup could not access its state files.", file=sys.stderr)
raise SystemExit(1) from error
@@ -0,0 +1,111 @@
pragma Singleton
// Pointer size and text scale.
//
// These are the two settings that must agree across three consumers that do not
// share a configuration system: the compositor draws the cursor, GTK
// applications read gsettings, and the shell renders its own text. Panama's
// store is the source of truth, and this pushes the value out to the other two
// so they cannot disagree.
//
// pointer size -> gsettings (GTK) + `hyprctl setcursor` (compositor)
// text scale -> gsettings (GTK)
//
// The shell's own font size is not scaled here. Theme.qml's sizes are part of
// the design rather than a user preference, and scaling them at runtime would
// reflow every panel against layouts that were tuned at the design size. Text
// scale therefore affects applications, which is where it matters, and the
// page says so rather than implying it does more.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
property string cursorTheme: ""
property string lastError: ""
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
readonly property real textScale: DesktopPreferences.get("textScale")
Process {
id: themeQuery
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
stdout: StdioCollector {
onStreamFinished: {
// gsettings quotes strings: 'oreo_blue_cursors'
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
}
}
}
// A short queue, because applying one setting takes several commands and
// Process runs one at a time.
property var pending: []
Process {
id: runner
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "That accessibility setting could not be applied.";
root.drain();
}
}
function drain(): void {
if (runner.running || root.pending.length === 0)
return;
const next = root.pending[0];
root.pending = root.pending.slice(1);
runner.exec(next);
}
function enqueue(commands: var): void {
root.pending = root.pending.concat(commands);
root.drain();
}
Component.onCompleted: {
themeQuery.running = true;
settle.restart();
}
// Push the stored values outward once at startup, so a value changed in a
// previous session is in effect in this one even though gsettings and the
// compositor do not read Panama's store.
Timer {
id: settle
interval: 1200
onTriggered: root.applyAll()
}
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { coalesce.restart(); }
}
Timer {
id: coalesce
interval: 250
onTriggered: root.applyAll()
}
function applyAll(): void {
root.lastError = "";
const size = String(root.cursorSize);
const commands = [
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
];
// setcursor needs a theme name; skip it rather than guess if gsettings
// has not answered yet. The next change will catch up.
if (root.cursorTheme !== "")
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
root.enqueue(commands);
}
}
@@ -0,0 +1,43 @@
pragma Singleton
// Shared PipeWire device discovery and default selection. Quick Settings and
// Panama Settings intentionally use this same boundary so they cannot disagree
// about what counts as an input or which node should become the default.
import Quickshell
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
readonly property var outputs: Pipewire.nodes.values.filter(node =>
!node.isStream && node.isSink)
readonly property var inputs: Pipewire.nodes.values.filter(node =>
!node.isStream
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
function nodes(output: bool): var {
return output ? root.outputs : root.inputs;
}
function current(output: bool): var {
return output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource;
}
function select(output: bool, node: var): void {
if (!node)
return;
if (output)
Pipewire.preferredDefaultAudioSink = node;
else
Pipewire.preferredDefaultAudioSource = node;
}
function label(node: var): string {
if (!node)
return "Unknown device";
return node.description || node.nickname || node.name || "Unknown device";
}
}
@@ -0,0 +1,97 @@
pragma Singleton
// Keeps everything that draws a window agreeing about light or dark.
//
// The shell repaints itself from Theme.qml the moment the preference changes,
// 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
// the compositor draws window borders and shadows
//
// A toolbar or a window border still wearing the other scheme is more jarring
// than either scheme on its own, which is why this exists rather than the
// setting simply being a Theme property.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
property string lastError: ""
// Applied one command at a time: Process runs a single command, and several
// of these are separate programs.
property var pending: []
Process {
id: runner
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "The colour scheme could not be applied everywhere.";
root.drain();
}
}
function drain(): void {
if (runner.running || root.pending.length === 0)
return;
const next = root.pending[0];
root.pending = root.pending.slice(1);
runner.exec(next);
}
function enqueue(commands: var): void {
root.pending = root.pending.concat(commands);
root.drain();
}
// Pushed once at startup as well as on change: gsettings and the compositor
// do not read Panama's store, so a scheme chosen in a previous session is
// only in effect for the shell until this runs.
Component.onCompleted: settle.restart()
Timer {
id: settle
interval: 1200
onTriggered: root.apply()
}
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { coalesce.restart(); }
}
Timer {
id: coalesce
interval: 250
onTriggered: root.apply()
}
function apply(): void {
root.lastError = "";
const scheme = root.dark ? "prefer-dark" : "prefer-light";
// Adwaita's light and dark are the same theme; only the preference and
// the -dark suffix differ, so applications that honour either agree.
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita";
const commands = [
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
];
// Unfocused window borders. The focused border is the prism gradient and
// is already scheme-independent; the inactive one is a flat neutral that
// would be invisible against the opposite background.
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
root.enqueue(commands);
}
}
@@ -0,0 +1,144 @@
pragma Singleton
// Network and Bluetooth state for the settings page.
//
// The hard parts -- scanning, joining, pairing -- already work in the quick
// settings panel through Quickshell.Networking and Quickshell.Bluetooth, which
// speak to NetworkManager and BlueZ over DBus. Nothing here shells out to nmcli
// or bluetoothctl, and nothing should: the founding requirement for this
// desktop was never having to drop to a terminal to join a network.
//
// This exists so the page does not have to reach into those modules for the
// same derived values the panel already computes, and so scanning is driven by
// whether the page is actually on screen. Scanning while nobody is looking is
// radio time and battery spent on a list that is not being read.
import Quickshell
import Quickshell.Networking
import Quickshell.Bluetooth
import QtQuick
Singleton {
id: root
// Set by the page while it is visible; drives both scanners.
property bool active: false
readonly property var wifiDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi)
return device;
}
return null;
}
readonly property var wiredDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wired)
return device;
}
return null;
}
readonly property var adapter: Bluetooth.defaultAdapter
readonly property bool wifiEnabled: Networking.wifiEnabled
readonly property bool wifiAvailable: Networking.wifiHardwareEnabled
// Current network, then saved, then by signal -- the order GNOME uses,
// which is the order you actually look for things in.
readonly property var networks: {
if (!root.wifiDevice || !root.wifiDevice.networks)
return [];
const list = root.wifiDevice.networks.values.slice();
list.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
if (a.known !== b.known)
return a.known ? -1 : 1;
return b.signalStrength - a.signalStrength;
});
return list;
}
readonly property var savedNetworks: root.networks.filter(network => network.known)
readonly property var bluetoothDevices: {
if (!Bluetooth.devices)
return [];
const list = Bluetooth.devices.values.slice();
list.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
if (a.paired !== b.paired)
return a.paired ? -1 : 1;
return String(a.name || "").localeCompare(String(b.name || ""));
});
return list;
}
readonly property var activeNetwork: root.networks.find(network => network.connected) ?? null
function isSecured(network: var): bool {
return network.security !== WifiSecurityType.Open
&& network.security !== WifiSecurityType.Owe
&& network.security !== WifiSecurityType.Unknown;
}
function securityLabel(network: var): string {
if (!root.isSecured(network))
return "Open";
switch (network.security) {
case WifiSecurityType.Wep: return "WEP";
case WifiSecurityType.Wpa: return "WPA";
case WifiSecurityType.Wpa2: return "WPA2";
case WifiSecurityType.Wpa3: return "WPA3";
case WifiSecurityType.Enterprise: return "Enterprise";
}
return "Secured";
}
// Four bars is what people read signal as, so bucket rather than showing a
// percentage that changes every scan and means nothing to anyone.
//
// signalStrength is 0.0-1.0, NOT a percentage. Treating it as 0-100 puts
// every network including the connected one in the bottom bucket, which is
// exactly as useless as showing nothing. Thresholds match the icon buckets
// in modules/quicksettings/WifiList.qml so the two never disagree.
function signalLabel(strength: real): string {
if (strength >= 0.8) return "Excellent";
if (strength >= 0.55) return "Good";
if (strength >= 0.3) return "Fair";
if (strength > 0.05) return "Weak";
return "No signal";
}
function connectionFailureText(reason: var): string {
switch (reason) {
case ConnectionFailReason.WifiAuthTimeout:
case ConnectionFailReason.Authentication:
return "Wrong password";
case ConnectionFailReason.WifiNetworkLost:
return "Network out of range";
}
return "Could not connect";
}
// Scanning follows visibility. NetworkManager keeps scanning as long as it
// is asked to, and Bluetooth discovery is worse -- it holds the radio.
function syncScanners(): void {
if (root.wifiDevice)
root.wifiDevice.scannerEnabled = root.active && root.wifiEnabled;
if (root.adapter && root.adapter.enabled) {
const shouldDiscover = root.active;
if (root.adapter.discovering !== shouldDiscover)
root.adapter.discovering = shouldDiscover;
}
}
onActiveChanged: root.syncScanners()
onWifiDeviceChanged: root.syncScanners()
onWifiEnabledChanged: root.syncScanners()
onAdapterChanged: root.syncScanners()
}
+133
View File
@@ -0,0 +1,133 @@
pragma Singleton
// System date, time, and timezone.
//
// Deliberately NOT backed by the Panama settings store. The timezone and the
// network-time setting belong to the machine, not to this desktop: they are
// shared with every other session and with services that never see Panama's
// JSON. Storing a copy would create a second answer to a question the system
// already answers, which is the exact failure this settings rewrite exists to
// remove. So this reads and writes `timedatectl` directly and holds no state of
// its own beyond what it last observed.
//
// Setting the timezone or toggling NTP needs privilege. timedatectl asks
// polkit, which shows the usual authentication dialog; on refusal the command
// fails and the error is surfaced rather than the UI pretending it worked.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property string timezone: ""
property bool ntpEnabled: false
property bool ntpSynchronised: false
property string localTime: ""
property string universalTime: ""
property string rtcTime: ""
property string lastError: ""
property var zones: []
readonly property bool busy: statusQuery.running || zonesQuery.running || writeRun.running
// "America/New_York" -> "New York" for display, keeping the region as a
// separate field so the list can be grouped and searched sensibly.
function regionOf(zone: string): string {
const slash = zone.indexOf("/");
return slash < 0 ? zone : zone.slice(0, slash);
}
function cityOf(zone: string): string {
const slash = zone.indexOf("/");
return (slash < 0 ? zone : zone.slice(slash + 1)).replace(/_/g, " ");
}
Process {
id: statusQuery
command: ["timedatectl", "show",
"-p", "Timezone", "-p", "NTP", "-p", "NTPSynchronized",
"-p", "TimeUSec", "-p", "RTCTimeUSec"]
stdout: StdioCollector {
onStreamFinished: root.parseStatus(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the system clock settings.";
}
}
Process {
id: zonesQuery
command: ["timedatectl", "list-timezones"]
stdout: StdioCollector {
onStreamFinished: {
root.zones = this.text.split("\n")
.map(line => line.trim())
.filter(line => line.length > 0);
}
}
}
Process {
id: writeRun
onExited: (exitCode, exitStatus) => {
// A polkit refusal and a bad value both land here. Neither should
// leave the UI showing a value the system did not take, so the
// status is re-read either way.
root.lastError = exitCode === 0
? ""
: "The system rejected that change, or authentication was cancelled.";
root.refresh();
}
}
Component.onCompleted: {
root.refresh();
zonesQuery.running = true;
}
function parseStatus(text: string): void {
for (const line of text.split("\n")) {
const split = line.indexOf("=");
if (split < 0)
continue;
const key = line.slice(0, split);
const value = line.slice(split + 1);
if (key === "Timezone")
root.timezone = value;
else if (key === "NTP")
root.ntpEnabled = value === "yes";
else if (key === "NTPSynchronized")
root.ntpSynchronised = value === "yes";
}
root.lastError = "";
}
function refresh(): void {
if (!statusQuery.running)
statusQuery.running = true;
}
// Only a timezone the system itself listed is ever passed on, so no
// 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.";
return false;
}
if (writeRun.running)
return false;
writeRun.exec(["timedatectl", "set-timezone", zone]);
return true;
}
function setNtp(enabled: bool): bool {
if (writeRun.running)
return false;
writeRun.exec(["timedatectl", "set-ntp", enabled ? "true" : "false"]);
return true;
}
}
@@ -0,0 +1,102 @@
pragma Singleton
// Freedesktop default handlers and session autostart entries.
//
// The helper owns parsing and atomic desktop-file writes. This singleton keeps
// the QML side typed and reactive, and every external command crosses Process
// as an argument array.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property var handlers: ({})
property var autostartEntries: []
property var luaAutostartEntries: []
property string lastError: ""
readonly property bool busy: snapshotProcess.running || mutationProcess.running
readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps"
readonly property var supportedRoles: ["browser", "mail", "files", "terminal", "music", "images", "video"]
Process {
id: snapshotProcess
stdout: StdioCollector {
onStreamFinished: root.applySnapshot(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Default applications could not be read. Try refreshing."
}
}
Process {
id: mutationProcess
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.lastError = "That application setting could not be changed."
return;
}
root.refresh();
}
}
function applySnapshot(text: string): void {
try {
const payload = JSON.parse(text);
root.handlers = payload.handlers ?? ({});
root.autostartEntries = payload.autostartEntries ?? [];
root.luaAutostartEntries = payload.luaAutostartEntries ?? [];
root.lastError = "";
} catch (error) {
root.lastError = "Default applications returned an unreadable response."
}
}
function refresh(): void {
if (root.busy)
return;
root.lastError = "";
snapshotProcess.exec([root.helper, "snapshot"]);
}
function knownDesktopId(desktopId: string): bool {
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$/.test(desktopId))
return false;
const entries = DesktopEntries.applications.values;
return entries.some(entry => {
const entryId = String(entry.id ?? "");
return entryId === desktopId || entryId + ".desktop" === desktopId;
});
}
function setDefault(role: string, desktopId: string): void {
if (root.busy)
return;
if (!root.supportedRoles.includes(role) || !root.knownDesktopId(desktopId)) {
root.lastError = "Choose an application from the available list."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "set-default", role, desktopId]);
}
function setAutostart(desktopId: string, enabled: bool): void {
if (root.busy)
return;
const known = root.autostartEntries.some(entry => entry.id === desktopId);
if (!known) {
root.lastError = "That user autostart entry is no longer available."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
}
Component.onCompleted: root.refresh()
}
+470
View File
@@ -0,0 +1,470 @@
pragma Singleton
// Display configuration: resolution, refresh rate, scale, and rotation.
//
// This is the only page in Panama Settings where a wrong value can leave you
// unable to SEE the screen well enough to undo it. A mode the display cannot
// show, or a scale that makes everything unreadable, is not recoverable through
// the same UI that caused it.
//
// So a change is never applied irreversibly. It is applied, then reverted
// automatically after a countdown unless confirmed -- the same contract every
// desktop uses for this one setting, and for the same reason. Confirming is
// what writes it to the settings store; letting the countdown run leaves
// nothing behind.
//
// Applied with `hyprctl eval` and hl.monitor{}. As everywhere else in Panama,
// success means the value was read back from the compositor and matched, never
// that a command exited zero.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// [{ name, description, width, height, refreshRate, scale, transform,
// modes: [{ label, mode, width, height, refresh }] }]
property var monitors: []
property string lastError: ""
// Set while a change is applied but not yet confirmed.
property string pendingOutput: ""
property var pendingPrevious: null
property var pendingRequested: null
property bool pendingVerified: false
property bool revertQueued: false
property var revertExpected: null
property string revertReason: ""
property bool revertVerificationActive: false
property int operationGeneration: 0
property int revertGeneration: -1
property bool externalChangeBlocked: false
property int secondsLeft: 0
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
readonly property bool canConfirm: root.awaitingConfirmation
&& root.pendingVerified
&& !root.busy
readonly property bool busy: query.running || applyRun.running || revertRun.running
|| root.revertExpected !== null
readonly property int confirmSeconds: 15
readonly property var transforms: [
{ value: 0, label: "Landscape" },
{ value: 1, label: "Portrait" },
{ value: 2, label: "Landscape (flipped)" },
{ value: 3, label: "Portrait (flipped)" }
]
// Scales that divide this desktop's common resolutions into whole pixels.
// Hyprland rejects a fractional scale that does not, and the message it
// gives is not something to put in front of a user.
readonly property var scales: [1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
Process {
id: query
property int generation: 0
command: ["hyprctl", "-j", "monitors"]
stdout: StdioCollector {
onStreamFinished: root.parse(this.text, query.generation)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the connected displays.";
if (root.revertQueued && !applyRun.running && root.awaitingConfirmation)
root.performRevert();
}
}
Process {
id: applyRun
onExited: (exitCode, exitStatus) => {
if (!root.awaitingConfirmation)
return;
if (root.revertQueued) {
if (!query.running)
root.performRevert();
return;
}
if (exitCode !== 0) {
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
return;
}
verifyTimer.attempts = 0;
verifyTimer.ticks = 0;
verifyTimer.restart();
}
}
Process {
id: revertRun
onExited: (exitCode, exitStatus) => {
// 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();
}
}
Component.onCompleted: root.refresh()
function refresh(): bool {
if (!query.running) {
query.generation = root.operationGeneration;
query.running = true;
return true;
}
return false;
}
function parse(text: string, generation: int): void {
try {
const raw = JSON.parse(text);
root.monitors = raw.map(monitor => {
const modes = root.normaliseModes(monitor.availableModes ?? []);
const width = monitor.width ?? 0;
const height = monitor.height ?? 0;
const refreshRate = monitor.refreshRate ?? 0;
const current = modes
.filter(mode => mode.width === width && mode.height === height)
.sort((left, right) =>
Math.abs(left.refresh - refreshRate) - Math.abs(right.refresh - refreshRate))[0];
return {
name: monitor.name ?? "",
description: monitor.description ?? monitor.model ?? "Display",
width: width,
height: height,
refreshRate: refreshRate,
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
scale: monitor.scale ?? 1,
transform: monitor.transform ?? 0,
currentFormat: monitor.currentFormat ?? "",
colorPreset: monitor.colorManagementPreset ?? "",
vrr: monitor.vrr === true,
modes: modes
};
});
if (root.awaitingConfirmation && root.pendingRequested
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
root.pendingVerified = true;
verifyTimer.stop();
root.lastError = "";
} else if (root.revertVerificationActive
&& generation === root.revertGeneration
&& root.revertExpected
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
revertVerifyTimer.stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpected = null;
if (root.revertReason === "")
root.lastError = "";
else
root.lastError = root.revertReason;
root.revertReason = "";
} else if (!root.awaitingConfirmation && !root.revertExpected && (
root.lastError === "Could not read the connected displays."
|| root.lastError === "The display list could not be read.")) {
root.lastError = "";
}
} catch (error) {
root.lastError = "The display list could not be read.";
}
}
// "4500x3000@60.00Hz" -> a sortable record. The compositor reports the same
// 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 {
const seen = {};
const out = [];
for (const entry of raw) {
const match = String(entry).match(/^(\d+)x(\d+)@([\d.]+)Hz$/);
if (!match)
continue;
const width = Number(match[1]);
const height = Number(match[2]);
const refreshText = match[3];
const refresh = Number(refreshText);
const roundedRefresh = Math.round(refresh);
const key = `${width}x${height}@${refreshText}`;
if (seen[key])
continue;
seen[key] = true;
out.push({
label: `${width} × ${height}`,
refreshLabel: Math.abs(refresh - roundedRefresh) < 0.005
? `${roundedRefresh} Hz`
: `${refresh.toFixed(2)} Hz`,
mode: `${width}x${height}@${refreshText}`,
width: width,
height: height,
refresh: refresh
});
}
return out.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.refresh - a.refresh);
}
function monitorNamed(name: string): var {
return root.monitors.find(monitor => monitor.name === name) ?? null;
}
function modeParts(mode: string): var {
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
if (!match)
return null;
return {
width: Number(match[1]),
height: Number(match[2]),
refresh: Number(match[3])
};
}
function isScaleClean(mode: string, scale: real): bool {
const parts = root.modeParts(mode);
if (!parts || root.scales.indexOf(scale) < 0 || !Number.isFinite(scale) || scale <= 0)
return false;
const logicalWidth = parts.width / scale;
const logicalHeight = parts.height / scale;
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.0001
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.0001;
}
function scalesForMode(mode: string): var {
return root.scales.filter(scale => root.isScaleClean(mode, scale));
}
function nearestCleanScale(mode: string, preferred: real): real {
const choices = root.scalesForMode(mode);
if (choices.length === 0)
return 1.0;
return choices.reduce((best, candidate) =>
Math.abs(candidate - preferred) < Math.abs(best - preferred) ? candidate : best,
choices[0]);
}
function matchesRequest(monitor: var, requested: var): bool {
if (!monitor || !requested || monitor.name !== requested.output)
return false;
const parts = root.modeParts(requested.mode);
return !!parts
&& monitor.width === parts.width
&& monitor.height === parts.height
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
&& Math.abs(monitor.scale - requested.scale) < 0.001
&& monitor.transform === requested.transform;
}
function modeIsCurrent(monitor: var, candidate: var): bool {
return !!monitor && !!candidate
&& monitor.width === candidate.width
&& monitor.height === candidate.height
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
}
// Applies immediately and starts the countdown. Nothing is stored yet: the
// settings file is only written by confirm().
function apply(output: string, mode: string, scale: real, transform: int): bool {
if (root.externalChangeBlocked) {
root.lastError = "Wait for Settings to finish restoring before changing a display.";
return false;
}
if (root.busy) {
root.lastError = "Wait for the current display operation to finish.";
return false;
}
if (root.awaitingConfirmation) {
root.lastError = "Finish the current display change first.";
return false;
}
const monitor = root.monitorNamed(output);
if (!monitor) {
root.lastError = "That display is not connected.";
return false;
}
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
root.lastError = "That display does not offer that mode.";
return false;
}
if (!root.isScaleClean(mode, scale)) {
root.lastError = "That scale does not divide this resolution cleanly.";
return false;
}
if (!root.transforms.some(candidate => candidate.value === transform)) {
root.lastError = "That rotation is not one Panama offers.";
return false;
}
root.pendingPrevious = {
output: output,
mode: monitor.mode,
scale: monitor.scale,
transform: monitor.transform
};
root.operationGeneration++;
root.pendingRequested = {
output: output,
mode: mode,
scale: scale,
transform: transform
};
root.pendingOutput = output;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = root.confirmSeconds;
root.lastError = "";
countdown.restart();
root.push(output, mode, scale, transform);
return true;
}
function push(output: string, mode: string, scale: real, transform: int): void {
// Values are validated above and the output name comes from the
// compositor's own list, so nothing user-authored reaches the payload.
applyRun.exec(["hyprctl", "eval",
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
}
function confirm(): bool {
if (!root.canConfirm || !root.matchesRequest(
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
if (root.awaitingConfirmation)
root.lastError = "Wait for the display to finish applying before keeping it.";
return false;
}
const stored = DesktopPreferences.get("displays");
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
next[root.pendingOutput] = {
mode: root.pendingRequested.mode,
scale: root.pendingRequested.scale,
transform: root.pendingRequested.transform
};
if (!DesktopPreferences.set("displays", next)) {
root.lastError = "That display setting could not be saved. Revert it and try again.";
return false;
}
root.clearPending();
root.lastError = "";
return true;
}
function clearPending(): void {
countdown.stop();
verifyTimer.stop();
root.pendingOutput = "";
root.pendingPrevious = null;
root.pendingRequested = null;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = 0;
}
function revert(): void {
root.revertWithMessage("");
}
function revertWithMessage(message: string): void {
if (!root.awaitingConfirmation)
return;
countdown.stop();
verifyTimer.stop();
root.pendingVerified = false;
root.revertReason = message;
if (message !== "")
root.lastError = message;
if (applyRun.running || query.running) {
root.revertQueued = true;
return;
}
root.performRevert();
}
function performRevert(): void {
const previous = root.pendingPrevious;
root.operationGeneration++;
root.revertGeneration = root.operationGeneration;
root.revertExpected = previous;
root.revertVerificationActive = false;
root.clearPending();
if (previous) {
revertRun.exec(["hyprctl", "eval",
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
}
}
// Clears any stored override for an output so it returns to the value
// shipped in hypr/monitors.lua on the next start.
function forget(output: string): void {
const stored = DesktopPreferences.get("displays");
if (!stored || typeof stored !== "object" || stored[output] === undefined)
return;
const next = Object.assign({}, stored);
delete next[output];
DesktopPreferences.set("displays", next);
}
function isOverridden(output: string): bool {
const stored = DesktopPreferences.get("displays");
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
}
Timer {
id: verifyTimer
property int attempts: 0
property int ticks: 0
interval: 120
repeat: true
onTriggered: {
ticks++;
if (ticks > 50) {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
return;
}
if (root.refresh())
attempts++;
}
}
Timer {
id: revertVerifyTimer
property int attempts: 0
property int ticks: 0
interval: 120
repeat: true
onTriggered: {
ticks++;
if (ticks > 50) {
stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpected = null;
root.revertReason = "";
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
return;
}
if (root.refresh())
attempts++;
}
}
Timer {
id: countdown
interval: 1000
repeat: true
onTriggered: {
root.secondsLeft -= 1;
if (root.secondsLeft <= 0)
root.revert();
}
}
}
+102
View File
@@ -0,0 +1,102 @@
pragma Singleton
// Installed fonts, split by what they can actually be used for.
//
// Two different questions with different answers: which families are readable
// as interface text, and which carry the Nerd Font glyphs the shell draws its
// icons with. Offering one list for both is how you end up with a desktop full
// of tofu, so they stay separate.
//
// Enumerated once on demand. Fonts do not appear while you are looking at a
// settings page, and fc-list over a few hundred families is not free.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-fonts"
property var interfaceFonts: []
property var monospaceFonts: []
property var iconFonts: []
property bool scanning: false
property string lastError: ""
readonly property string interfaceFont: DesktopPreferences.get("interfaceFont")
readonly property string iconFont: DesktopPreferences.get("iconFont")
readonly property bool loaded: root.interfaceFonts.length > 0
// True when the stored family is not installed. fontconfig silently
// substitutes something else, so the shell keeps working and quietly stops
// looking the way it was configured to -- worth saying out loud.
readonly property bool interfaceMissing: root.loaded
&& root.interfaceFonts.indexOf(root.interfaceFont) < 0
readonly property bool iconMissing: root.iconFonts.length > 0
&& root.iconFonts.indexOf(root.iconFont) < 0
Process {
id: scan
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.interfaceFonts = parsed.interface ?? [];
root.monospaceFonts = parsed.monospace ?? [];
root.iconFonts = parsed.icons ?? [];
root.lastError = "";
} catch (error) {
root.lastError = "The installed fonts could not be read.";
}
root.scanning = false;
}
}
onExited: (exitCode, exitStatus) => {
root.scanning = false;
if (exitCode !== 0)
root.lastError = "The installed fonts could not be read.";
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (scan.running)
return;
root.scanning = true;
scan.running = true;
}
// Only a family this machine reported is accepted, so a hand-edited
// settings file cannot put arbitrary text where a font name belongs -- it
// reaches hl.config as a string on the compositor side.
function setInterface(family: string): bool {
if (root.interfaceFonts.indexOf(family) < 0) {
root.lastError = "That font is not installed.";
return false;
}
return root.store("interfaceFont", family);
}
function setIcon(family: string): bool {
if (root.iconFonts.indexOf(family) < 0) {
root.lastError = "That font does not carry icon glyphs.";
return false;
}
return root.store("iconFont", family);
}
function store(key: string, family: string): bool {
if (!DesktopPreferences.set(key, family)) {
root.lastError = "That font name could not be saved.";
return false;
}
root.lastError = "";
return true;
}
}
@@ -0,0 +1,129 @@
pragma Singleton
// Turning a place name into coordinates.
//
// The weather card needs latitude and longitude, but nobody knows their own
// coordinates, and a settings page that demands them is a settings page nobody
// changes. Open-Meteo publishes a geocoding endpoint that needs no API key and
// no account, which is the same reason the forecast itself uses them.
//
// Fetched with curl rather than XMLHttpRequest for the same reason as
// services/Weather.qml: curl is guaranteed present, and a search that fails
// must leave the page usable rather than producing an error popup.
//
// Only the query is sent. The stored location label never leaves the machine.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// [{ name, admin, country, latitude, longitude, label }]
property var results: []
property bool searching: false
property string lastError: ""
property string lastQuery: ""
readonly property string endpoint: "https://geocoding-api.open-meteo.com/v1/search"
Process {
id: fetch
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (exitCode, exitStatus) => {
root.searching = false;
if (exitCode !== 0)
root.lastError = "Could not reach the location service.";
}
}
// Debounced: typing "Denver" should not fire six searches.
Timer {
id: debounce
interval: 350
onTriggered: root.run()
}
property string pending: ""
function search(query: string): void {
const trimmed = String(query).trim();
root.pending = trimmed;
if (trimmed.length < 2) {
root.results = [];
root.lastError = "";
debounce.stop();
return;
}
debounce.restart();
}
function run(): void {
if (fetch.running || root.pending.length < 2)
return;
root.searching = true;
root.lastError = "";
root.lastQuery = root.pending;
// --get with --data-urlencode makes curl do the escaping, so a place
// name with spaces or an ampersand cannot alter the request.
fetch.exec(["curl", "-s", "--max-time", "10", "--get",
"--data-urlencode", `name=${root.pending}`,
"--data-urlencode", "count=8",
"--data-urlencode", "format=json",
root.endpoint]);
}
function parse(text: string): void {
try {
const parsed = JSON.parse(text);
const out = [];
for (const item of (parsed.results ?? [])) {
if (typeof item.latitude !== "number" || typeof item.longitude !== "number")
continue;
const admin = item.admin1 ?? "";
const country = item.country ?? "";
out.push({
name: item.name ?? "",
admin: admin,
country: country,
latitude: item.latitude,
longitude: item.longitude,
// What the user will see stored as their location label.
label: [item.name, admin, country].filter(part => !!part).join(", ")
});
}
root.results = out;
root.lastError = out.length === 0 ? "No places match that name." : "";
} catch (error) {
root.results = [];
root.lastError = "The location service returned something unreadable.";
}
}
// Stores a chosen place. Coordinates are rounded to four decimals -- roughly
// ten metres, 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;
const longitude = Math.round(place.longitude * 10000) / 10000;
const label = String(place.label).slice(0, 64);
const ok = DesktopPreferences.set("weatherLatitude", latitude)
&& DesktopPreferences.set("weatherLongitude", longitude)
&& DesktopPreferences.set("weatherLocation", label);
if (!ok) {
root.lastError = "That location could not be saved.";
return false;
}
root.results = [];
root.lastError = "";
return true;
}
}
@@ -0,0 +1,89 @@
pragma Singleton
// The GPUs that can report utilisation.
//
// The vitals readout needs one specific sysfs file, and card numbering is
// neither stable across machines nor meaningful to a person -- this machine has
// two amdgpu cards, one discrete and one integrated, and picking a number
// blindly measures whichever the kernel enumerated first.
//
// Enumerated on demand rather than polled: hardware does not appear while you
// are looking at a settings page.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gpus"
// [{ card, path, name, busy }]
property var devices: []
property bool scanning: false
property string lastError: ""
readonly property string selectedPath: DesktopPreferences.get("gpuBusyPath")
readonly property var selected: root.devices.find(device => device.path === root.selectedPath) ?? null
// True when a GPU is stored that this machine does not have -- after moving
// the settings file between machines, say.
readonly property bool selectionMissing: root.devices.length > 0 && root.selected === null
Process {
id: scan
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.devices = Array.isArray(parsed) ? parsed : [];
root.lastError = "";
} catch (error) {
root.devices = [];
root.lastError = "The graphics devices could not be read.";
}
root.scanning = false;
}
}
onExited: (exitCode, exitStatus) => {
root.scanning = false;
if (exitCode !== 0)
root.lastError = "The graphics devices could not be read.";
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (scan.running)
return;
root.scanning = true;
scan.running = true;
}
// Only a path this machine actually reported is accepted, so a hand-edited
// settings file cannot point the readout at an arbitrary file.
function select(path: string): bool {
if (!root.devices.some(device => device.path === path)) {
root.lastError = "That graphics device is not present.";
return false;
}
if (!DesktopPreferences.set("gpuBusyPath", path)) {
root.lastError = "That graphics device could not be saved.";
return false;
}
root.lastError = "";
return true;
}
// "AMD ... [Radeon RX 7700 XT / 7800 XT] (rev c8)" is what lspci gives; the
// bracketed marketing name is the part anyone recognises.
function shortName(name: string): string {
const bracketed = String(name).match(/\[([^\]]+)\]\s*(?:\(rev[^)]*\))?\s*$/);
return bracketed ? bracketed[1] : String(name).replace(/\s*\(rev[^)]*\)\s*$/, "");
}
}
@@ -0,0 +1,120 @@
pragma Singleton
// Redacted Home Assistant configuration state. The helper is the only object
// that touches the private env file; QML never receives the stored token.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant-config"
property string url: ""
property var entities: []
property bool tokenConfigured: false
property bool configured: false
property string lastError: ""
property string pendingPayload: ""
property var refreshHomeAssistant: function() { HomeAssistant.refresh(); }
readonly property bool busy: statusProc.running || writeProc.running
signal configurationSaved
function errorMessage(code: string): string {
switch (code) {
case "invalid-url": return "Enter an HTTP or HTTPS Home Assistant URL.";
case "invalid-token": return "The access token contains unsupported characters.";
case "invalid-entities": return "Entity IDs must look like light.living_room.";
case "read-failed": return "The private configuration file could not be read.";
case "write-failed": return "The private configuration file could not be saved.";
case "invalid-payload": return "The configuration could not be validated.";
default: return code ? "Home Assistant configuration is unavailable." : "";
}
}
function applyResult(text: string, saved: bool): void {
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
result = { ok: false, error: "invalid-response" };
}
if (result.ok !== true) {
root.lastError = root.errorMessage(String(result.error || "invalid-response"));
return;
}
root.url = String(result.url || "");
root.entities = Array.isArray(result.entities) ? result.entities : [];
root.tokenConfigured = result.tokenConfigured === true;
root.configured = result.configured === true;
root.lastError = "";
if (saved) {
root.configurationSaved();
root.refreshHomeAssistant();
}
}
function refresh(): void {
if (!statusProc.running && !writeProc.running)
statusProc.running = true;
}
// An empty token means "keep the stored token". Clearing is an explicit
// separate action so editing the URL can never erase a secret by accident.
function save(url: string, entitiesText: string, token: string): bool {
if (root.busy)
return false;
const payload = { url: url, entities: entitiesText };
if (token.trim() !== "")
payload.token = token;
return root.startWrite(payload);
}
function clearToken(): bool {
if (root.busy || !root.tokenConfigured)
return false;
return root.startWrite({ token: "" });
}
function startWrite(payload: var): bool {
root.lastError = "";
root.pendingPayload = JSON.stringify(payload);
writeProc.running = true;
return true;
}
Process {
id: statusProc
command: [root.helperPath, "status"]
stdout: StdioCollector {
onStreamFinished: root.applyResult(this.text, false)
}
onExited: (code, status) => {
if (code !== 0 && root.lastError === "")
root.lastError = "Home Assistant configuration could not be loaded.";
}
}
Process {
id: writeProc
command: [root.helperPath, "write"]
stdinEnabled: true
stdout: StdioCollector {
onStreamFinished: root.applyResult(this.text, true)
}
onStarted: {
writeProc.write(root.pendingPayload + "\n");
root.pendingPayload = "";
}
onExited: (code, status) => {
root.pendingPayload = "";
if (code !== 0 && root.lastError === "")
root.lastError = "Home Assistant configuration could not be saved.";
}
}
Component.onCompleted: root.refresh()
}
+115
View File
@@ -0,0 +1,115 @@
pragma Singleton
// Idle, lock, and sleep timings.
//
// hypridle has no IPC for reconfiguration, and its config is hyprlang rather
// than the shared JSON, so this cannot work the way the compositor settings do.
// Instead scripts/panama-idle regenerates a config from the settings store and
// restarts the daemon.
//
// The generated file lives under XDG_STATE_HOME rather than ~/.config/hypr,
// because that directory is a symlink into the Panama repository -- writing
// there at runtime would put machine state into a tracked file. A systemd
// drop-in points hypridle at the generated path with `-c`.
//
// "Managed" is therefore a real state with two sides: when the drop-in is
// installed the timings below are in effect, and when it is not, hypridle is
// running the repository's shipped hypridle.conf and these values are only a
// stored intention. The Power page says which it is rather than showing
// controls that quietly do nothing.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-idle"
property bool managed: false
property string serviceState: "unknown"
property string generatedPath: ""
property string lastError: ""
readonly property bool busy: statusQuery.running || applyRun.running
// The values as stored. They only describe what is running when `managed`.
readonly property int blankMinutes: DesktopPreferences.get("screenBlankMinutes")
readonly property int lockMinutes: DesktopPreferences.get("lockMinutes")
readonly property int suspendMinutes: DesktopPreferences.get("suspendMinutes")
readonly property bool lockOnSleep: DesktopPreferences.get("lockOnSleep")
// Blanking after locking is legal but pointless, and blanking with lock off
// is fine. Surfacing the one genuinely confusing combination beats silently
// reordering the user's numbers.
readonly property bool lockBeforeBlank: root.lockMinutes > 0
&& root.blankMinutes > 0
&& root.lockMinutes < root.blankMinutes
Process {
id: statusQuery
command: [root.helperPath, "status"]
stdout: StdioCollector {
onStreamFinished: {
try {
const state = JSON.parse(this.text);
root.managed = state.managed === true;
root.serviceState = String(state.active ?? "unknown");
root.generatedPath = String(state.generated ?? "");
root.lastError = "";
} catch (error) {
root.lastError = "Could not read the idle configuration.";
}
}
}
}
Process {
id: applyRun
onExited: (exitCode, exitStatus) => {
root.lastError = exitCode === 0 ? "" : "Could not update the idle configuration.";
root.refresh();
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (!statusQuery.running)
statusQuery.running = true;
}
// Regenerates the config from the current settings and restarts hypridle if
// Panama is managing it. Safe to call when it is not: the file is written
// and nothing is restarted.
function apply(): void {
if (applyRun.running)
return;
applyRun.exec([root.helperPath, "apply"]);
}
function setManaged(enabled: bool): void {
if (applyRun.running)
return;
applyRun.exec([root.helperPath, enabled ? "install" : "remove"]);
}
// Regenerate whenever one of the four inputs changes. Coalesced, because a
// slider drag settles through several commits and each one would otherwise
// restart the daemon.
Connections {
target: DesktopPreferences
function onRevisionChanged(): void {
if (root.managed)
regenerate.restart();
}
}
Timer {
id: regenerate
interval: 400
onTriggered: root.apply()
}
}
+318
View File
@@ -0,0 +1,318 @@
pragma Singleton
// The keymap, read from the compositor rather than restated.
//
// The Shortcuts page used to hold a hand-typed array of nineteen entries while
// keybinds.lua produced a hundred and thirteen. It could not show the other
// ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j`
// is the only description of the keymap that cannot be wrong, so this reads
// that and every bind carries its own human label (see the `description`
// argument in hypr/keybinds.lua).
//
// Refreshed on demand, not polled: binds only change when the config is
// reloaded, and nothing in this shell should wake up to re-read something that
// has not moved.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// [{ chord, description, group, mouse, repeating, locked }], ordered as
// Hyprland reports them, which follows the order they appear in the config.
property var binds: []
property bool loaded: false
property string lastError: ""
readonly property bool busy: query.running
// Hyprland's modmask bits. SUPER is the Panama modifier.
readonly property var modifierBits: [
{ bit: 64, name: "Super" },
{ bit: 4, name: "Ctrl" },
{ bit: 8, name: "Alt" },
{ bit: 1, name: "Shift" }
]
// Keysyms whose raw names would be noise in a shortcuts list.
readonly property var keyNames: ({
"mouse_up": "Scroll up",
"mouse_down": "Scroll down",
"mouse:272": "Left click",
"mouse:273": "Right click",
"mouse:274": "Middle click",
"bracketleft": "[",
"bracketright": "]",
"grave": "`",
"Print": "Print Screen",
"left": "←",
"right": "→",
"up": "↑",
"down": "↓"
})
Process {
id: query
command: ["hyprctl", "-j", "binds"]
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the keymap from Hyprland.";
}
}
// ── Rebinding ───────────────────────────────────────────────────────────
// Overrides map a SHIPPED chord to a replacement. hypr/keybinds.lua reads
// them and substitutes only the chord -- the action is always the Lua value
// written in that file -- so an override can move a shortcut but can never
// make one do something else.
//
// Applying needs `hyprctl reload` rather than a live `hl.bind`: Hyprland
// reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
// so the action cannot be reconstructed from the outside to re-bind it.
// Reload re-runs the config, which re-reads the settings file.
readonly property var overrides: {
const stored = DesktopPreferences.get("keybindOverrides");
return (stored && typeof stored === "object") ? stored : ({});
}
property bool reloading: false
// The chord a bind ships with, given the chord it currently answers to.
// Displayed binds come from the compositor and so already reflect any
// override; the override map is what tells us where they started.
function shippedChordFor(currentChord: string): string {
for (const shipped in root.overrides) {
if (root.overrides[shipped] === currentChord)
return shipped;
}
return currentChord;
}
function isOverridden(currentChord: string): bool {
return root.shippedChordFor(currentChord) !== currentChord;
}
// Refuses a chord already answering to something else, so rebinding cannot
// quietly shadow an existing shortcut.
function conflictFor(chord: string, exceptCurrent: string): string {
for (const bind of root.binds) {
if (bind.luaChord === chord && bind.luaChord !== exceptCurrent)
return bind.description;
}
return "";
}
// A moved shortcut vacates its shipped chord, so another override may use
// it legitimately. Refuse to reset the first shortcut until that occupant
// moves away; otherwise Hyprland would receive two binds on one chord.
function overrideOccupantFor(chord: string, exceptShipped: string): string {
for (const shipped in root.overrides) {
if (shipped !== exceptShipped && root.overrides[shipped] === chord)
return shipped;
}
return "";
}
function rebind(currentChord: string, newChord: string): bool {
if (newChord === "" || newChord === currentChord)
return false;
const conflict = root.conflictFor(newChord, currentChord);
if (conflict !== "") {
root.lastError = `${newChord} is already ${conflict}.`;
return false;
}
const shipped = root.shippedChordFor(currentChord);
const next = Object.assign({}, root.overrides);
if (newChord === shipped)
delete next[shipped];
else
next[shipped] = newChord;
if (!DesktopPreferences.set("keybindOverrides", next)) {
root.lastError = "That shortcut could not be saved.";
return false;
}
root.applyReload();
return true;
}
function resetBind(currentChord: string): bool {
const shipped = root.shippedChordFor(currentChord);
if (shipped === currentChord)
return true;
const occupant = root.overrideOccupantFor(shipped, shipped);
if (occupant !== "") {
root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`;
return false;
}
const next = Object.assign({}, root.overrides);
delete next[shipped];
if (!DesktopPreferences.set("keybindOverrides", next)) {
root.lastError = "That shortcut could not be reset.";
return false;
}
root.applyReload();
return true;
}
function resetAll(): void {
if (Object.keys(root.overrides).length === 0)
return;
DesktopPreferences.set("keybindOverrides", ({}));
root.applyReload();
}
Process {
id: reloadRun
command: ["hyprctl", "reload"]
onExited: (exitCode, exitStatus) => {
root.reloading = false;
if (exitCode !== 0) {
root.lastError = "The compositor did not reload.";
return;
}
root.lastError = "";
// The settings file is written on a timer, so re-read the keymap
// once the reload has had a moment to pick it up.
settle.restart();
}
}
Timer {
id: settle
interval: 350
onTriggered: root.refresh()
}
function applyReload(): void {
if (reloadRun.running)
return;
root.reloading = true;
// Give DesktopPreferences' coalescing write a moment to land first.
reloadDelay.restart();
}
Timer {
id: reloadDelay
interval: 120
onTriggered: reloadRun.running = true
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (query.running)
return;
root.lastError = "";
query.running = true;
}
function parse(text: string): void {
try {
const raw = JSON.parse(text);
const out = [];
for (const bind of raw) {
const description = String(bind.description ?? "").trim();
// A bind with no description cannot be presented usefully --
// the dispatcher is "__lua" and the argument is a bytecode
// offset. Showing the chord alone would be worse than omitting
// it, and tests/quickshell/keybinds-contract.sh fails the build
// if any exist, so this should never be reached in practice.
if (description === "")
continue;
out.push({
chord: root.formatChord(bind),
// The same chord in the form hypr/keybinds.lua writes, which
// is what an override is keyed by. The display form
// prettifies modifiers and arrow keys and so cannot be used
// for that.
luaChord: root.luaChord(bind),
description: description,
group: root.groupFor(description, bind),
mouse: bind.mouse === true,
repeating: bind.repeat === true,
locked: bind.locked === true
});
}
root.binds = out;
root.loaded = true;
root.lastError = "";
} catch (error) {
root.lastError = "The keymap could not be read.";
}
}
// "SUPER + SHIFT + K" -- uppercase modifiers in the order keybinds.lua
// writes them, then the raw keysym rather than its display name.
function luaChord(bind: var): string {
const parts = [];
for (const modifier of root.modifierBits) {
if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name.toUpperCase());
}
parts.push(String(bind.key ?? ""));
return parts.join(" + ");
}
function formatChord(bind: var): string {
const parts = [];
for (const modifier of root.modifierBits) {
if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name);
}
const key = String(bind.key ?? "");
parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
return parts.join(" + ");
}
// Grouping is by what the shortcut does, taken from its own description,
// so adding a bind puts it in the right section without touching this file.
function groupFor(description: string, bind: var): string {
const text = description.toLowerCase();
if (bind.key && String(bind.key).indexOf("XF86") === 0)
return "Media & hardware keys";
if (text.indexOf("workspace") >= 0)
return "Workspaces";
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
return "Windows";
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|| text.indexOf("brightness") >= 0)
return "Media & hardware keys";
return "Applications & shell";
}
// Section order for the page. Anything a future bind invents lands at the
// end rather than being dropped.
readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"]
function grouped(): var {
const buckets = {};
for (const bind of root.binds) {
buckets[bind.group] = buckets[bind.group] ?? [];
buckets[bind.group].push(bind);
}
const names = Object.keys(buckets).sort((a, b) => {
const ia = root.groupOrder.indexOf(a);
const ib = root.groupOrder.indexOf(b);
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
});
return names.map(name => ({ name: name, binds: buckets[name] }));
}
}
+46 -1
View File
@@ -26,6 +26,7 @@ Singleton {
id: root
property bool initialized: false
property bool suppressStatusEvents: false
// The manual switch. Ignored while `automatic` is on.
property bool enabled: Settings.nightLightEnabledByDefault
@@ -53,6 +54,22 @@ Singleton {
}
}
// Launcher actions already provide immediate Prism OSD feedback. Keep the
// ambient Signal Glass channel quiet for that path so one choice produces
// one confirmation instead of two competing transients.
function toggleQuietly(): void {
root.suppressStatusEvents = true;
root.toggle();
root.suppressStatusEvents = false;
}
function restore(manualEnabled: bool, automaticEnabled: bool): void {
root.suppressStatusEvents = true;
root.enabled = manualEnabled;
root.automatic = automaticEnabled;
root.suppressStatusEvents = false;
}
// The window wraps midnight (17:00 → 10:00), so the comparison flips when
// `from` is later in the day than `to`.
function inWindow(hour: real): bool {
@@ -89,8 +106,36 @@ Singleton {
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic)
// `enabled`, `temperature`, and `automatic` are declared as bindings on the
// store, but a binding is destroyed the moment anything assigns to the
// property -- which toggle() does. Without this, the service would write to
// the store and never read from it again: Quick Settings would keep working
// while the same settings on the Displays page silently did nothing, which
// is worse than not offering them there at all.
//
// No loop: set() is a no-op when the value is unchanged, and assigning a
// property its current value emits nothing, so this converges immediately.
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { root.syncFromStore(); }
}
function syncFromStore(): void {
const storedEnabled = DesktopPreferences.get("nightLightEnabled") === true;
if (root.enabled !== storedEnabled)
root.enabled = storedEnabled;
const storedAutomatic = DesktopPreferences.get("nightLightAutomatic") === true;
if (root.automatic !== storedAutomatic)
root.automatic = storedAutomatic;
const storedTemperature = DesktopPreferences.get("nightLightTemperature");
if (typeof storedTemperature === "number" && root.temperature !== storedTemperature)
root.temperature = storedTemperature;
}
onActiveChanged: {
if (!root.initialized)
if (!root.initialized || root.suppressStatusEvents)
return;
StatusEvents.publish({
key: "display-night-light",
+116 -4
View File
@@ -37,6 +37,39 @@ Singleton {
// Cleared when the notification centre is opened. The bar binds to this.
property int unreadCount: 0
// Kept separate from the persisted map so this version can safely run
// before the matching schema entry lands. A later accepted write folds the
// complete map into DesktopPreferences and clears this fallback.
property var fallbackAppRules: ({})
// Display metadata is intentionally session-only. The durable shape stays
// just the per-application rule map, while a fresh notification gives the
// settings page a human-readable name straight away.
property var rememberedApplications: ({})
readonly property var persistedAppRules: {
const stored = DesktopPreferences.get("notificationAppRules");
return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {};
}
// The schema change is the persistence boundary. This branch keeps a
// session fallback only so it remains usable while that companion change
// is being integrated; it intentionally makes no restart guarantee then.
readonly property bool appRulesSchemaAvailable: PreferenceSchema.has("notificationAppRules")
readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)
readonly property var applications: {
// byId()/heuristicLookup() do not make a binding by themselves. This
// read updates persisted app labels once DesktopEntries finishes scan.
const entries = DesktopEntries.applications.values;
const remembered = root.rememberedApplications;
return Object.keys(root.appRules).map(appId => ({
id: appId,
name: root.applicationLabel(appId, entries, remembered)
})).sort((a, b) => a.name.localeCompare(b.name));
}
// Arrival times, keyed by notification id — the protocol carries no
// timestamp. Deliberately formatted once at arrival rather than shown as
// "5 minutes ago", which would need a clock ticking behind every card.
@@ -49,6 +82,79 @@ Singleton {
readonly property bool hasNotifications: root.history.length > 0
function notificationAppId(notification: var): string {
const desktopEntry = String(notification.desktopEntry ?? "").trim();
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
}
function applicationLabel(appId: string, entries: var, remembered: var): string {
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
const entry = DesktopEntries.byId(appId)
|| DesktopEntries.byId(desktopId)
|| DesktopEntries.heuristicLookup(appId)
|| DesktopEntries.heuristicLookup(desktopId);
return entry?.name || remembered[appId]?.name || appId;
}
function normalizedAppRule(rule: var): var {
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
return {
enabled: source.enabled !== false,
showOnLockScreen: source.showOnLockScreen !== false,
showContentOnLockScreen: source.showContentOnLockScreen !== false
};
}
function appRule(appId: string): var {
return root.normalizedAppRule(root.appRules[appId]);
}
function setAppRule(appId: string, patch: var): bool {
if (!appId)
return false;
const current = root.appRule(appId);
const next = {};
for (const knownAppId of Object.keys(root.appRules))
next[knownAppId] = root.appRule(knownAppId);
next[appId] = {
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true,
showOnLockScreen: patch.showOnLockScreen === undefined ? current.showOnLockScreen : patch.showOnLockScreen === true,
showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true
};
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
root.fallbackAppRules = {};
else
root.fallbackAppRules = next;
return true;
}
function rememberApplication(notification: var): string {
const appId = root.notificationAppId(notification);
const next = Object.assign({}, root.rememberedApplications);
next[appId] = {
name: String(notification.appName ?? "").trim() || appId
};
root.rememberedApplications = next;
if (root.appRules[appId] === undefined)
root.setAppRule(appId, {});
return appId;
}
// These policy getters deliberately accept Notification objects, so a lock
// screen can use the same source of truth without duplicating app matching.
function shouldShowOnLockScreen(notification: var): bool {
const rule = root.appRule(root.notificationAppId(notification));
return rule.enabled && rule.showOnLockScreen;
}
function shouldShowContentOnLockScreen(notification: var): bool {
const rule = root.appRule(root.notificationAppId(notification));
return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen;
}
// history grouped by app, in most-recent-app-first order — the shape
// NotificationCenter.qml renders directly.
readonly property var groups: {
@@ -85,13 +191,20 @@ Singleton {
actionIconsSupported: true
inlineReplySupported: true
onNotification: notification => {
onNotification: notification => root.handleNotification(notification)
}
function handleNotification(notification: var): void {
// Replayed from before a shell reload. Letting these through would
// re-toast and re-list everything on every edit, so they are left
// untracked and allowed to die.
if (notification.lastGeneration)
return;
const appId = root.rememberApplication(notification);
if (!root.appRule(appId).enabled)
return;
// Without this the object is destroyed the instant this returns.
notification.tracked = true;
root.arrivals[notification.id] = new Date();
@@ -109,11 +222,10 @@ Singleton {
if (!root.doNotDisturb)
root.popups = [notification].concat(root.popups);
}
}
// ── Mutation ────────────────────────────────────────────────────────────
function pushHistory(n: Notification): void {
function pushHistory(n: var): void {
const next = [n].concat(root.history);
// Anything past the cap is released, otherwise it stays tracked
@@ -166,7 +278,7 @@ Singleton {
// Called from the `closed` signal — the object is on its way out, so this
// only ever removes references, never touches the notification.
function forget(n: Notification): void {
function forget(n: var): void {
delete root.arrivals[n.id];
if (root.history.indexOf(n) !== -1)
root.history = root.history.filter(x => x !== n);
@@ -0,0 +1,45 @@
pragma Singleton
import Quickshell
import Quickshell.Hyprland
import QtQuick
import "../modules/osd/OsdModel.js" as OsdModel
Singleton {
id: root
property bool active: false
property string monitorName: ""
property var state: OsdModel.messageState("dialog-information-symbolic", "", 1400)
function present(next: var): void {
root.state = next;
root.monitorName = Hyprland.focusedMonitor?.name ?? "";
root.active = true;
if (next.duration > 0) {
hideTimer.interval = next.duration;
hideTimer.restart();
} else {
hideTimer.stop();
}
}
function progress(kind: string, value: int, maximum: int, label: string): void {
root.present(OsdModel.progressState(kind, value, maximum, label, 1400));
}
function message(kind: string, label: string): void {
root.present(OsdModel.messageState(kind, label, 1400));
}
function hide(): void {
hideTimer.stop();
root.active = false;
}
Timer {
id: hideTimer
interval: 1400
onTriggered: root.active = false
}
}
@@ -0,0 +1,258 @@
pragma Singleton
// Snapshots of Panama's durable settings stores.
//
// DesktopPreferences and HomePreferences use separate files. The helper owns
// the transactional filesystem boundary; this service owns settling the live
// desktop after those files have changed underneath it.
//
// HomePreferences intentionally keeps its FileView in Quickshell's private
// state directory while snapshots use Panama's canonical state directory. This
// service bridges them through HomePreferences' public mutation API, then soft
// reloads once external consumers have settled.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-backup"
property var snapshots: []
property string lastError: ""
property string lastAction: ""
// Narrow service boundaries keep restore sequencing explicit and make it
// possible to verify the real handler in an isolated shell without ever
// calling the daily-driver compositor or wallpaper services.
property var readHomeState: function() {
return {
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites
};
}
property var resetHome: function() { HomePreferences.resetHomeDefaults(); }
property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
property var reloadDesktop: function() { DesktopPreferences.reload(); }
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
property var systemBusy: function() { return SystemSettings.busy; }
property var currentWallpaper: function() {
return String(DesktopPreferences.get("wallpaperPath") ?? "");
}
property var applyWallpaper: function(path) { Wallpaper.set(path); }
property var reloadShell: function() { Quickshell.reload(false); }
property var protectedDisplays: ({})
readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running
Process {
id: listQuery
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.snapshots = Array.isArray(parsed) ? parsed : [];
if (root.lastError === "Could not read the list of snapshots.")
root.lastError = "";
} catch (error) {
root.lastError = "Could not read the list of snapshots.";
}
}
}
}
Process {
id: actionRun
property bool restoring: false
property string outputText: ""
stdout: StdioCollector {
onStreamFinished: actionRun.outputText = this.text
}
onStarted: actionRun.outputText = ""
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.lastError = actionRun.restoring
? "That snapshot could not be restored."
: "The settings could not be backed up.";
if (actionRun.restoring) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
}
return;
}
root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring) {
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
root.lastError = homeReloaded
? ""
: "Desktop settings were restored, but Home favourites could not be reloaded.";
if (!homeReloaded) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
}
} else
root.lastError = "";
root.refresh();
}
}
Timer {
id: applyRestoredState
interval: 80
repeat: false
onTriggered: {
// DesktopPreferences.reload() invalidates reactive shell bindings.
// These services also own state outside QML and need an explicit
// replay: compositor options, Lua-generated binds, and hyprpaper.
root.applyCompositor();
root.reloadKeybinds();
root.applyWallpaper(root.currentWallpaper());
settleReload.attempts = 0;
settleReload.restart();
}
}
Timer {
id: settleReload
property int attempts: 0
interval: 100
repeat: true
onTriggered: {
attempts++;
// Let the current instances finish their external writes before a
// soft reload replaces them. The cap keeps a failed external tool
// from leaving restored Home state stale indefinitely.
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
stop();
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.reloadShell();
}
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (!listQuery.running)
listQuery.running = true;
}
function save(): void {
if (actionRun.running)
return;
actionRun.restoring = false;
actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);
}
function serialiseHomeState(): string {
const current = root.readHomeState();
const favorites = [];
for (const favorite of current.favorites ?? []) {
favorites.push({
id: String(favorite.id ?? ""),
alias: String(favorite.alias ?? "")
});
}
return JSON.stringify({
initialized: current.initialized === true,
favorites: favorites
});
}
function handleRestoreOutput(text: string): bool {
if (!root.reloadHomeState(text))
return false;
root.reloadDesktop();
if (!root.protectDisplays(root.protectedDisplays))
return false;
applyRestoredState.restart();
return true;
}
// Restore output carries the canonical Home state. Reconstructing through
// these methods keeps validation and persistence inside HomePreferences;
// this service never mutates its aliases or private FileView directly.
function reloadHomeState(text: string): bool {
try {
const result = JSON.parse(text);
const restored = result?.home;
if (!restored || restored.preserve === true)
return true;
if (restored.present !== true)
return restored.present === false
? root.resetHomeState()
: false;
const data = restored.data;
if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites))
return false;
const ids = [];
const aliases = [];
const seen = {};
for (const favorite of data.favorites) {
const id = favorite?.id;
const alias = favorite?.alias;
if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id)
|| typeof alias !== "string" || seen[id])
return false;
seen[id] = true;
ids.push(id);
aliases.push(alias);
}
if (!data.initialized && ids.length > 0)
return false;
root.resetHome();
if (!data.initialized)
return true;
root.initializeHome(ids);
for (let index = 0; index < ids.length; index++)
root.aliasHome(ids[index], aliases[index]);
return true;
} catch (error) {
return false;
}
}
function resetHomeState(): bool {
root.resetHome();
return true;
}
// The name is matched against the snapshot list rather than trusted, so no
// caller-supplied path reaches the helper even though it validates as well.
function restore(name: string): bool {
if (actionRun.running)
return false;
if (root.displayBusy()) {
root.lastError = "Finish the current display change before restoring settings.";
return false;
}
if (!root.snapshots.some(snapshot => snapshot.name === name)) {
root.lastError = "That snapshot is not in the list.";
return false;
}
const currentDisplays = root.readDisplays();
root.protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.setDisplayBlocked(true);
actionRun.restoring = true;
actionRun.exec([root.helperPath, "restore", name]);
return true;
}
}
@@ -0,0 +1,106 @@
pragma Singleton
// Search across every setting, not just page names.
//
// The sidebar's field used to filter the twelve page labels, so "gaps",
// "wallpaper", and "repeat delay" all found nothing — which is precisely the
// thing that makes a settings app feel smaller than it is. This indexes the
// schema itself, so any setting is reachable by typing what it does, and a new
// schema entry becomes searchable with no change here.
//
// Shortcuts are indexed too: "screenshot" should find the key that takes one.
import Quickshell
import QtQuick
import qs.config
Singleton {
id: root
// Which page shows the settings in a given schema group. A group with no
// entry here still appears in results and routes to Home rather than being
// dropped, so adding a group can never make a setting unreachable.
readonly property var groupPages: ({
"clock": "appearance",
"vitals": "appearance",
"windows": "appearance",
"effects": "appearance",
"wallpaper": "appearance",
"dock": "desktop",
"focus": "desktop",
"display": "displays",
"nightLight": "displays",
"idle": "power",
"accessibility": "accessibility",
"input": "shortcuts",
"weather": "appearance",
"notifications": "notifications",
"capture": "screen-intelligence"
})
// Settings that are real but have no schema entry, because the system owns
// them rather than Panama. Without these, searching "timezone" would fail
// on a settings app that plainly has one.
readonly property var extraEntries: [
{ label: "Timezone", detail: "Set the system timezone", page: "datetime" },
{ label: "Network time", detail: "Synchronise the clock with a time server", page: "datetime" },
{ label: "Wi-Fi", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Bluetooth", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" }
]
function pageFor(group: string): string {
return root.groupPages[group] ?? "home";
}
// [{ label, detail, page, kind }] for a query. Empty query yields nothing:
// the sidebar shows its normal navigation in that case.
function search(query: string): var {
const needle = String(query).trim().toLowerCase();
if (needle === "")
return [];
const results = [];
const seen = {};
function add(label, detail, page, kind) {
const dedupe = `${kind}:${label}:${page}`;
if (seen[dedupe])
return;
seen[dedupe] = true;
results.push({ label: label, detail: detail, page: page, kind: kind });
}
for (const entry of PreferenceSchema.entries) {
if (entry.internal)
continue;
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group}`.toLowerCase();
if (haystack.indexOf(needle) >= 0)
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
}
for (const entry of root.extraEntries) {
if (`${entry.label} ${entry.detail}`.toLowerCase().indexOf(needle) >= 0)
add(entry.label, entry.detail, entry.page, "setting");
}
for (const bind of Keybinds.binds) {
if (bind.description.toLowerCase().indexOf(needle) >= 0)
add(bind.description, bind.chord, "shortcuts", "shortcut");
}
// Exact prefix matches first: typing "blur" should put "Blur" above
// "Blur radius", and both above a setting that merely mentions blur in
// its explanation.
return results.sort((a, b) => {
const al = a.label.toLowerCase();
const bl = b.label.toLowerCase();
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
const bp = bl === needle ? 0 : (bl.indexOf(needle) === 0 ? 1 : 2);
return ap !== bp ? ap - bp : al.localeCompare(bl);
}).slice(0, 40);
}
}
@@ -92,7 +92,7 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;
@@ -0,0 +1,97 @@
pragma Singleton
// GNOME and GTK applications already honour these desktop sound preferences.
// Panama controls the same durable keys so moving between sessions does not
// create two competing notions of whether event feedback is enabled.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property bool eventSounds: true
property bool inputFeedback: false
property string lastError: ""
readonly property bool busy: eventRead.running || inputRead.running
|| eventWrite.running || inputWrite.running
function parsedBoolean(text: string, fallback: bool): bool {
const value = text.trim();
if (value === "true")
return true;
if (value === "false")
return false;
return fallback;
}
function refresh(): void {
if (!eventRead.running)
eventRead.running = true;
if (!inputRead.running)
inputRead.running = true;
}
function setEventSounds(enabled: bool): void {
root.eventSounds = enabled;
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)];
eventWrite.running = true;
}
function setInputFeedback(enabled: bool): void {
root.inputFeedback = enabled;
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)];
inputWrite.running = true;
}
Process {
id: eventRead
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
stdout: StdioCollector {
onStreamFinished: root.eventSounds = root.parsedBoolean(this.text, root.eventSounds)
}
onExited: (code, status) => {
if (code !== 0)
root.lastError = "Event sound preferences could not be read.";
}
}
Process {
id: inputRead
command: ["gsettings", "get", "org.gnome.desktop.sound", "input-feedback-sounds"]
stdout: StdioCollector {
onStreamFinished: root.inputFeedback = root.parsedBoolean(this.text, root.inputFeedback)
}
onExited: (code, status) => {
if (code !== 0)
root.lastError = "Input feedback preferences could not be read.";
}
}
Process {
id: eventWrite
onExited: (code, status) => {
if (code !== 0) {
root.lastError = "Event sound preferences could not be changed.";
root.refresh();
} else {
root.lastError = "";
}
}
}
Process {
id: inputWrite
onExited: (code, status) => {
if (code !== 0) {
root.lastError = "Input feedback preferences could not be changed.";
root.refresh();
} else {
root.lastError = "";
}
}
}
Component.onCompleted: root.refresh()
}
@@ -33,6 +33,16 @@ Singleton {
property string quickshellVersion: "0.3.0"
property string lastError: ""
// Explicit seams keep reset sequencing testable without changing the live
// keymap, wallpaper, or display from an isolated contract harness.
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
property var applyWallpaper: function(path) { Wallpaper.set(path); }
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
@@ -196,6 +206,11 @@ Singleton {
}
// ── Applying options ────────────────────────────────────────────────────
// Test harnesses may replace the external compositor boundary while still
// exercising validation, commit routing, persistence, and reset replay.
// Production leaves this unset and always uses the verified Hyprland path.
property var compositorApplyOverride: null
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
// The whole batch is validated before anything is sent, so one bad value
// rejects the batch rather than half-applying it.
@@ -216,14 +231,41 @@ Singleton {
}
if (Object.keys(requested).length === 0)
return false;
if (root.compositorApplyOverride !== null)
return root.compositorApplyOverride(requested);
// A write in flight is queued rather than refused. Options are applied
// and verified one batch at a time, but the callers are a settings UI
// and a startup replay of every compositor-backed preference -- they
// overlap routinely, and dropping a change on the floor would leave the
// stored value and the compositor disagreeing. Later values for the
// same key win.
if (configWrite.running || configVerify.running) {
root.lastError = "Another change is still being applied.";
return false;
root.queued = Object.assign({}, root.queued, requested);
return true;
}
root.startWrite(requested);
return true;
}
// Merged batches waiting for the current write to finish.
property var queued: ({})
function startWrite(requested: var): void {
configWrite.pending = requested;
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
return true;
}
// Called when a write settles, however it settled. A failed batch must not
// strand whatever queued up behind it.
function drainQueue(): void {
const next = root.queued;
if (Object.keys(next).length === 0)
return;
root.queued = ({});
root.startWrite(next);
}
// The value as Hyprland stores it. Several options are a toggle in the UI
@@ -308,6 +350,7 @@ Singleton {
}
root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
root.drainQueue();
}
function matchesObserved(entry: var, value: var, answer: var): bool {
@@ -334,6 +377,89 @@ Singleton {
function reportWriteFailure(requested: var, text: string): void {
const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label);
root.lastError = `Hyprland rejected ${labels.join(" and ")}.`;
root.drainQueue();
}
// The one entry point the settings UI uses to change any preference.
//
// A compositor-backed setting must be applied and verified before it is
// stored, so that preferences never claim a value Hyprland refused.
// Everything else is a direct write. Rows bind a schema key and call this;
// they never need to know which kind they are holding.
function commitPreference(key: string, value: var): bool {
const entry = PreferenceSchema.spec(key);
if (!entry) {
root.lastError = "That setting is not part of Panama.";
return false;
}
if (entry.hypr) {
const batch = {};
batch[key] = value;
return root.applyOptions(batch);
}
return DesktopPreferences.set(key, value);
}
// Restores shipped defaults across every store Panama owns, not just the
// schema. Panama keeps user state in more than one file -- the schema store,
// the focus session, and the Home accessory arrangement -- and a reset that
// silently skipped one would be worse than no reset at all.
//
// Compositor-backed values are re-applied afterwards, since resetting the
// stored value does not by itself tell Hyprland anything.
function restoreDefaults(): bool {
if (root.displayBusy()) {
root.lastError = "Finish the current display change before restoring defaults.";
return false;
}
const currentDisplays = root.readDisplays();
const protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.setDisplayBlocked(true);
DesktopPreferences.resetDesktopDefaults();
if (!root.protectDisplays(protectedDisplays)) {
root.setDisplayBlocked(false);
root.lastError = "The current display setting could not be protected during reset.";
return false;
}
// Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised
// favourites list behind while claiming to restore Panama's defaults.
//
// HomePreferences owns the write-through boundary so the state file is
// rewritten before this reset can be considered complete.
HomePreferences.resetHomeDefaults();
resettleTimer.restart();
return true;
}
Timer {
id: resettleTimer
interval: 60
onTriggered: {
root.applyPersistedDisplayPolicy();
root.reloadKeybinds();
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
resetRelease.attempts = 0;
resetRelease.restart();
}
}
Timer {
id: resetRelease
property int attempts: 0
interval: 100
repeat: true
onTriggered: {
attempts++;
if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) {
stop();
root.setDisplayBlocked(false);
}
}
}
function setAutoHdr(enabled: bool): void {
@@ -359,9 +485,15 @@ Singleton {
}
function isGnomePanelAllowed(panel: string): bool {
// Verified against `gnome-control-center --list` on this system. A name
// that panel list does not contain opens nothing and reports an error,
// so guessing one here would be a silently dead button.
return [
"wifi", "network", "bluetooth", "sound", "power", "printers",
"online-accounts", "users", "mouse", "keyboard", "sharing"
"applications", "background", "bluetooth", "color", "display",
"keyboard", "mouse", "multitasking", "network", "notifications",
"online-accounts", "power", "printers", "privacy", "search",
"sharing", "sound", "system", "universal-access", "wacom",
"wellbeing", "wifi", "wwan"
].indexOf(panel) >= 0;
}
@@ -0,0 +1,173 @@
pragma Singleton
// The desktop background.
//
// hyprpaper owns the actual painting; this owns choosing. Two things are worth
// knowing about hyprpaper 0.8:
//
// * Its IPC is much smaller than the documentation for older versions
// suggests. `wallpaper <output>,<path>` and `listactive` work; `preload`,
// `listloaded`, `unload`, and `reload` all answer "invalid hyprpaper
// request". So there is no preload step -- setting is a single call.
// * hyprpaper.conf lives in the Panama repo via the ~/.config/hypr symlink,
// so it cannot be rewritten at runtime without dirtying a tracked file.
// The chosen wallpaper therefore lives in the shared settings store like
// every other preference, and is re-applied when the shell starts.
//
// The argument is "<output>,<path>", so a path containing a comma would be
// parsed as a different request. The schema's pattern rejects those, and the
// value is passed as a single argv element rather than through a shell.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// Absolute paths of candidate images, newest first.
property var available: []
property string active: ""
property string lastError: ""
property bool scanning: false
readonly property string configured: DesktopPreferences.get("wallpaperPath")
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
// Directories searched for wallpapers, in order. Screenshots are
// deliberately excluded: a folder of 300 screenshots is not a wallpaper
// picker, and including it made the grid useless on this machine.
readonly property var searchRoots: [
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
`${Quickshell.env("HOME")}/.local/share/backgrounds`,
"/usr/share/backgrounds"
]
Process {
id: scan
// -print0 would be safer against odd filenames, but the schema already
// rejects paths containing commas or newlines, and this list is only
// ever offered as candidates -- the value that gets stored is validated
// again on the way in.
command: ["bash", "-lc",
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
stdout: StdioCollector {
onStreamFinished: {
const paths = this.text.split("\n").map(line => line.trim()).filter(line => line.length > 0);
root.available = paths;
root.scanning = false;
}
}
}
Process {
id: activeQuery
command: ["hyprctl", "hyprpaper", "listactive"]
stdout: StdioCollector {
onStreamFinished: {
// "DP-2: /path/to/image.jpg", one line per output.
const first = this.text.split("\n").find(line => line.indexOf(":") > 0);
root.active = first ? first.slice(first.indexOf(":") + 1).trim() : "";
}
}
}
// hyprpaper requires an explicit output name: the "<empty>,<path>" form that
// older versions accepted as "all outputs" is silently ignored by 0.8, so a
// wallpaper set that way appears to succeed and never changes. Outputs are
// therefore walked one at a time.
Process {
id: apply
property string requested: ""
property string storedValue: ""
property var remaining: []
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.lastError = "hyprpaper could not load that image.";
apply.remaining = [];
return;
}
if (apply.remaining.length > 0) {
const next = apply.remaining[0];
apply.remaining = apply.remaining.slice(1);
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${next},${apply.requested}`]);
return;
}
root.lastError = "";
DesktopPreferences.set("wallpaperPath", apply.storedValue);
root.refreshActive();
}
}
Component.onCompleted: {
root.rescan();
root.refreshActive();
restore.restart();
}
// hyprpaper is started by the compositor's autostart, so it may not be
// listening yet when the shell comes up. Re-applying the stored choice
// after a short delay makes the wallpaper survive a reboot without needing
// hyprpaper.conf to know about it.
Timer {
id: restore
interval: 1500
onTriggered: {
const stored = root.configured;
if (stored !== "" && stored !== root.active)
root.set(stored);
}
}
function rescan(): void {
if (scan.running)
return;
root.scanning = true;
scan.running = true;
}
function refreshActive(): void {
if (!activeQuery.running)
activeQuery.running = true;
}
// Applies to every connected output. Returns false when the path is not one
// the schema will accept, so a caller can report the refusal.
function set(path: string): bool {
const effectivePath = path === "" ? root.shippedPath : path;
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
root.lastError = "That file path cannot be used as a wallpaper.";
return false;
}
if (apply.running)
return false;
apply.requested = effectivePath;
apply.storedValue = path;
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
if (outputs.length === 0) {
root.lastError = "No display to set a wallpaper on.";
return false;
}
apply.remaining = outputs.slice(1);
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
return true;
}
// The display name for a path: the file's own name, without extension,
// with separators turned into spaces.
function titleFor(path: string): string {
const file = String(path).split("/").pop();
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
}
}
@@ -0,0 +1,102 @@
// Isolated behavioral harness for SettingsBackup's live restore handoff.
// Every external consumer is replaced before restore output is exercised, so
// this file never writes the real compositor, wallpaper, keymap, or shell.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
id: root
property var calls: []
property bool homeInitialized: false
property var homeFavorites: []
property bool displayOperationBusy: false
property bool displayBlocked: false
property var displayGeneration: ({ "DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0 } })
function record(name: string): void {
const next = root.calls.slice();
next.push(name);
root.calls = next;
}
Component.onCompleted: {
SettingsBackup.readHomeState = function() {
return {
initialized: root.homeInitialized,
favorites: root.homeFavorites
};
};
SettingsBackup.resetHome = function() {
root.record("home.reset");
root.homeInitialized = false;
root.homeFavorites = [];
};
SettingsBackup.initializeHome = function(ids) {
root.record("home.initialize:" + ids.join(","));
root.homeInitialized = true;
root.homeFavorites = ids.map(id => ({ id: id, alias: "" }));
};
SettingsBackup.aliasHome = function(id, alias) {
root.record("home.alias:" + id + "=" + alias);
root.homeFavorites = root.homeFavorites.map(favorite =>
favorite.id === id ? { id: id, alias: alias } : favorite);
};
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
SettingsBackup.protectDisplays = function(value) {
root.record("display.protect:" + JSON.stringify(value));
root.displayGeneration = value;
return true;
};
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
SettingsBackup.setDisplayBlocked = function(blocked) {
root.record("display.block:" + blocked);
root.displayBlocked = blocked;
};
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
SettingsBackup.keybindsReloading = function() { return false; };
SettingsBackup.systemBusy = function() { return false; };
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
}
IpcHandler {
target: "settings-backup-behavior"
function reset(): void {
root.calls = [];
root.homeInitialized = false;
root.homeFavorites = [];
root.displayOperationBusy = false;
root.displayBlocked = false;
SettingsBackup.protectedDisplays = root.displayGeneration;
}
function apply(output: string): bool {
return SettingsBackup.handleRestoreOutput(output);
}
function restoreWhileDisplayBusy(): bool {
root.displayOperationBusy = true;
SettingsBackup.snapshots = [{ name: "settings-20260818-010203004.json" }];
return SettingsBackup.restore("settings-20260818-010203004.json");
}
function status(): string {
return JSON.stringify({
calls: root.calls,
initialized: root.homeInitialized,
favorites: root.homeFavorites,
displayBlocked: root.displayBlocked,
displays: root.displayGeneration,
lastError: SettingsBackup.lastError
});
}
}
}
@@ -0,0 +1,21 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
IpcHandler {
target: "settings-search-test"
function find(query: string): string {
const hits = SettingsSearch.search(query);
return JSON.stringify({
count: hits.length,
top: hits.length > 0 ? hits[0].label : "",
topPage: hits.length > 0 ? hits[0].page : "",
labels: hits.slice(0, 6).map(hit => hit.label)
});
}
}
}
@@ -6,6 +6,48 @@ import qs.config
import qs.services
ShellRoot {
id: root
property var resetCalls: []
property var appliedBatches: []
property bool displayBlocked: false
function recordReset(name: string): void {
const next = root.resetCalls.slice();
next.push(name);
root.resetCalls = next;
}
Component.onCompleted: {
// Keep compositor verification entirely inside the isolated harness.
// Production applyOptions is covered separately by the Hyprland write
// contract; this seam proves commit/reset routing without changing the
// desktop that is running the test.
if (Quickshell.env("PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR") === "1") {
SystemSettings.compositorApplyOverride = function(requested) {
const batches = root.appliedBatches.slice();
batches.push(requested);
root.appliedBatches = batches;
for (const key in requested)
DesktopPreferences.set(key, requested[key]);
return true;
};
}
SystemSettings.displayBusy = function() { return false; };
SystemSettings.readDisplays = function() { return DesktopPreferences.get("displays"); };
SystemSettings.protectDisplays = function(value) {
root.recordReset("display.protect");
return DesktopPreferences.set("displays", value);
};
SystemSettings.setDisplayBlocked = function(blocked) {
root.recordReset("display.block:" + blocked);
root.displayBlocked = blocked;
};
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
SystemSettings.keybindsReloading = function() { return false; };
SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); };
}
IpcHandler {
target: "settings-system-test"
@@ -29,6 +71,46 @@ ShellRoot {
return SystemSettings.applyOptions(JSON.parse(payload));
}
// The routing entry point the settings rows use: a compositor-backed
// key must be applied and verified before it is stored, a local one is
// written directly. The contract checks both halves.
function commit(key: string, payload: string): bool {
return SystemSettings.commitPreference(key, JSON.parse(payload));
}
function stored(key: string): string {
return JSON.stringify(DesktopPreferences.get(key));
}
function seedHome(): void {
HomePreferences.favorites = [{ id: "light.contract_probe", alias: "Probe" }];
HomePreferences.initialized = true;
}
function homeState(): string {
return JSON.stringify({
count: HomePreferences.favorites.length,
initialized: HomePreferences.initialized
});
}
function restoreDefaults(): bool {
root.resetCalls = [];
return SystemSettings.restoreDefaults();
}
function resetState(): string {
return JSON.stringify({
calls: root.resetCalls,
displayBlocked: root.displayBlocked,
appliedBatches: root.appliedBatches
});
}
function applyState(): string {
return JSON.stringify(root.appliedBatches);
}
function panelAllowed(panel: string): bool {
return SystemSettings.isGnomePanelAllowed(panel);
}
+67 -2
View File
@@ -38,6 +38,7 @@ import qs.modules.datemenu
import qs.modules.focus
import qs.modules.signals
import qs.modules.settings
import qs.modules.osd
// Clipboard and datemenu ship explicit qmldir manifests because they were
// added while this daily-driver shell was already running; that also keeps
@@ -72,6 +73,11 @@ ShellRoot {
SignalGlass {}
}
Variants {
model: Quickshell.screens
Osd {}
}
// ── Single-instance overlays ────────────────────────────────────────────
// These are always constructed but only *visible* when ShellState says so.
// They're cheap while hidden, and keeping them alive means opening the
@@ -108,7 +114,10 @@ ShellRoot {
IpcHandler {
target: "focus"
function start(): void { FocusSession.startDefault(); }
function start(): bool {
FocusSession.startDefault();
return FocusSession.active;
}
function reveal(): void { FocusSession.reveal(); }
function dismiss(): void { FocusSession.dismiss(); }
function pause(): void { FocusSession.pauseOrResume(); }
@@ -117,7 +126,12 @@ ShellRoot {
ShellState.openOverview(FocusSession.workspaceId);
FocusSession.dismiss();
}
function end(): void { FocusSession.end(false); }
function end(): bool {
if (!FocusSession.active)
return false;
FocusSession.end(false);
return !FocusSession.active;
}
function status(): string {
return JSON.stringify({
active: FocusSession.active,
@@ -194,6 +208,25 @@ ShellRoot {
}
}
IpcHandler {
target: "osd"
function progress(kind: string, value: int, maximum: int, label: string): void {
OsdState.progress(kind, value, maximum, label);
}
function message(kind: string, label: string): void {
OsdState.message(kind, label);
}
function hide(): void { OsdState.hide(); }
function status(): string {
return JSON.stringify({
active: OsdState.active,
monitorName: OsdState.monitorName,
state: OsdState.state
});
}
}
IpcHandler {
target: "activity"
@@ -232,6 +265,38 @@ ShellRoot {
}
}
// Small stateful controls used by launcher commands. Returning the state
// after mutation lets callers give accurate OSD feedback without keeping a
// second copy of service state in a shell script.
IpcHandler {
target: "caffeine"
function toggle(): bool {
Caffeine.toggle();
return Caffeine.enabled;
}
function status(): bool { return Caffeine.enabled; }
}
IpcHandler {
target: "night-light"
function toggle(): bool {
NightLight.toggleQuietly();
return NightLight.active;
}
function status(): bool { return NightLight.active; }
function settings(): string {
return JSON.stringify({
enabled: NightLight.enabled,
automatic: NightLight.automatic,
active: NightLight.active
});
}
function restore(enabled: bool, automatic: bool): bool {
NightLight.restore(enabled, automatic);
return NightLight.active;
}
}
IpcHandler {
target: "kdeconnect"
function fixture(name: string): void { KdeConnect.applyFixture(name); }
@@ -0,0 +1,34 @@
// Read-only contract harness for the Sound page. It instantiates every device
// row against the real PipeWire graph but exposes no mutating IPC methods.
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
import qs.services
import qs.modules.settings
ShellRoot {
SoundPage {
width: 760
height: 900
}
PwObjectTracker {
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
}
IpcHandler {
target: "sound-page-test"
function status(): string {
return JSON.stringify({
ready: Pipewire.ready,
outputs: AudioDevices.outputs.length,
inputs: AudioDevices.inputs.length,
defaultOutput: AudioDevices.label(AudioDevices.current(true)),
defaultInput: AudioDevices.label(AudioDevices.current(false))
});
}
}
}

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