Author SHA1 Message Date
Gabriel Brown 607acb0a2d Make About answer what fastfetch answers
About reported four component versions and, since this morning, a short
hardware summary. It now covers what someone actually wants from an
About page or a fastfetch run: operating system, model, hostname,
kernel, uptime, package counts, shell, locale, windowing system,
resolution, processor, graphics, memory, swap, and disk.

Rows are ordered the way fastfetch presents them -- what the system is,
then what is installed on it, then the hardware underneath -- and the
GPU rows are spliced in directly after Processor rather than appended,
because a graphics card listed after "Disk" reads as an afterthought.
Graphics is still joined from GraphicsDevices rather than read a second
time, so the two readouts cannot disagree.

Memory is total, not used. About is not a monitor: a "12.4 GiB used"
figure is stale before it finishes drawing, and the Home page's vitals
readout is where live numbers belong. Disk is the exception because free
space does not move while you look at it.

Package counting is why this whole helper runs on demand -- rpm -qa on a
full workstation is a few thousand lines and takes a moment. Swap and
package counts are omitted entirely when they are zero or the tools are
absent, rather than reported as "0" or "Unknown".

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:09:06 -04:00
Gabriel Brown 9b2fa80ab7 Drop the product branding: this is just Settings
It is the system settings app for this desktop, so it is named the way
one is. The window is "Settings", the titlebar is "Settings", the
wordmark above the search field is gone, the sidebar's last row is
"About", and the status line reads "Desktop is healthy".

Prose followed the same rule. Forty-odd strings explained what "Panama"
does -- "Panama never animates while idle", "Restore Panama defaults",
"Panama looks in ~/Pictures/Wallpapers" -- which is how a product
describes itself, not how a settings panel describes a setting. They now
say what happens. No user-visible "Panama" remains anywhere in the app.

Two consequences worth naming:

The SUPER+I shortcut's description is user-visible, because the
Shortcuts page is generated from it, so that is renamed too. Rebindings
are keyed by shipped chord rather than description, so no existing
override is orphaned by this.

Three contracts matched the window by title and one matched that
shortcut by description; all four are updated. There are no Hyprland
window rules keyed on the title, so nothing about the window's placement
changes.

The desktop entry is now Name=Settings, but the FILE keeps its
panama-settings name, as does the icon: the dock pins applications by
desktop id, and renaming the file would silently unpin it.
dock-pins-contract covers exactly that.

The dated design docs under docs/superpowers keep the old name. They are
a record of what was decided when, and editing them to agree with the
present would make them lie about the past.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:08:55 -04:00
Gabriel Brown 816ea68f9a Read DDC/CI from the AUX bus, not the EDID bus
With I2C access granted, the helper still found nothing: every connected
monitor was probed on the wrong bus.

A connector has two. The `ddc` symlink points at the classic I2C line
that carries EDID on HDMI and DVI. DisplayPort carries DDC/CI over the
AUX channel instead, which appears as a child directory of the
connector. Both exist on a DP connector and both resolve, so the wrong
choice looks entirely reasonable and simply finds no monitor: this
machine's DP-2 has ddc -> i2c-5, where ddcutil reports "No monitor
detected", while its AUX child i2c-9 answers VCP 0x10 immediately.

This is the guess the previous commit said was unverified, and it was
wrong in the way that mattered. Enumerating from sysfs is still right --
it gives the connector name Hyprland uses and skips empty connectors --
but it has to prefer the AUX child and fall back to the symlink.

The fixture now mirrors sysfs properly: /sys/class/drm/<connector> is a
SYMLINK to the real device directory, and `find` does not follow the
path it is given. A fixture built from plain directories passes whether
or not the code resolves the symlink first, which is a test that agrees
with itself rather than with the kernel.

Verified on hardware: the Kuycon P20 reports 100%, accepts 70 and
returns to 100, and the media keys move it through codex's OSD path.
Both bus-selection mistakes are now caught by the contract.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 09:51:55 -04:00
Gabriel Brown 3ff414fb1b Stop two contracts from driving each other's shell
settings-hyprland-write-contract failed in full suite runs and passed on
its own, reporting "a typed batch did not reach the compositor" with
every value at its default.

Both it and settings-commit-reset-contract drive the same harness file,
and Quickshell identifies an instance by its config path -- not by the
environment it was launched with. So when one run's instance has not
fully exited, the other's wait for `ipc show` is satisfied by that
instance's target, and the whole contract then talks to a shell it did
not start.

The two directions fail differently, and the second is the alarming one:

  The write contract lands on the isolated instance, whose compositor
  write seam is deliberately stubbed. Its writes go nowhere, which is
  exactly the symptom above.

  The commit/reset contract lands on the non-isolated instance and
  drives the DAILY DESKTOP's real compositor while believing it is
  isolated.

Both now refuse to start while another instance of that harness is
alive, and say which hazard they are avoiding rather than failing on an
assertion much later.

One trap worth naming, since it bit me writing this: `rg -c` prints
nothing at all when there are no matches, so an unguarded command
substitution yields "" and not "0" -- the first version of the guard
fired on a perfectly clean machine.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:43:59 -04:00
Gabriel Brown 0fd59c59e3 Answer GNOME's Search panel, and pin the settings navigation wiring
GNOME's Search panel configures which applications provide results in
gnome-shell's overview and which folders are indexed. gnome-shell does
not run here, so reimplementing those switches would store preferences
that change nothing. Under Panama searching is the launcher's job and
Vicinae carries its own preferences, so the Applications page says where
search lives and how to rebind the keys that open it, rather than
offering settings that would be inert or duplicated.

The nav contract is the more useful half. Adding a settings page means
editing four files, and missing any one of them fails quietly in a
different way: no sidebar row, a row that silently shows Home, a deep
link that redirects to Home, or -- worst -- a missing qmldir entry,
which makes the page "not a type" and takes the entire settings window
down with it. Nothing at runtime cross-checks the four. Having just
added four pages by hand, this checks them statically, and also fails on
a page file that exists but is unreachable.

Verified it catches a missing qmldir registration and a missing
allow-list entry.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:38:54 -04:00
Gabriel Brown b1f9891849 Add multitasking controls, and a light theme for the launcher
Two things, both closing gaps in work that was already reported done.

GNOME's Multitasking panel, in Hyprland's terms: tiling layout, split
behaviour, floating-window snapping, workspace wrap-around and
back-and-forth, whether applications may take focus, and whether the
pointer changes the active display. Hyprland creates and destroys
workspaces as you use them, so there is no fixed count to expose, and
the page says so rather than leaving a conspicuous absence.

The Desktop page described the first two of these as read-only facts --
"Layout: Tiling", "Workspace movement: Dynamic" -- which was never true.
Both are ordinary Hyprland options that happened to have no controls,
and TextRow's own documentation says a setting the user could reasonably
change does not belong in it. Schema defaults are Panama's shipped
values from looks.lua rather than Hyprland's own, so restoring defaults
returns the desktop to how it ships. 43 mapped options now, from 35.

The launcher had no light theme. vicinae.json already selected a theme
per system appearance, but both entries pointed at Moon, so choosing
light mode left the most frequently opened window on the desktop dark --
a hole in the light/dark work, not a missing feature. Day is authored
from the same palette as the kitty Day theme so the two cannot drift,
and link-dotfiles now installs every authored theme rather than only the
dark one, which is why the gap survived being noticed.

Its placeholder colour is not Tokyo Night Day's own: that measures
2.54:1 against the background, below the 3:1 floor for secondary text.
This is 3.25:1, the same value used for neovim's light comments.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:34:05 -04:00
Gabriel Brown 6026bf308b Add Region & Language, and answer "what am I running on" in About
More GNOME Settings parity.

Region & Language is new. The locale is localectl's, and Panama stores no
copy of it -- there is exactly one system locale, so a preference here
would be a second source of truth that drifts the moment anything else
changes it. Codes are resolved against iso-codes into "Portuguese
(Brazil)" the way GNOME does, with the code kept visible because it is
what actually gets written and someone choosing between two Spanish
variants needs to see it. Changing it is privileged and only applies to
programs started afterwards, so the page says a sign-out is needed
rather than claiming the new language is in use.

The service is called SystemLocale, not Locale: QML has a built-in
Locale value type that silently shadows a singleton of that name, and
every binding then reads properties off the wrong thing. The page
rendered empty with nothing but "cannot read property of undefined" to
explain it.

About now answers what GNOME's About answers -- model, processor,
memory, disk, OS, kernel, windowing system -- where before it listed
only Panama's own component versions. Graphics is joined from
GraphicsDevices rather than read again, because two readouts of the same
hardware are two things that can disagree. Placeholder DMI strings
("To Be Filled By O.E.M.") are filtered out, and unreadable facts are
omitted rather than shown as "Unknown".

The Fedora hand-off card was one row listing five subjects that opened
the network panel regardless. Naming a panel and then not opening it
reads as a broken button rather than a deliberate hand-off. Each subject
now opens the panel that owns it, and openGnomePanel takes an optional
subpage so "Users" reaches System's users page the way GNOME's own
desktop entry does, instead of dropping the user on System's front page.
Printers and online accounts are not repeated here; they stay with the
network hardware on Network & Devices.

One bug this surfaced, caught by the hyprland write contract: the Lua
config key and the hyprctl option name genuinely differ for tap to
click. hl.config wants input.touchpad.tap_to_click; getoption answers to
input:touchpad:tap-to-click. Either spelling used for both fails -- a
hyphen is not a Lua identifier, and the underscored name is not a known
option -- which is what the schema's two separate fields are for.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:30:21 -04:00
Gabriel Brown 14529c011f Add a Privacy & Security page
Continuing towards GNOME Settings parity. GNOME's Privacy panel covers
screen lock, camera and microphone access, file history, trash, and
device security; Panama had no equivalent page at all, despite already
tracking camera and microphone use for the bar indicator.

Device security is a new read-only readout: Secure Boot, TPM, disk
encryption, SELinux mode, and the firewall. None of these is a
preference -- they are set in firmware, at install time, or by system
policy, and a switch offering to change them would either fail or do
something far-reaching from a control that looks like every other
control. What it answers is "is this machine set up the way I think it
is", which otherwise takes five commands and root. Facts that cannot be
determined report Unknown rather than guessing, because a security
readout that quietly says "fine" when it failed to look is worse than
no readout.

File history and trash retention are deliberately NOT offered as
switches. They are GNOME preferences enforced by gsd-housekeeping, which
does not run in a Hyprland session -- verified, it is not running here.
Toggling them would store a preference, change nothing, and give no sign
of it. They are delegated to GNOME Settings by name instead.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:20:04 -04:00
Gabriel Brown 7378c18ab9 Merge remote-tracking branch 'origin/fix/osd-ddc-brightness' 2026-08-18 08:16:49 -04:00
Gabriel Brown 9ce7040b91 Add a Mouse & Touchpad page and make keyboard layout editable
Working towards parity with GNOME Settings, which splits pointing
devices into their own panel. Panama had pointer speed and focus-follows
buried under a page called "Input & Shortcuts", and had nothing at all
for scroll direction, acceleration profile, scroll speed, left-handed
buttons, or any touchpad setting -- all of which could only be changed
by editing hypr/input.lua by hand, which is the thing this app exists to
stop.

Every new mapping was read back off the running compositor rather than
assumed, and two were not what they look like: touchpad drag lock is an
int with three states, not a switch, and scroll factors are floats even
at their default of exactly 1. Getting either wrong makes every write to
that setting look rejected. The shape contract now covers 35 mapped
options, up from 23.

The touchpad card renders only when a touchpad is attached, which is
what InputDevices is for. On a desktop it would be worse than useless:
every switch on it would appear to work, because the preference is
stored and Hyprland accepts an option for a device class it has no
member of, so the settings would silently affect nothing.

Keyboard layout was read-only text, justified by a note saying changes
needed a compositor reload. That is not true in 0.56.2 -- setting
input:kb_variant through hl.config re-keymaps attached keyboards
immediately, verified by watching active_keymap on a real keyboard
change to "English (US, intl., with dead keys)" and back. So layout,
variant, and options are now real controls, joined by a TextEntryRow
that commits on Enter or focus loss rather than per keystroke, since
half a layout name is a valid string meaning something else.

Rejected input is shown as rejected rather than sanitised: these strings
are serialised into an hl.config payload, where stripping an unexpected
character would turn a typo into a different working setting.

Verified each new pointer option applies and reverts against the live
compositor. Schema, search, commit/reset, and system contracts pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:16:10 -04:00
Gabriel Brown 09e5f1ad6b Fix external monitor brightness OSD 2026-08-18 08:15:01 -04:00
Gabriel Brown 7541a45e77 Add external monitor brightness over DDC/CI
brightnessctl drives the kernel backlight class, which laptop panels
have and this desktop does not -- it reports only keyboard and NIC LEDs.
So BrightnessControl removed itself and there was no way to dim the
screen from Panama at all. DDC/CI is the channel the buttons on a
monitor's bezel drive, and it is the only brightness an external display
has. Both sources now render a row each, so a machine gets whichever it
actually has, or none.

Displays are enumerated from sysfs rather than `ddcutil detect`. The
kernel publishes the connector-to-bus mapping as
/sys/class/drm/<card>-<connector>/ddc along with whether anything is
plugged in, which beats parsing detect's undocumented brief output,
yields the connector name spelled exactly as Hyprland spells it, and
probes only connectors with a monitor attached -- one bus on this
machine rather than fourteen, where each empty bus costs a timeout.
No model name is read: Hyprland already knows what every output is
called, so the UI joins on the connector instead of keeping a second
source of truth that could disagree with the Displays page.

Writes are debounced, serial, and read back. Serial because DDC/CI has
no arbitration and two ddcutil processes on one bus interleave their
exchanges and both return garbage. Read back because a write is not a
promise: panels clamp to their own range, ignore values while waking
from standby, and drop writes that arrive too fast. Without the read the
slider would show what Panama asked for rather than what the monitor
did, which is the same class of lie as trusting `hyprctl keyword`.

Brightness is deliberately not a stored preference. The monitor
remembers it and the bezel buttons change it behind Panama's back, so
persisting it would mean restoring a value the panel had moved past.

The contract runs against fixtures with ddcutil stubbed and both sysfs
roots redirected, so it never touches a real monitor. Its fixture
reports a maximum of 200 rather than 100 on purpose -- at 100 the
scaling arithmetic is the identity and a helper that ignored the
reported maximum would pass everything. Verified it catches that, plus a
dropped connection-status filter and an unstripped connector prefix.

Not yet confirmed against hardware: this machine cannot open any I2C bus
yet. ddcutil's udev rule grants that through uaccess but only to devices
created after it was installed, so it needs one udevadm trigger. The
helper detects exactly that case and returns the command as its error
rather than reporting "no displays".

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:01:49 -04:00
Gabriel Brown 1a91d00f2c Stop two contracts from latching a broken desktop state
Both tests run against the live session and capture "what it was before"
so they can put it back. Neither checked that what they found was sane,
so one interrupted run poisoned every run after it -- and because each
subsequent run faithfully restored the bad value, the desktop stayed
broken while the failure looked like an ordinary flake.

displays-contract left the monitor at scale 1.25 after a failed revert.
The next run recorded 1.25 as the original and restored the desktop to
it. It now reads the shipped scale out of monitors.lua and refuses to
run when the live display disagrees. A failure to parse that value is
fatal rather than skipped, because silently skipping the check is how
the laundering happened in the first place.

focus-session-expiry kills and restarts the shell mid-session, so an
interrupted run leaves caffeine on with nothing left to turn it off. The
next `focus start` recorded "previously on", handed it back on expiry,
and failed the assertion that caffeine ends off -- identically, forever,
with the desktop unable to idle or lock the entire time. It now refuses
to start unless caffeine is already off, which is the only state in
which the test can tell "restored correctly" from "never released".

Both guards name the exact command to recover with. Verified each fires
on a dirty state and passes on a clean one; caffeine was found latched
on this machine and has been released.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:48:02 -04:00
Gabriel Brown 19063a3e02 Carry the colour scheme into terminals and the editor
The scheme switch already reached everything that reads
org.freedesktop.appearance -- GTK4, Qt6, Chromium, Electron -- because
ColorScheme.qml writes the gsettings key those all watch. Applications
carrying their own palettes did not follow, so choosing light mode left
the two windows actually used all day, kitty and neovim, still dark.

kitty: the 32 colours move out of kitty.conf into themes/, and kitty.conf
ends with `include current-theme.conf`. The generated file is machine
state rather than configuration, so it is gitignored and link-dotfiles
seeds it on install -- otherwise a fresh checkout starts by complaining
about a missing include. Running terminals are re-coloured in place over
their control sockets; a restart is not needed.

neovim: reads settings.json directly, since it neither watches the portal
nor keeps a socket open. Tokyo Night ships Day in the same family as
Moon, so light mode keeps the editor's identity instead of turning it
into a different-looking application. The existing readability overrides
were written against Moon and are now dark-only -- applied to Day they
would have put light grey on a light background, the same problem they
exist to fix, inverted. Light mode gets one override of its own:
tokyonight's shipped comment colour measures 2.54:1 against Day's
background, under the 3:1 floor for secondary text, so it is replaced
with 3.25:1 -- readable, still dimmer than Normal's 4.52:1.

An editor already open when the scheme flips re-applies on FocusGained,
which is cheap and fires exactly when the mismatch would be noticed.

Verified both directions: kitty re-coloured 4 live terminals, and neovim
starts as tokyonight-day with background=light and tokyonight-moon with
background=dark.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:47:51 -04:00
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
155 changed files with 9409 additions and 471 deletions
+3
View File
@@ -13,3 +13,6 @@
# Python helper bytecode is local runtime state. # Python helper bytecode is local runtime state.
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
# Generated from the colour scheme; machine state, not configuration.
/config/dot/kitty/current-theme.conf
+1 -1
View File
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
| Piece | What it is | | Piece | What it is |
|---|---| |---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README | | `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI | | `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed | | `config/dot/vicinae/` | Raycast-style launcher, themed |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) | | `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start | | `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
+1 -1
View File
@@ -36,7 +36,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Removable media | udiskie plus udisks notifications | Live | | Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live | | Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live | | Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live | | System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
## GNOME extension migration ## GNOME extension migration
+8 -8
View File
@@ -27,7 +27,7 @@ Don't "fix" them.
| File | Contents | | File | Contents |
|---|---| |---|---|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope | | `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file Panama Settings writes. See below | | `prefs.lua` | Reads the settings file the Settings app writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below | | `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision | | `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout | | `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
@@ -59,7 +59,7 @@ generated elsewhere:
`quickshell/scripts/panama-idle` regenerates the hypridle config from the `quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed; remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting The Settings app shows which of the two states you are in rather than presenting
controls that quietly do nothing. controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with: Remove the drop-in and go back to the shipped config with:
@@ -77,7 +77,7 @@ relationship is:
`prefs.get("key", <shipped value>)`, so the config still works standalone with `prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all. no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it. - **The JSON is the truth.** Hyprland and Quickshell both read it.
- **Panama Settings is the editor.** It writes the file *and* applies the change - **The Settings app is the editor.** It writes the file *and* applies the change
live, so nothing needs a reload and the two sides cannot drift apart. live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to To add an adjustable setting: add an entry to
@@ -95,7 +95,7 @@ accepts the config in each of those states.
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file. the Settings app without editing this file.
```lua ```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
@@ -118,7 +118,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row. Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so `tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently. this cannot regress silently.
@@ -275,7 +275,7 @@ The mental model is unchanged from Forge:
| `SUPER + V` | Clipboard history | | `SUPER + V` | Clipboard history |
| `SUPER + .` | Emoji picker | | `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings | | `SUPER + S` | Quick settings |
| `SUPER + I` | Panama Settings | | `SUPER + I` | Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session | | `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre | | `SUPER + B` | Notification centre |
| `SUPER + grave` | Workspace overview | | `SUPER + grave` | Workspace overview |
@@ -288,7 +288,7 @@ The mental model is unchanged from Forge:
### Apps ### Apps
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator · `SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME `E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
Settings remains searchable in Vicinae for hardware and account panels. Settings remains searchable in Vicinae for hardware and account panels.
## Calendar and notifications ## Calendar and notifications
@@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra
dashboard windows. dashboard windows.
Home shows the first four selected favourites at rest and every selected light Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites, when expanded. Use **Settings → Home & Phone** to choose favourites,
set Panama-only aliases, and arrange their order. Dragging a brightness control set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous power toggle leaves Home Assistant responsible for restoring its previous
+17 -14
View File
@@ -30,6 +30,9 @@ local sysmonitor = "flatpak run io.missioncenter.MissionCenter"
-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the -- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
-- window against the already-running server (started in autostart.lua). -- window against the already-running server (started in autostart.lua).
local launcher = "vicinae toggle" 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. -- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
@@ -77,7 +80,7 @@ bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" }) bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" }) bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" }) bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ──────────────────────────────────────────────────────────────── -- ── Launcher ────────────────────────────────────────────────────────────────
@@ -252,23 +255,23 @@ bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { descri
-- ── Media and volume ──────────────────────────────────────────────────────── -- ── Media and volume ────────────────────────────────────────────────────────
-- locked = true keeps these working on the lock screen, as they do in GNOME. -- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting. -- 6% steps match the GNOME volume-step setting.
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" }) bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" }) bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" }) bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" }) bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers. -- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" }) 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("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" }) bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" }) bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl 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("playerctl next"), { locked = true , description = "Next track" }) bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" }) bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" }) bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" }) bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" }) 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. -- Hardware keys GNOME mapped that have obvious equivalents.
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" }) bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
+10 -2
View File
@@ -29,7 +29,11 @@ hl.config({
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means -- Unfocused windows get no colour at all. The gradient only means
-- something if exactly one window on screen is wearing it. -- 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, resize_on_border = true,
@@ -115,7 +119,11 @@ hl.config({
disable_hyprland_logo = true, disable_hyprland_logo = true,
disable_splash_rendering = 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 -- Variable refresh rate. 3 = enable only for fullscreen windows whose
-- content type is "video" or "game" -- the tag rules.lua applies to -- content type is "video" or "game" -- the tag rules.lua applies to
+2 -32
View File
@@ -183,7 +183,6 @@ font_size 14.0
#: Cursor customization {{{ #: Cursor customization {{{
cursor #c8d3f5
#: Default cursor color. If set to the special value none the cursor #: Default cursor color. If set to the special value none the cursor
#: will be rendered with a "reverse video" effect. It's color will be #: will be rendered with a "reverse video" effect. It's color will be
@@ -193,7 +192,6 @@ cursor #c8d3f5
#: precedence. Also, the cursor colors are modified if the cell #: precedence. Also, the cursor colors are modified if the cell
#: background and foreground colors have very low contrast. #: background and foreground colors have very low contrast.
cursor_text_color #111325
#: The color of text under the cursor. If you want it rendered with #: The color of text under the cursor. If you want it rendered with
#: the background color of the cell underneath instead, use the #: the background color of the cell underneath instead, use the
@@ -312,7 +310,6 @@ cursor_shape block
#: robustly with the ever-changing sea of bugs that is Cocoa is too #: robustly with the ever-changing sea of bugs that is Cocoa is too
#: much effort. #: much effort.
url_color #4fd6be
# url_style curly # url_style curly
#: The color and style for highlighting URLs on mouse-over. url_style #: The color and style for highlighting URLs on mouse-over. url_style
@@ -734,12 +731,10 @@ window_border_width 1pt
#: placed centrally. A value of top-left means the padding will be #: placed centrally. A value of top-left means the padding will be
#: only at the bottom and right edges. #: only at the bottom and right edges.
active_border_color #82aaff
#: The color for the border of the active window. Set this to none to #: The color for the border of the active window. Set this to none to
#: not draw borders around the active window. #: not draw borders around the active window.
inactive_border_color #2f334d
#: The color for the border of inactive windows. #: The color for the border of inactive windows.
@@ -960,16 +955,11 @@ tab_powerline_style round
#: Template to use for active tabs. If not specified falls back to #: Template to use for active tabs. If not specified falls back to
#: tab_title_template. #: tab_title_template.
active_tab_foreground #1e2030
active_tab_background #82aaff
# active_tab_font_style bold-italic # active_tab_font_style bold-italic
inactive_tab_foreground #c8d3f5
inactive_tab_background #42465a
# inactive_tab_font_style normal # inactive_tab_font_style normal
#: Tab bar colors and styles. #: Tab bar colors and styles.
tab_bar_background #222436
#: Background color for the tab bar. Defaults to using the terminal #: Background color for the tab bar. Defaults to using the terminal
#: background color. #: background color.
@@ -983,8 +973,8 @@ tab_bar_margin_color none
#: Color scheme {{{ #: Color scheme {{{
foreground #c8d3f5
background #222436 include current-theme.conf
#: The foreground and background colors. #: The foreground and background colors.
@@ -1043,8 +1033,6 @@ dim_opacity 1
#: How much to dim text that has the DIM/FAINT attribute set. One #: How much to dim text that has the DIM/FAINT attribute set. One
#: means no dimming and zero means fully dimmed (i.e. invisible). #: means no dimming and zero means fully dimmed (i.e. invisible).
selection_foreground #2d3f76
selection_background #c8d3f5
#: The foreground and background colors for text selected with the #: The foreground and background colors for text selected with the
#: mouse. Setting both of these to none will cause a "reverse video" #: mouse. Setting both of these to none will cause a "reverse video"
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
#: dull and bright version, for the first 16 colors. You can set the #: dull and bright version, for the first 16 colors. You can set the
#: remaining 240 colors as color16 to color255. #: remaining 240 colors as color16 to color255.
color0 #1b1d2b
color8 #444a73
#: black #: black
color1 #ff757f
color9 #ff757f
#: red #: red
color2 #a5e8b5
color10 #a5e8b5
#: green #: green
color3 #ffc777
color11 #ffc777
#: yellow #: yellow
color4 #82aaff
color12 #82aaff
#: blue #: blue
color5 #c099ff
color13 #c099ff
#: magenta #: magenta
color6 #86e1fc
color14 #86e1fc
#: cyan #: cyan
color7 #828bb8
color15 #c8d3f5
#: white #: white
color16 #ff966c
color17 #c53b53
# mark1_foreground black # mark1_foreground black
@@ -0,0 +1,38 @@
# Tokyo Night Day — the palette's own light variant.
#
# The same hues as Moon at a different lightness, so a terminal in light mode
# still belongs to this desktop rather than looking like a different machine.
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
cursor #3760bf
cursor_text_color #e1e2e7
url_color #118c74
active_border_color #2e7de9
inactive_border_color #c4c8da
active_tab_foreground #e1e2e7
active_tab_background #2e7de9
inactive_tab_foreground #3760bf
inactive_tab_background #c4c8da
tab_bar_background #e1e2e7
foreground #3760bf
background #e1e2e7
selection_foreground #e1e2e7
selection_background #99a7df
color0 #e9e9ed
color8 #a1a6c5
color1 #f52a65
color9 #f52a65
color2 #587539
color10 #587539
color3 #8c6c3e
color11 #8c6c3e
color4 #2e7de9
color12 #2e7de9
color5 #9854f1
color13 #9854f1
color6 #007197
color14 #007197
color7 #6172b0
color15 #3760bf
color16 #b15c00
color17 #c64343
@@ -0,0 +1,42 @@
# Tokyo Night Moon — the dark theme Panama ships.
#
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
# includes current-theme.conf, which Panama generates from the colour scheme
# setting; that generated file is gitignored because it is machine state.
#
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
# remote control. Without it a scheme change would only reach terminals opened
# afterwards.
cursor #c8d3f5
cursor_text_color #111325
url_color #4fd6be
active_border_color #82aaff
inactive_border_color #2f334d
active_tab_foreground #1e2030
active_tab_background #82aaff
inactive_tab_foreground #c8d3f5
inactive_tab_background #42465a
tab_bar_background #222436
foreground #c8d3f5
background #222436
selection_foreground #2d3f76
selection_background #c8d3f5
color0 #1b1d2b
color8 #444a73
color1 #ff757f
color9 #ff757f
color2 #a5e8b5
color10 #a5e8b5
color3 #ffc777
color11 #ffc777
color4 #82aaff
color12 #82aaff
color5 #c099ff
color13 #c099ff
color6 #86e1fc
color14 #86e1fc
color7 #828bb8
color15 #c8d3f5
color16 #ff966c
color17 #c53b53
+3
View File
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
vim.fn.winrestview(view) vim.fn.winrestview(view)
end, end,
}) })
-- Follow the desktop's light/dark setting while running, not only at startup.
require("config.panama").watch()
+88
View File
@@ -0,0 +1,88 @@
-- Panama desktop integration.
--
-- Neovim is the one application here that neither reads the desktop portal nor
-- has a control socket open by default, so it reads the shared settings file
-- directly -- the same ~/.config/panama/settings.json that the shell and the
-- Hyprland config read.
--
-- Nothing here may raise. A missing or malformed settings file must cost the
-- user their colour scheme preference and nothing else; editing text is more
-- important than matching the desktop.
local M = {}
local function settings_path()
local config_home = os.getenv("XDG_CONFIG_HOME")
if config_home == nil or config_home == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
config_home = home .. "/.config"
end
return config_home .. "/panama/settings.json"
end
-- "dark" or "light". Defaults to dark, which is what Panama ships.
function M.color_scheme()
local path = settings_path()
if not path then
return "dark"
end
local ok, contents = pcall(function()
local file = io.open(path, "r")
if not file then
return nil
end
local text = file:read("*a")
file:close()
return text
end)
if not ok or not contents or contents == "" then
return "dark"
end
local decoded_ok, decoded = pcall(vim.json.decode, contents)
if not decoded_ok or type(decoded) ~= "table" then
return "dark"
end
return decoded.colorScheme == "light" and "light" or "dark"
end
function M.is_light()
return M.color_scheme() == "light"
end
-- tokyonight ships a light variant in the same family, so light mode stays the
-- same identity rather than becoming a different editor theme.
function M.tokyonight_style()
return M.is_light() and "day" or "moon"
end
-- Re-apply the scheme when the window regains focus.
--
-- Neovim reads the setting once at startup and has no control socket open by
-- default, so an editor already running when the desktop scheme flips would
-- otherwise stay on the old palette until it was restarted. FocusGained is
-- cheap, happens exactly when you would notice the mismatch, and does nothing
-- at all unless the scheme actually changed.
function M.watch()
local applied = M.color_scheme()
vim.api.nvim_create_autocmd("FocusGained", {
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
callback = function()
local current = M.color_scheme()
if current == applied then
return
end
applied = current
vim.o.background = current
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
end,
})
end
return M
+48 -11
View File
@@ -1,31 +1,68 @@
-- Tokyo Night, following the desktop's colour scheme.
--
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
-- so the editor keeps the identity the rest of the desktop has rather than
-- becoming a different-looking application when the scheme flips.
--
-- The readability fixes below are deliberately dark-only. They were written
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
-- applying them to Day would put light grey text on a light background, which
-- is exactly the legibility problem they exist to solve, inverted.
local panama = require("config.panama")
return { return {
{ {
"folke/tokyonight.nvim", "folke/tokyonight.nvim",
opts = { opts = function()
style = "moon", local light = panama.is_light()
return {
style = panama.tokyonight_style(),
light_style = "day",
transparent = true, transparent = true,
on_colors = function(colors) on_colors = function(colors)
if light then
-- Day's defaults are tuned for a light ground and mostly need no
-- help. Comments are the exception: the shipped #848cb5 measures
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
-- for secondary text. This is 3.25:1 -- readable, and still clearly
-- dimmer than Normal's 4.52:1 so it does not compete with code.
colors.comment = "#7079a8"
return
end
colors.comment = "#a0a7c5" colors.comment = "#a0a7c5"
colors.fg_gutter = "#787f93" colors.fg_gutter = "#787f93"
colors.terminal_black = "#828bb8" colors.terminal_black = "#828bb8"
end, end,
on_highlights = function(highlights, colors) on_highlights = function(highlights, colors)
-- Fix inline code visibility in markdown -- Inline code in markdown is invisible at both lightnesses without
-- an explicit background, because the theme leaves it unset.
highlights["@markup.raw.markdown_inline"] = { highlights["@markup.raw.markdown_inline"] = {
bg = colors.terminal_black, bg = light and colors.bg_highlight or colors.terminal_black,
fg = colors.fg, fg = colors.fg,
} }
highlights["RenderMarkdownCodeInline"] = { highlights["RenderMarkdownCodeInline"] = {
bg = colors.terminal_black, bg = light and colors.bg_highlight or colors.terminal_black,
fg = colors.fg, fg = colors.fg,
} }
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
-- imports, making text nearly invisible against LspReferenceText's background -- LspReference* readability: DiagnosticUnnecessary dims fg for unused
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg } -- imports, making text nearly invisible against LspReferenceText's
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg } -- background.
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg } local reference = {
bg = light and colors.bg_visual or colors.fg_gutter,
fg = colors.fg,
}
highlights["LspReferenceText"] = reference
highlights["LspReferenceRead"] = reference
highlights["LspReferenceWrite"] = reference
end,
}
end, end,
},
}, },
{ {
"LazyVim/LazyVim", "LazyVim/LazyVim",
@@ -257,6 +257,24 @@ Singleton {
detail: "XKB layout name, or a comma-separated list to switch between", detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" } hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
}, },
{
key: "keyboardVariant", type: "string", def: "", group: "input",
// Same shape as the layout list and for the same reason: this is
// serialised into an hl.config string.
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
label: "Layout variant",
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
},
{
key: "keyboardOptions", type: "string", def: "", group: "input",
// XKB option names are colon-separated pairs in a comma-separated
// list, e.g. "compose:ralt,caps:escape".
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
label: "Keyboard options",
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
},
{ {
key: "numlockByDefault", type: "bool", def: true, group: "input", key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login", label: "Num Lock on login",
@@ -310,6 +328,167 @@ Singleton {
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" } hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
}, },
// ── Pointer ─────────────────────────────────────────────────────────
//
// Every `readAs` below was read back off the running compositor rather
// than guessed. Two are not what they look like: touchpad drag lock is
// an int with three states, not a switch, and scroll factors are floats
// even at their default of exactly 1.
{
key: "naturalScroll", type: "bool", def: false, group: "pointer",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers, as on a phone",
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
},
{
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
label: "Acceleration",
detail: "Flat moves the pointer the same distance however fast you move",
options: [
{ value: "flat", label: "Flat" },
{ value: "adaptive", label: "Adaptive" }
],
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
},
{
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "pointer",
label: "Scroll speed",
detail: "Multiplies how far one notch of the wheel scrolls",
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
},
{
key: "leftHanded", type: "bool", def: false, group: "pointer",
label: "Left-handed",
detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
},
// ── Touchpad ────────────────────────────────────────────────────────
//
// Shown only on machines that have one. These are separate from the
// pointer settings above because libinput keeps them separate: a mouse
// and a touchpad on the same machine can scroll in opposite directions,
// and usually should.
{
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
label: "Tap to click",
detail: "A tap counts as a click without pressing down",
// The Lua config key and the hyprctl option name genuinely differ
// here: hl.config wants input.touchpad.tap_to_click, getoption
// answers to input:touchpad:tap-to-click. Using either spelling for
// both fails -- a hyphen is not a Lua identifier, and the
// underscored name is not a known option to getoption. This is what
// the two separate fields are for.
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
},
{
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers",
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
},
{
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
label: "Disable while typing",
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
},
{
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "touchpad",
label: "Scroll speed",
detail: "Multiplies how far a two-finger scroll travels",
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
},
{
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
label: "Drag lock",
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
// An int with three states rather than a switch, which is why this
// is an enum: reported as `int` by getoption, not `bool`.
options: [
{ value: 0, label: "Off" },
{ value: 1, label: "On" },
{ value: 2, label: "On, until you tap again" }
],
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
},
{
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
label: "Middle-click by pressing both buttons",
detail: "Pressing left and right together acts as a middle click",
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
// ── Multitasking ────────────────────────────────────────────────────
//
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
// described the first two of these as read-only facts ("Layout:
// Tiling"), which was never true -- they are ordinary settings that
// happened not to have controls.
//
// Defaults here are Panama's shipped values from hypr/looks.lua, not
// Hyprland's own, so restoring defaults returns the desktop to how it
// ships rather than to how Hyprland would behave with no config.
{
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
label: "Tiling layout",
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
options: [
{ value: "dwindle", label: "Dwindle" },
{ value: "master", label: "Master and stack" }
],
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
},
{
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
label: "Keep split direction",
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
},
{
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
label: "New windows open",
detail: "Where a new window lands relative to the one that was focused",
options: [
{ value: 0, label: "Where the pointer is" },
{ value: 1, label: "Always left or above" },
{ value: 2, label: "Always right or below" }
],
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
},
{
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
label: "Snap floating windows",
detail: "Floating windows stick to screen edges and to each other as you drag them",
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
},
{
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
label: "Switch back and forth",
detail: "Selecting the workspace you are already on returns you to the previous one",
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
},
{
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
label: "Wrap around at the ends",
detail: "Moving past the last workspace continues from the first",
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
},
{
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
label: "Let applications take focus",
detail: "An application asking for attention is switched to, rather than only highlighted",
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
},
{
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
label: "Pointer changes active display",
detail: "Moving the pointer to another display makes it the active one",
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
},
// ── Night light ───────────────────────────────────────────────────── // ── Night light ─────────────────────────────────────────────────────
{ {
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
@@ -369,6 +548,73 @@ Singleton {
detail: "Requires your password when the machine wakes" 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 ─────────────────────────────────────────────────── // ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and // Backed by gsettings so GTK applications agree with the shell, and
// pushed to the compositor as well where it has its own notion. // pushed to the compositor as well where it has its own notion.
@@ -402,6 +648,47 @@ Singleton {
detail: "How often Panama updates the current conditions" 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 ────────────────────────────────────────────────── // ── Vitals refresh ──────────────────────────────────────────────────
{ {
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500, key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
@@ -476,13 +763,18 @@ Singleton {
// A "json" value: the ordered list of desktop entry ids pinned to the // 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 // dock. Kept in the shared store so that reordering the dock is covered
// by Restore defaults like everything else, rather than living in its // by Restore defaults like everything else, rather than living in its
// own file. The shipped order is the GNOME dash it replaced. // 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", key: "dockPinned", type: "json", group: "dock",
label: "Pinned applications", label: "Pinned applications",
detail: "Applications that stay in the Dock whether or not they are running", detail: "Applications that stay in the Dock whether or not they are running",
def: [ def: [
"org.gnome.Settings", "kitty", "org.gnome.Nautilus", "panama-settings", "kitty", "org.gnome.Nautilus",
"com.bitwarden.desktop", "org.gnome.Software", "helium", "com.bitwarden.desktop", "org.gnome.Software", "helium",
"org.mozilla.thunderbird_esr", "com.slack.Slack", "org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk", "app.bluebubbles.BlueBubbles", "rustdesk",
@@ -521,6 +813,21 @@ Singleton {
detail: "Resolution, scale, and rotation per connected display" 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 ──────────────────────────────────────────────────────── // ── Internal ────────────────────────────────────────────────────────
{ {
key: "lastPage", type: "string", def: "home", group: "internal", key: "lastPage", type: "string", def: "home", group: "internal",
+6 -6
View File
@@ -22,12 +22,12 @@ Singleton {
// ── Weather ───────────────────────────────────────────────────────────── // ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already // Coordinates taken from the GNOME night-light setting, which had already
// resolved the location. Uses Open-Meteo, which needs no API key. // resolved the location. Uses Open-Meteo, which needs no API key.
readonly property real latitude: 27.7375 readonly property real latitude: DesktopPreferences.get("weatherLatitude")
readonly property real longitude: -82.6861 readonly property real longitude: DesktopPreferences.get("weatherLongitude")
// Open-Meteo returns coordinates but no friendly place name. Keep the // Open-Meteo returns coordinates but no friendly place name. Keep the
// label deliberately general rather than exposing precise coordinates in // label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them. // the UI or guessing at a city from them.
readonly property string weatherLocation: "Local weather" readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit") readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes") readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
@@ -41,13 +41,13 @@ Singleton {
// amdgpu exposes utilisation here. Verified present on this machine; the // amdgpu exposes utilisation here. Verified present on this machine; the
// widget hides itself if the path is missing rather than showing zeros. // 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 ───────────────────────────────────────────────────────── // ── Night light ─────────────────────────────────────────────────────────
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00. // Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature") readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
readonly property real nightLightFrom: 17.0 readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
readonly property real nightLightTo: 10.0 readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled") readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ─────────────────────────────────────────────────────── // ── Notifications ───────────────────────────────────────────────────────
+49 -32
View File
@@ -18,36 +18,47 @@ import QtQuick
Singleton { Singleton {
id: root 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 ───────────────────────────────────────────────────────────── // ── Palette ─────────────────────────────────────────────────────────────
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly. // Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
readonly property color bg: "#222436" readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
readonly property color bgDark: "#1e2030" readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
readonly property color bgHighlight: "#2f334d" readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
readonly property color bgPanel: "#2e2f3d" // dock glass surface readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
readonly property color bgPopover: "#21212f" // Openbar submenu background readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
readonly property color fg: "#c8d3f5" readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
readonly property color fgDim: "#828bb8" readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
readonly property color fgMuted: "#636da6" readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
readonly property color gutter: "#3b4261" readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
// The pair. `accent` is the primary and carries every state meaning // The pair. `accent` is the primary and carries every state meaning
// (focused, active, on). `accentSecondary` is the orchid from the tmux // (focused, active, on). `accentSecondary` is the orchid from the tmux
// theme — it never appears alone, only as the far end of a gradient. That // 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, // 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. // so the pink stops being special the moment it's used as a flat fill.
readonly property color accent: "#82aaff" // blue readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
readonly property color accentSecondary: "#b172b0" // orchid, from tmux readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
readonly property color accentAlt: "#65bcff" // blue1, a lighter blue readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
readonly property color cyan: "#86e1fc" readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
readonly property color teal: "#4fd6be" readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
readonly property color green: "#c3e88d" readonly property color green: root.dark ? "#c3e88d" : "#587539"
readonly property color yellow: "#ffc777" readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
readonly property color orange: "#ff966c" readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
readonly property color red: "#ff757f" readonly property color red: root.dark ? "#ff757f" : "#f52a65"
readonly property color redDeep: "#c53b53" readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
readonly property color magenta: "#c099ff" readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
readonly property color pink: "#fca7ea" readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
// Semantic aliases — prefer these in widgets so intent survives a repaint. // Semantic aliases — prefer these in widgets so intent survives a repaint.
readonly property color ok: green readonly property color ok: green
@@ -59,11 +70,14 @@ Singleton {
// The dock sits on compositor blur, so its fill is deliberately very // The dock sits on compositor blur, so its fill is deliberately very
// translucent. Popovers are near-opaque because they carry text that must // translucent. Popovers are near-opaque because they carry text that must
// stay legible. The bar itself has no surface or alpha token. // stay legible. The bar itself has no surface or alpha token.
readonly property real dockAlpha: 0.34 // Light surfaces need more opacity for the same sense of a solid panel: the
readonly property real popoverAlpha: 0.92 // same 0.34 that reads as glass over a dark desktop reads as haze over a
readonly property real overlayAlpha: 0.55 // light one, and text on it stops being legible.
readonly property real hoverAlpha: 0.14 readonly property real dockAlpha: root.dark ? 0.34 : 0.62
readonly property real activeAlpha: 0.24 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 ──────────────────────────────────────────────────────────── // ── Geometry ────────────────────────────────────────────────────────────
readonly property int barHeight: 36 readonly property int barHeight: 36
@@ -90,12 +104,12 @@ Singleton {
// Adwaita Sans for everything the user reads as text. No exceptions: a // Adwaita Sans for everything the user reads as text. No exceptions: a
// monospaced clock or percentage reads as a terminal readout pasted into a // monospaced clock or percentage reads as a terminal readout pasted into a
// UI, which is the opposite of the intent here. // 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 // Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
// pragmatic alternative to freedesktop symbolic icons, which ship with a // pragmatic alternative to freedesktop symbolic icons, which ship with a
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml). // hardcoded dark fill Qt will not 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, // Apply to any text whose digits change in place — clocks, percentages,
// elapsed timers, dimension readouts. Tabular figures all share one // elapsed timers, dimension readouts. Tabular figures all share one
@@ -107,10 +121,13 @@ Singleton {
readonly property var tabularFigures: ({ readonly property var tabularFigures: ({
"tnum": 1 "tnum": 1
}) })
readonly property int fontSize: 13 // The four sizes are a scale, not four independent numbers: they move
readonly property int fontSizeSmall: 11 // together so the relationship between body, caption, heading and title
readonly property int fontSizeLarge: 16 // survives a change to the base rather than drifting apart.
readonly property int fontSizeTitle: 20 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 ────────────────────────────────────────────────────────────── // ── Motion ──────────────────────────────────────────────────────────────
// Event-driven only. Nothing in this shell animates while idle — no pulse, // 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,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 });
}
}
}
+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
import Quickshell.Services.Pipewire import Quickshell.Services.Pipewire
import qs.config import qs.config
import qs.services
Item { Item {
id: root id: root
@@ -16,23 +17,12 @@ Item {
implicitHeight: list.implicitHeight implicitHeight: list.implicitHeight
readonly property var nodes: { readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs
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 current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource readonly property var current: AudioDevices.current(root.output)
function select(node): void { function select(node): void {
if (root.output) AudioDevices.select(root.output, node)
Pipewire.preferredDefaultAudioSink = node;
else
Pipewire.preferredDefaultAudioSource = node;
} }
ScrollColumn { ScrollColumn {
@@ -52,7 +42,7 @@ Item {
implicitHeight: 38 implicitHeight: 38
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic" icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-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 selected: nodeRow.modelData === root.current
onClicked: root.select(nodeRow.modelData) onClicked: root.select(nodeRow.modelData)
} }
@@ -1,24 +1,44 @@
// Backlight slider, via brightnessctl. // Brightness, from whichever source this machine actually has.
// //
// This machine drives an external DisplayPort monitor and has no backlight // Two exist and they are not interchangeable:
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the //
// row removes itself rather than sitting there as a dead control. Probed once // The kernel backlight class, driven by brightnessctl. Laptop panels have it;
// at startup — backlight devices do not appear and disappear. // this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
//
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
// only brightness an external display has, and it is per-monitor.
//
// A machine may have neither, either, or both, so this renders a row per source
// found and removes itself entirely when there are none, rather than sitting
// there as a dead control.
//
// Connector labels appear only when there is more than one row. A single
// slider needs no explanation of which screen it dims.
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import qs.widgets import qs.widgets
import qs.config import qs.config
import qs.services
Item { Item {
id: root id: root
property bool available: false property bool backlightAvailable: false
property real value: 0 property real backlightValue: 0
visible: root.available readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
implicitHeight: root.available ? 32 : 0 readonly property bool labelled: root.rowCount > 1
visible: root.rowCount > 0
implicitHeight: rows.implicitHeight
// Probing I2C takes on the order of a second, so it waits until the panel
// is actually on screen rather than running at shell startup. Monitors do
// not come and go, so once is enough.
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
// `-m` is the machine-readable form: name,class,current,percent,max // `-m` is the machine-readable form: name,class,current,percent,max
Process { Process {
@@ -34,18 +54,85 @@ Item {
const fields = line.split(","); const fields = line.split(",");
if (fields.length < 5 || fields[1] !== "backlight") if (fields.length < 5 || fields[1] !== "backlight")
continue; continue;
root.available = true; root.backlightAvailable = true;
root.value = parseInt(fields[3]) / 100; root.backlightValue = parseInt(fields[3]) / 100;
return; return;
} }
} }
function apply(v: real): void { function applyBacklight(v: real): void {
root.value = v; root.backlightValue = v;
// Never go fully dark: a 0% backlight looks like a broken shell. // Never go fully dark: a 0% backlight looks like a broken shell.
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]); Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
} }
Column {
id: rows
anchors.left: parent.left
anchors.right: parent.right
spacing: 4
BrightnessRow {
width: rows.width
visible: root.backlightAvailable
label: "Built-in"
value: root.backlightValue
onMoved: v => root.applyBacklight(v)
}
Repeater {
model: Brightness.displays
BrightnessRow {
required property var modelData
width: rows.width
// Hyprland already knows what each output is called, so the
// name comes from there rather than from a second source that
// could disagree with the Displays page. The connector is the
// fallback, so a display is never an unlabelled slider.
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
value: modelData.value / 100
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
}
}
}
component BrightnessRow: Item {
id: row
property string label: ""
property real value: 0
signal moved(real value)
implicitHeight: caption.height + control.height
Text {
id: caption
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 6
anchors.rightMargin: 6
anchors.top: parent.top
visible: root.labelled
height: visible ? implicitHeight + 2 : 0
text: row.label
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
// The slider and its glyph share one strip so the two stay aligned
// whether or not a caption sits above them. Anchoring the glyph to
// both a caption and a centre line instead would conflict, and an
// anchor set to undefined is not released.
Item {
id: control
anchors.left: parent.left
anchors.right: parent.right
anchors.top: caption.bottom
height: 32
// ValueSlider draws its own leading icon, but symbolic icons need // ValueSlider draws its own leading icon, but symbolic icons need
// recolouring to be visible — see ThemedIcon. // recolouring to be visible — see ThemedIcon.
ThemedIcon { ThemedIcon {
@@ -63,7 +150,9 @@ Item {
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 32 anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
value: root.value value: row.value
onMoved: v => root.apply(v) onMoved: v => row.moved(v)
}
}
} }
} }
@@ -4,11 +4,20 @@ import qs.config
import qs.services import qs.services
SettingsPage { SettingsPage {
title: "About Panama" id: root
title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste." lede: "A curated Hyprland desktop built around focus, speed, and good taste."
Component.onCompleted: {
if (!MachineInfo.scanned)
MachineInfo.refresh();
if (GraphicsDevices.devices.length === 0)
GraphicsDevices.refresh();
}
SettingsCard { SettingsCard {
title: "Panama Desktop" title: "Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling" subtitle: "Tokyo Night Moon · Prism glass · native tiling"
TextRow { TextRow {
@@ -31,6 +40,59 @@ SettingsPage {
} }
} }
// What GNOME's About panel answers and this page did not: what am I running
// on. Rows come from MachineInfo, except graphics, which is joined from
// GraphicsDevices rather than read a second time -- two readouts of the same
// hardware are two things that can disagree.
//
// The join splices the GPU rows in directly after Processor rather than
// appending them, so the card reads the way fastfetch does: what the system
// is, then what is installed on it, then the hardware underneath. A GPU
// listed after "Disk" reads as an afterthought.
readonly property var machineRows: {
const rows = (MachineInfo.facts ?? []).slice();
const gpus = GraphicsDevices.devices ?? [];
if (gpus.length === 0)
return rows;
const graphics = gpus.map((device, index) => ({
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
value: device.name
}));
const after = rows.findIndex(row => row.label === "Processor");
if (after < 0)
return rows.concat(graphics);
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
}
SettingsCard {
title: "This machine"
subtitle: "Hardware and system, as the kernel reports it."
Repeater {
model: root.machineRows
TextRow {
id: machineRow
required property var modelData
required property int index
label: machineRow.modelData.label
value: machineRow.modelData.value
divider: machineRow.index < root.machineRows.length - 1
}
}
TextRow {
visible: MachineInfo.scanned && root.machineRows.length === 0
label: "Hardware"
detail: "The system did not report anything readable"
value: "Unavailable"
divider: false
}
}
SettingsCard { SettingsCard {
title: "Design principles" title: "Design principles"
@@ -25,14 +25,14 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Text" title: "Text"
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected." subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
SliderRow { setting: "textScale"; divider: false } SliderRow { setting: "textScale"; divider: false }
} }
SettingsCard { SettingsCard {
title: "Motion" title: "Motion"
subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing." subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
ToggleRow { setting: "animationsEnabled"; divider: false } ToggleRow { setting: "animationsEnabled"; divider: false }
} }
@@ -46,12 +46,21 @@ SettingsPage {
title: "Background" title: "Background"
subtitle: Wallpaper.lastError !== "" subtitle: Wallpaper.lastError !== ""
? Wallpaper.lastError ? Wallpaper.lastError
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." : "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperPicker { WallpaperPicker {
id: wallpapers
width: parent.width 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 { ActionRow {
label: "Look for new images" label: "Look for new images"
detail: Wallpaper.scanning detail: Wallpaper.scanning
@@ -64,6 +73,56 @@ SettingsPage {
} }
} }
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 { SettingsCard {
title: "Windows" title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
@@ -103,12 +162,29 @@ SettingsPage {
ToggleRow { setting: "showCpu" } ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" } ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: false } 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 { SettingsCard {
title: "Theme" 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." subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" } TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false } TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
@@ -142,6 +142,40 @@ SettingsPage {
} }
} }
// GNOME's Search panel, answered honestly.
//
// It configures which applications provide results in gnome-shell's
// overview and which folders are indexed. gnome-shell does not run here,
// so those settings would do nothing. Under Panama searching is the
// launcher's job, and Vicinae carries its own preferences -- reimplementing
// them here would give two places to change one thing.
SettingsCard {
title: "Search"
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
TextRow {
label: "Launcher"
detail: SystemSettings.vicinaeActive
? "Running as a user service"
: "Not running — Super+Shift+R opens the fallback launcher"
value: "Vicinae"
}
TextRow {
label: "Open search"
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
value: "Super+Space"
}
ActionRow {
label: "Change these shortcuts"
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
action: "Open keyboard"
divider: false
onTriggered: ShellState.openSettings("shortcuts")
}
}
SettingsCard { SettingsCard {
title: "User autostart" title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it." subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
@@ -175,7 +209,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Compositor autostart" title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here." subtitle: "These are started from the Hyprland configuration. They are read-only here."
TextRow { TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0 visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
@@ -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
}
}
@@ -1,92 +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 QtQuick
import Quickshell
import Quickshell.Networking import Quickshell.Networking
import Quickshell.Bluetooth
import qs.config import qs.config
import qs.services import qs.services
import qs.modules.quicksettings
SettingsPage { SettingsPage {
id: root id: root
title: "Network & Devices" title: "Network & Devices"
lede: "Connect graphically—no terminal workflow required." lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
readonly property var wifiDevice: { // Drive the scanners only while this page is the one being shown.
for (const device of Networking.devices.values) { Component.onCompleted: Connectivity.active = true
if (device.type === DeviceType.Wifi) Component.onDestruction: Connectivity.active = false
return device;
}
return null;
}
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
SettingsCard { SettingsCard {
title: "Wi‑Fi" title: "Wired"
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off" 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 { SettingRow {
label: "Wi‑Fi" label: "Wi-Fi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found" detail: Connectivity.wifiEnabled ? "On" : "Off"
controlWidth: 48 controlWidth: 48
divider: Connectivity.wifiEnabled
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: Networking.wifiEnabled checked: Connectivity.wifiEnabled
enabled: Networking.wifiHardwareEnabled enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value onToggled: value => Networking.wifiEnabled = value
} }
} }
WifiList { WifiPanel {
width: parent.width width: parent.width
device: root.wifiDevice visible: Connectivity.wifiEnabled
active: true
maxHeight: 240
}
ActionRow {
label: "Advanced network settings"
detail: "VPN, wired profiles, DNS, and connection details"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("network")
} }
} }
SettingsCard { SettingsCard {
title: "Bluetooth" 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 { SettingRow {
label: "Bluetooth" 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 controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: root.bluetoothAdapter?.enabled ?? false checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
enabled: root.bluetoothAdapter !== null enabled: Connectivity.adapter !== null
onToggled: value => { onToggled: value => {
if (root.bluetoothAdapter) if (Connectivity.adapter)
root.bluetoothAdapter.enabled = value; Connectivity.adapter.enabled = value;
} }
} }
} }
BluetoothList { BluetoothPanel {
width: parent.width width: parent.width
active: true visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
maxHeight: 220
} }
}
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 { ActionRow {
label: "Advanced Bluetooth settings" label: "Network connections"
detail: "Device details and system-level options" 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 divider: false
action: "Open panel" onTriggered: SystemSettings.openGnomePanel("online-accounts")
onTriggered: SystemSettings.openGnomePanel("bluetooth")
} }
} }
} }
@@ -47,18 +47,16 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Window layout" title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling." subtitle: "Follows the Forge mental model, with native Hyprland tiling."
TextRow { // These were two read-only rows reporting "Tiling" and "Dynamic", which
label: "Layout" // described settings rather than facts -- both are ordinary Hyprland
detail: "Dwindle with preserved split direction" // options that simply had no controls. TextRow's own documentation says
value: "Tiling" // a setting the user could reasonably change does not belong in it.
} ChoiceRow { setting: "windowLayout" }
TextRow { ToggleRow { setting: "preserveSplit" }
label: "Workspace movement" ChoiceRow { setting: "forceSplit" }
detail: "Alt+H / Alt+L, add Shift to move a window" ToggleRow { setting: "windowSnapping" }
value: "Dynamic"
}
ActionRow { ActionRow {
label: "Gaps, corners, and effects" label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview" detail: "Adjusted on the Appearance page, beside a live preview"
@@ -68,6 +66,18 @@ SettingsPage {
} }
} }
// GNOME's Multitasking panel, in Hyprland's terms.
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ChoiceRow { setting: "followMouse"; divider: false }
}
SettingsCard { SettingsCard {
title: "Focus" title: "Focus"
@@ -111,10 +121,10 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Reset" title: "Reset"
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed." subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow { ActionRow {
label: "Restore Panama defaults" label: "Restore defaults"
detail: "Applies immediately, including to the compositor" detail: "Applies immediately, including to the compositor"
action: "Restore defaults" action: "Restore defaults"
divider: false divider: false
@@ -50,10 +50,13 @@ Rectangle {
// Stands in for the wallpaper. Static: an animated gradient here would // Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page. // repaint forever behind a settings page.
// Follows the colour scheme. A preview that stays dark while the shell
// around it is light does not read as "your desktop" -- it reads as a
// screenshot of someone else's.
gradient: Gradient { gradient: Gradient {
orientation: Gradient.Vertical orientation: Gradient.Vertical
GradientStop { position: 0.0; color: "#2b3050" } GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
GradientStop { position: 1.0; color: "#241f33" } GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
} }
// The bar, so the preview reads as this desktop and not a generic one. // The bar, so the preview reads as this desktop and not a generic one.
@@ -86,9 +89,12 @@ Rectangle {
} }
} }
// The real bar carries a clock here, not a name. Standing in with the
// actual time keeps the preview a picture of this desktop rather than
// a picture of a product.
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
text: "Panama" text: Qt.formatTime(new Date(), "h:mm")
color: Theme.alpha(Theme.fg, 0.45) color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: 9 font.pixelSize: 9
@@ -203,7 +209,7 @@ Rectangle {
shadowEnabled: true shadowEnabled: true
shadowColor: win.focused && root.glowOn shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5) ? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha("#15161e", 0.85) : Theme.alpha(Theme.dark ? "#15161e" : "#6172b0", Theme.dark ? 0.85 : 0.45)
shadowBlur: win.focused && root.glowOn shadowBlur: win.focused && root.glowOn
? Math.min(1.0, root.px(root.glowRange) / 12) ? Math.min(1.0, root.px(root.glowRange) / 12)
: Math.min(1.0, root.px(root.shadowRange) / 24) : Math.min(1.0, root.px(root.shadowRange) / 24)
@@ -1,7 +1,7 @@
// Displays. // Displays.
// //
// Resolution, refresh rate, scale, and rotation, plus the gaming display // Resolution, refresh rate, scale, and rotation, plus panel brightness and the
// policy that was already here. // gaming display policy that was already here.
// //
// Every geometry change goes through an apply-then-confirm countdown. This is // 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, // the one page where a wrong value can leave the screen unreadable or blank,
@@ -32,7 +32,14 @@ SettingsPage {
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : ""; root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
} }
Component.onCompleted: root.syncSelectedOutput() // Probing I2C for DDC-capable monitors takes on the order of a second, so
// it runs when this page is opened rather than at shell startup. Monitors
// do not appear while you are looking at a settings page, so once is enough.
Component.onCompleted: {
root.syncSelectedOutput();
if (!Brightness.scanned)
Brightness.refresh();
}
Connections { Connections {
target: Displays target: Displays
function onMonitorsChanged(): void { root.syncSelectedOutput(); } function onMonitorsChanged(): void { root.syncSelectedOutput(); }
@@ -142,7 +149,7 @@ SettingsPage {
ActionRow { ActionRow {
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "") visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
label: "Using a custom display setting" label: "Using a custom display setting"
detail: "Forget it to go back to the resolution and scale Panama ships" detail: "Forget it to go back to the shipped resolution and scale"
action: "Forget" action: "Forget"
divider: false divider: false
onTriggered: Displays.forget(root.monitor.name) onTriggered: Displays.forget(root.monitor.name)
@@ -187,9 +194,61 @@ SettingsPage {
} }
} }
SettingsCard {
title: "Night Light"
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 }
}
// Panel brightness, over DDC/CI.
//
// This is hardware state rather than a stored preference: the monitor
// remembers it, the bezel buttons change it behind Panama's back, and
// writing it into settings.json would mean restoring a value the panel had
// already moved on from. So there is no schema key here and no SliderRow --
// the rows read and write the display directly.
SettingsCard {
visible: Brightness.available || Brightness.lastError !== ""
title: "Brightness"
subtitle: Brightness.available
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
: Brightness.lastError
Repeater {
model: Brightness.displays
SettingRow {
id: brightnessRow
required property var modelData
required property int index
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
: modelData.value + "%"
divider: brightnessRow.index < Brightness.displays.length - 1
controlWidth: 190
ValueSlider {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
width: parent.width
value: brightnessRow.modelData.value / 100
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
}
}
}
}
SettingsCard { SettingsCard {
title: "Gaming display policy" title: "Gaming display policy"
subtitle: "Applied immediately and restored when Panama starts." subtitle: "Applied immediately and restored when the session starts."
ToggleRow { setting: "autoHdr" } ToggleRow { setting: "autoHdr" }
ChoiceRow { setting: "vrrPolicy" } ChoiceRow { setting: "vrrPolicy" }
@@ -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 string sourceName
required property int index required property int index
required property bool featured required property bool featured
required property bool canMoveEarlier
required property bool canMoveLater
signal aliasCommitted(string id, string alias) signal aliasCommitted(string id, string alias)
signal removeRequested(string id) signal removeRequested(string id)
signal moveRequested(string id, int targetIndex) signal moveRequested(string id, int targetIndex)
readonly property bool dragging: dragHandler.active
implicitHeight: 108 implicitHeight: 108
radius: Theme.cardRadius radius: Theme.cardRadius
color: root.dragging color: Theme.alpha(Theme.bgDark, 0.7)
? Theme.mix(Theme.bgDark, Theme.accent, 0.09) border.width: 1
: Theme.alpha(Theme.bgDark, 0.7) border.color: Theme.alpha(Theme.fg, 0.07)
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
}
PrismEdge { PrismEdge {
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
inset: root.radius inset: root.radius
opacity: root.dragging ? 0.82 : 0.2 opacity: 0.2
} }
Rectangle { Column {
id: dragHandle id: reorderControls
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 11 anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: 30 width: 30
height: 42 spacing: 4
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)
Text { SettingsButton {
anchors.centerIn: parent width: 30
text: "⠿" height: 29
color: root.dragging ? Theme.accent : Theme.fgDim text: "↑"
font.family: Theme.fontFamily enabled: root.canMoveEarlier
font.pixelSize: 16 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 { SettingsButton {
id: handleMouse width: 30
anchors.fill: parent height: 29
hoverEnabled: true text: "↓"
acceptedButtons: Qt.NoButton enabled: root.canMoveLater
cursorShape: Qt.SizeAllCursor activeFocusOnTab: enabled
} onClicked: root.moveRequested(root.favorite.id, root.index + 1)
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
DragHandler { Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
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;
}
} }
} }
Rectangle { Rectangle {
id: aliasFrame id: aliasFrame
anchors.left: dragHandle.right anchors.left: reorderControls.right
anchors.leftMargin: 10 anchors.leftMargin: 10
anchors.right: removeButton.left anchors.right: removeButton.left
anchors.rightMargin: 12 anchors.rightMargin: 12
@@ -188,16 +156,4 @@ Rectangle {
Keys.onReturnPressed: root.removeRequested(root.favorite.id) Keys.onReturnPressed: root.removeRequested(root.favorite.id)
Keys.onSpacePressed: 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));
}
} }
@@ -9,7 +9,7 @@ SettingsPage {
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening") readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
title: `${root.greeting}, Gabriel` title: `${root.greeting}, Gabriel`
lede: "Your Panama desktop is configured and ready." lede: "Your desktop is configured and ready."
SettingsCard { SettingsCard {
title: SystemSettings.monitorDescription || "Active display" title: SystemSettings.monitorDescription || "Active display"
@@ -103,6 +103,16 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Weather" title: "Weather"
subtitle: "Local conditions in the date menu" subtitle: "Local conditions in the date menu"
TextRow {
label: "Location"
detail: "Only the search term is sent; the name below is a label kept on this machine"
value: Settings.weatherLocation
}
LocationPicker {
width: parent.width
}
ChoiceRow { setting: "temperatureUnit" } ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false } SliderRow { setting: "weatherRefreshMinutes"; divider: false }
} }
@@ -43,13 +43,203 @@ SettingsPage {
return "Home Assistant is unavailable"; return "Home Assistant is unavailable";
} }
function saveHomeAssistantConfig(): void {
HomeAssistantConfig.save(
homeUrlInput.text,
homeEntitiesInput.text,
homeTokenInput.text
);
}
Connections {
target: HomeAssistantConfig
function onConfigurationSaved(): void {
homeTokenInput.clear();
homeUrlInput.text = HomeAssistantConfig.url;
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
}
}
SettingsCard { SettingsCard {
title: "Home Assistant" title: "Home Assistant"
subtitle: root.homeStatus() 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 a 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 { SettingRow {
label: "Light catalog" label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper." detail: "Light state is read through the Home Assistant helper."
divider: false divider: false
controlWidth: 176 controlWidth: 176
@@ -122,6 +312,8 @@ SettingsPage {
}) })
sourceName: modelData.sourceName sourceName: modelData.sourceName
featured: index < 4 featured: index < 4
canMoveEarlier: index > 0
canMoveLater: index < favoritesGrid.count - 1
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias) onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex) onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id) onRemoveRequested: id => HomePreferences.remove(id)
@@ -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
}
}
@@ -0,0 +1,57 @@
// Mouse & Touchpad.
//
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
// where it was before. These all reach Hyprland's input section, which until now
// could only be changed by editing hypr/input.lua by hand.
//
// The touchpad card renders only when a touchpad is attached. On a desktop it
// would be worse than useless -- every switch on it appears to work, because the
// preference is stored and the compositor accepts an option for a device class
// with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Mouse & Touchpad"
lede: InputDevices.hasTouchpad
? "Pointer behaviour for your mouse and touchpad."
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
SettingsCard {
title: "Mouse"
subtitle: "Applied to every pointing device that is not a touchpad."
SliderRow { setting: "pointerSensitivity" }
ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "leftHanded"; divider: false }
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Touchpad"
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
ToggleRow { setting: "touchpadTapToClick" }
ToggleRow { setting: "touchpadNaturalScroll" }
ToggleRow { setting: "touchpadDisableWhileTyping" }
SliderRow { setting: "touchpadScrollFactor" }
ChoiceRow { setting: "touchpadDragLock" }
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
SliderRow { setting: "cursorSize"; divider: false }
}
}
@@ -24,7 +24,7 @@ SettingsPage {
TextRow { TextRow {
label: "Notification history" label: "Notification history"
detail: "Live notifications retained by Panama" detail: "Live notifications retained by the shell"
value: `${Notifs.history.length} items` value: `${Notifs.history.length} items`
} }
@@ -50,10 +50,89 @@ SettingsPage {
SliderRow { setting: "maxVisibleToasts"; divider: false } 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 { SettingsCard {
title: "Focus sessions" title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace." 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 { SettingRow {
id: durationRow id: durationRow
@@ -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
}
}
}
@@ -20,8 +20,8 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Idle behaviour" title: "Idle behaviour"
subtitle: IdleLock.managed subtitle: IdleLock.managed
? "Panama is managing hypridle. Changes take effect immediately." ? "Idle timings are managed here. Changes take effect immediately."
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable." : "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" } SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" } SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
@@ -41,10 +41,10 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Management" 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." subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
SettingRow { SettingRow {
label: "Let Panama manage idle timings" label: "Manage idle timings here"
detail: IdleLock.serviceState === "active" detail: IdleLock.serviceState === "active"
? "hypridle is running" ? "hypridle is running"
: "hypridle is " + IdleLock.serviceState : "hypridle is " + IdleLock.serviceState
@@ -0,0 +1,103 @@
// Privacy & Security.
//
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
// history, trash, and device security. Panama covers the parts it genuinely
// owns and is explicit about the parts it does not.
//
// The file-history and trash settings are the notable omission, and the reason
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
// which is not running in a Hyprland session. Offering switches for them would
// store a preference, change nothing, and give no sign of it -- the exact
// failure this codebase keeps designing against. So they are delegated by name
// rather than reimplemented as controls that lie.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Privacy & Security"
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
? "Screen lock, device access, and a machine whose security settings all check out."
: "Screen lock, which applications can see you, and how this machine is protected."
Component.onCompleted: if (!DeviceSecurity.scanned) DeviceSecurity.refresh()
SettingsCard {
title: "Screen lock"
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
}
SettingsCard {
title: "Camera & microphone"
subtitle: PrivacyState.anyActive
? "In use right now — the bar shows an indicator whenever this is true."
: "Nothing is using your camera or microphone."
TextRow {
label: "Camera"
detail: PrivacyState.cameraActive
? "In use by " + (PrivacyState.cameraApp || "an application")
: "Not in use"
value: PrivacyState.cameraActive ? "Active" : "Idle"
}
TextRow {
label: "Microphone"
detail: PrivacyState.microphoneActive
? "In use by " + (PrivacyState.microphoneApp || "an application")
: "Not in use"
value: PrivacyState.microphoneActive ? "Active" : "Idle"
}
TextRow {
label: "Screen sharing"
detail: PrivacyState.screenSharingActive
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
: "Not being shared"
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
divider: false
}
}
SettingsCard {
title: "Device security"
subtitle: DeviceSecurity.attentionCount === 0
? "Everything below is in its recommended state."
: DeviceSecurity.attentionCount + " item"
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
Repeater {
model: DeviceSecurity.facts
TextRow {
id: factRow
required property var modelData
required property int index
label: factRow.modelData.label
detail: factRow.modelData.detail
value: factRow.modelData.value
divider: factRow.index < DeviceSecurity.facts.length - 1
}
}
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
ActionRow {
label: "File history & trash"
detail: "Opens GNOME Settings, which owns these"
action: "Open privacy"
divider: false
onTriggered: SystemSettings.openGnomePanel("privacy")
}
}
}
@@ -1,4 +1,4 @@
# Panama Settings # Settings
The control centre for everything Panama owns. Anything the system owns — The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as hardware, accounts, printers — is delegated to GNOME Settings and labelled as
@@ -0,0 +1,75 @@
// Region & Language.
//
// GNOME keeps language and formats under System; the setting itself is the
// machine's locale, which localectl owns. Panama does not store a copy of it --
// there is exactly one system locale and localectl is where it lives, so a
// preference here would be a second source of truth that drifts the moment
// anything else changes it.
//
// Keyboard layout is deliberately not repeated here even though GNOME groups it
// with region. It is a compositor setting that applies instantly, it lives on
// the Keyboard page with the rest of the typing settings, and showing it twice
// invites the two views to disagree.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Region & Language"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: "The language and regional formats this machine uses."
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
SettingsCard {
title: "Language"
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
TextRow {
label: "Current language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
}
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => SystemLocale.set(value)
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "Language problem"
subtitle: SystemLocale.lastError
}
SettingsCard {
title: "Formats"
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
ActionRow {
label: "Clock and date display"
detail: "How the desktop itself shows the time"
action: "Open appearance"
onTriggered: ShellState.openSettings("appearance")
}
ActionRow {
label: "Regional formats"
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
action: "Open system"
divider: false
onTriggered: SystemSettings.openGnomePanel("system", "region")
}
}
}
@@ -18,7 +18,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Read anything on screen" title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions." subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions."
SettingRow { SettingRow {
icon: "󰗊" icon: "󰗊"
@@ -52,7 +52,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Local recognition" title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result." subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result."
TextRow { TextRow {
label: "Text recognition" label: "Text recognition"
@@ -0,0 +1,94 @@
// A searchable list of choices, for settings with far too many values to put in
// a dropdown and no useful preview to show.
//
// SearchPicker {
// items: Locale.locales // [{ value, label, detail }]
// current: Locale.current
// placeholder: "Search languages"
// onPicked: value => Locale.set(value)
// }
//
// FontPicker is the same idea specialised: it draws each candidate in its own
// family, which is the whole reason it is a list rather than a text field. This
// one is for choices whose only useful preview is their name.
import QtQuick
import qs.config
// SearchField lives with the clipboard module, which is where it was first
// needed; FontPicker imports it from the same place.
import qs.modules.clipboard
Column {
id: root
// [{ value, label, detail }]
property var items: []
property string current: ""
property string emptyText: "Nothing to choose from"
property string placeholder: "Search"
signal picked(string value)
spacing: 0
// Capped and filtered rather than listing everything: 327 locales 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.items.filter(item =>
needle === ""
? true
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
// The current choice stays visible while browsing, so it is always
// clear what would be replaced.
const selected = list.find(item => item.value === root.current);
if (needle === "" && selected !== undefined)
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: root.placeholder
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData.value === root.current
label: candidate.modelData.label
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
controlWidth: 120
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData.value)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: candidate.selected
text: "✓"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 2
color: Theme.accent
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
@@ -120,14 +120,40 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Fedora system settings" title: "Fedora system settings"
subtitle: "These remain owned by trusted system services and GNOME's mature panels." subtitle: "Panels this app does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices."
// This was one row listing five subjects and opening the network panel
// regardless. Naming a panel and then not opening it is worse than not
// offering it: it looks like a broken button rather than a deliberate
// hand-off, and someone looking for printers had to know to navigate
// once GNOME Settings appeared on the wrong page.
ActionRow {
label: "Users"
detail: "Accounts, passwords, and automatic login"
action: "Open users"
onTriggered: SystemSettings.openGnomePanel("system", "users")
}
ActionRow { ActionRow {
label: "Network, Bluetooth, printers, users, and accounts" label: "Sharing"
detail: "GNOME Settings remains searchable from the launcher too" detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing"
onTriggered: SystemSettings.openGnomePanel("sharing")
}
ActionRow {
label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners"
action: "Open colour"
onTriggered: SystemSettings.openGnomePanel("color")
}
ActionRow {
label: "Digital wellbeing"
detail: "Screen time and break reminders"
action: "Open wellbeing"
divider: false divider: false
action: "Open network" onTriggered: SystemSettings.openGnomePanel("wellbeing")
onTriggered: SystemSettings.openGnomePanel("network")
} }
} }
} }
@@ -40,7 +40,7 @@ Rectangle {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 18 anchors.leftMargin: 18
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Panama Settings" text: "Settings"
color: Theme.fg color: Theme.fg
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSize
@@ -99,6 +99,9 @@ Rectangle {
case "notifications": return notificationsPage; case "notifications": return notificationsPage;
case "screen-intelligence": return screenIntelligencePage; case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage; case "shortcuts": return shortcutsPage;
case "mouse": return mousePage;
case "privacy": return privacyPage;
case "region": return regionPage;
case "accessibility": return accessibilityPage; case "accessibility": return accessibilityPage;
case "power": return powerPage; case "power": return powerPage;
case "datetime": return dateTimePage; case "datetime": return dateTimePage;
@@ -153,6 +156,9 @@ Rectangle {
Component { id: notificationsPage; NotificationsPage {} } Component { id: notificationsPage; NotificationsPage {} }
Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
Component { id: shortcutsPage; ShortcutsPage {} } Component { id: shortcutsPage; ShortcutsPage {} }
Component { id: mousePage; MousePage {} }
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: servicesPage; ServicesPage {} } Component { id: servicesPage; ServicesPage {} }
Component { id: aboutPage; AboutPage {} } Component { id: aboutPage; AboutPage {} }
@@ -31,13 +31,16 @@ Rectangle {
{ page: "sound", label: "Sound", icon: "\u{F057E}" }, { page: "sound", label: "Sound", icon: "\u{F057E}" },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" }, { page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "power", label: "Power & Lock", icon: "\u{F0425}" },
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" }, { page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" }, { page: "services", label: "Startup & Services", icon: "\u{F0493}" },
{ page: "about", label: "About Panama", icon: "\u{F02FD}" } { page: "about", label: "About", icon: "\u{F02FD}" }
] ]
width: 272 width: 272
@@ -56,15 +59,6 @@ Rectangle {
height: implicitHeight height: implicitHeight
spacing: 12 spacing: 12
Text {
text: "PANAMA"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
font.letterSpacing: 2.2
}
Rectangle { Rectangle {
width: parent.width width: parent.width
height: 36 height: 36
@@ -314,7 +308,7 @@ Rectangle {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 36 anchors.leftMargin: 36
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Panama desktop is healthy" text: "Desktop is healthy"
color: Theme.fgDim color: Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -8,7 +8,7 @@ FloatingWindow {
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
title: "Panama Settings" title: "Settings"
visible: ShellState.settingsOpen visible: ShellState.settingsOpen
implicitWidth: 1120 implicitWidth: 1120
implicitHeight: 760 implicitHeight: 760
@@ -28,19 +28,19 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Keyboard" title: "Keyboard"
// These were read-only text, on the grounds that a layout change needed
// a compositor reload. It does not: setting input:kb_variant through
// hl.config re-keymaps attached keyboards immediately -- verified by
// watching active_keymap on a real keyboard change and change back. So
// they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" } SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" } SliderRow { setting: "keyRepeatRate" }
ToggleRow { setting: "numlockByDefault"; divider: false } ToggleRow { setting: "numlockByDefault"; divider: false }
} }
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "pointerSensitivity" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard { SettingsCard {
title: "Hardware input" title: "Hardware input"
@@ -150,7 +150,7 @@ SettingsPage {
SettingsCard { SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0 visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts" title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration." subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
ActionRow { ActionRow {
label: "Restore every shipped shortcut" label: "Restore every shipped shortcut"
@@ -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,8 +1,6 @@
import QtQuick import QtQuick
import Quickshell.Services.Pipewire
import qs.config import qs.config
import qs.services import qs.services
import qs.modules.quicksettings
SettingsPage { SettingsPage {
title: "Sound" title: "Sound"
@@ -10,43 +8,58 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Output" title: "Output"
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device" subtitle: AudioDevices.current(true)?.description ?? "No output device"
AudioSlider { SoundDeviceList {
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 width: parent.width
output: true output: true
maxHeight: 190 }
AudioBalance {
width: parent.width
node: AudioDevices.current(true)
} }
} }
SettingsCard { SettingsCard {
title: "Input" title: "Input"
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device" subtitle: AudioDevices.current(false)?.description ?? "No input device"
AudioSlider { SoundDeviceList {
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 width: parent.width
output: false output: false
maxHeight: 160 }
}
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)
}
} }
} }
@@ -0,0 +1,109 @@
// A free-text setting, bound to a schema key by name.
//
// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
//
// For the settings whose value is a short string with too many legal values to
// enumerate -- XKB layouts and options, chiefly. Label, explanation, and
// validation all come from PreferenceSchema, so a row cannot drift from the
// setting it edits.
//
// Rejected input is shown as rejected rather than silently dropped or quietly
// sanitised. Several of these strings are serialised into an hl.config payload,
// where stripping an unexpected character would turn a typo into a different
// working setting -- so the schema's pattern decides, the field turns red when
// it fails, and nothing is committed until it passes.
//
// Committed on Enter or when focus leaves, not per keystroke: half a layout
// name is a valid string that means something else, and each commit is a round
// trip to the compositor.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
property string placeholder: ""
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "")
// Empty is always allowed to be typed through, even where the pattern
// forbids it, so a field can be cleared on the way to a new value.
readonly property bool valid: input.text === ""
|| !root.spec?.pattern
|| new RegExp(root.spec.pattern).test(input.text)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: 210
function commit(): void {
if (!root.valid || input.text === root.stored)
return;
SystemSettings.commitPreference(root.setting, input.text);
}
function revert(): void {
input.text = root.stored;
}
// Follows the store when the value changes elsewhere -- a reset, a restored
// backup -- but never while the field has focus, which would overwrite what
// is being typed.
onStoredChanged: if (!input.activeFocus) input.text = root.stored
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: !root.valid
? Theme.danger
: (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
verticalAlignment: TextInput.AlignVCenter
clip: true
text: root.stored
color: root.valid ? Theme.fg : Theme.danger
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
selectByMouse: true
selectionColor: Theme.alpha(Theme.accent, 0.35)
onAccepted: root.commit()
// Commit, then show what is actually stored. If the compositor
// refused the value, the field snaps back to the value in effect
// rather than displaying a setting that was never applied; if it
// accepted, onStoredChanged brings the field back up to date the
// moment the write lands.
onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); }
Keys.onEscapePressed: {
root.revert();
input.focus = false;
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: input.text === "" && !input.activeFocus
text: root.placeholder
color: Theme.fgMuted
font: input.font
}
}
}
}
@@ -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}`;
}
}
@@ -19,6 +19,19 @@ Item {
implicitHeight: grid.implicitHeight 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 int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160 readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
@@ -30,7 +43,7 @@ Item {
spacing: 10 spacing: 10
Repeater { Repeater {
model: Wallpaper.available model: root.shown
Rectangle { Rectangle {
id: tile id: tile
@@ -143,7 +156,7 @@ Item {
width: parent.width - 40 width: parent.width - 40
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." text: "No images found. Looked in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
color: Theme.fgMuted color: Theme.fgMuted
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall 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
}
}
@@ -37,3 +37,17 @@ DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.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
MousePage 1.0 MousePage.qml
TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.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
}
}
}
}
}
}
}
}
}
}
}
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# What this machine is, as JSON: {label, value} pairs in display order.
#
# GNOME's About panel answers "what am I running on" in one screen, and
# fastfetch answers it in more detail; this covers both -- model, OS, kernel,
# uptime, package counts, shell, resolution, processor, memory, swap, disk and
# locale. Rows are ordered roughly the way fastfetch presents them: what the
# system is, then what is installed on it, then the hardware underneath.
#
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
# the vitals readout, and naming them again here would be a second source of
# truth that could disagree with the first. The page joins the two.
#
# Anything unreadable is omitted rather than reported as "Unknown". These are
# facts about hardware, and a row saying "Processor: Unknown" is noise where
# simply not having the row is not.
set -uo pipefail
facts=()
emit() {
[[ -n "${2:-}" ]] || return 0
facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')")
}
# ── System ───────────────────────────────────────────────────────────────────
if [[ -r /etc/os-release ]]; then
# Sourced in a subshell so the variables cannot leak into this script.
os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )"
emit "Operating system" "$os"
fi
# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default
# string"). Those are worse than nothing, so they are filtered out.
dmi() {
local value
value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0
case "$value" in
""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;;
esac
printf '%s' "$value"
}
vendor="$(dmi sys_vendor)"
product="$(dmi product_name)"
if [[ -n "$vendor" && -n "$product" ]]; then
emit "Model" "$vendor $product"
else
emit "Model" "${product:-$vendor}"
fi
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
emit "Kernel" "$(uname -r 2>/dev/null)"
# `uptime -p` already reads as prose ("1 week, 23 hours, 5 minutes"); only the
# leading "up " needs removing.
emit "Uptime" "$(uptime -p 2>/dev/null | sed 's/^up //')"
# ── Installed ────────────────────────────────────────────────────────────────
# Counted rather than listed. rpm -qa on a full workstation is a few thousand
# lines and takes a moment, which is part of why this whole helper runs on
# demand rather than at startup.
packages=""
if command -v rpm >/dev/null 2>&1; then
rpm_count="$(rpm -qa 2>/dev/null | wc -l)"
[[ "$rpm_count" -gt 0 ]] && packages="$rpm_count rpm"
fi
if command -v flatpak >/dev/null 2>&1; then
flatpak_count="$(flatpak list --app 2>/dev/null | wc -l)"
if [[ "$flatpak_count" -gt 0 ]]; then
[[ -n "$packages" ]] && packages="$packages, "
packages="$packages$flatpak_count flatpak"
fi
fi
emit "Packages" "$packages"
# $SHELL is the login shell, which is the one worth reporting -- the shell this
# script happens to run under is an implementation detail of the caller.
if [[ -n "${SHELL:-}" ]]; then
shell_name="$(basename "$SHELL")"
shell_version="$("$SHELL" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
emit "Shell" "${shell_name}${shell_version:+ $shell_version}"
fi
emit "Locale" "${LANG:-}"
case "${XDG_SESSION_TYPE:-}" in
wayland) emit "Windowing system" "Wayland" ;;
x11) emit "Windowing system" "X11" ;;
esac
# ── Hardware ─────────────────────────────────────────────────────────────────
# The focused monitor's actual mode, including the fractional scale, since a
# 4500x3000 panel at 1.5 presents very differently from one at 1.
if command -v hyprctl >/dev/null 2>&1; then
emit "Resolution" "$(hyprctl -j monitors 2>/dev/null \
| jq -r 'map(select(.focused)) + . | .[0]
| select(. != null)
| "\(.width)x\(.height) @ \(.refreshRate | floor)Hz · scale \(.scale)"' 2>/dev/null)"
fi
cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)"
threads="$(nproc 2>/dev/null)"
if [[ -n "$cpu" ]]; then
# The marketing name usually already says "6-Core", so the thread count is
# the part that adds information.
[[ -n "$threads" ]] && cpu="$cpu ($threads threads)"
emit "Processor" "$cpu"
fi
# Reported as the kernel sees it, which is a little under the sticker figure
# because firmware and integrated graphics reserve some before Linux starts.
#
# Total rather than used: About is not a monitor, and a "12.4 GiB used" figure
# is stale before it finishes drawing. The Home page's vitals readout is where
# live numbers belong.
emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
emit "Swap" "$swap"
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
+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
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# External monitor brightness over DDC/CI.
#
# A desktop with no backlight class device has no brightness control at all --
# brightnessctl only sees keyboard and NIC LEDs. The panel itself still has a
# brightness setting, reachable over the monitor's DDC/CI channel (VCP feature
# 0x10), which is what the buttons on the bezel drive.
#
# Usage:
# panama-brightness list -> {"displays":[...],"error":""}
# panama-brightness get <bus> -> integer percent
# panama-brightness set <bus> <pct> -> applies, prints nothing
#
# Displays are enumerated from sysfs rather than from `ddcutil detect`. The
# kernel publishes the connector-to-I2C-bus mapping directly, as
# /sys/class/drm/<card>-<connector>/ddc, along with whether anything is plugged
# in. That is better than parsing detect output in three ways: the format is
# stable where detect's brief output is undocumented, the connector name comes
# out exactly as Hyprland and the Displays page already spell it (DP-2), and
# only connectors with a monitor attached get probed -- one bus on this machine
# instead of fourteen, which is the difference between a fast scan and a slow
# one, since each probe of an empty bus waits for a timeout.
#
# No model name is reported. Hyprland already knows the human-readable
# description of every output, so the UI joins on the connector name rather than
# having two sources of truth for what a monitor is called.
set -uo pipefail
readonly VCP_BRIGHTNESS=0x10
# Test seams. The contract needs to exercise enumeration and parsing on a
# machine whose real monitors it must not touch, so both roots this script
# reads are overridable. Nothing sets them in normal use.
readonly DRM_ROOT="${PANAMA_BRIGHTNESS_DRM_ROOT:-/sys/class/drm}"
readonly DEV_ROOT="${PANAMA_BRIGHTNESS_DEV_ROOT:-/dev}"
emit_error() {
printf '{"displays":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v ddcutil >/dev/null 2>&1 || emit_error 'ddcutil is not installed'
# Reading a VCP value needs read/write access to the monitor's I2C bus. The
# udev rule ddcutil ships grants that to the seat user through uaccess, but only
# to devices created after the rule was installed -- so a machine that installed
# ddcutil without rebooting has the rule in place and no access to show for it.
# That is by far the most likely reason for an empty list, and it is fixable in
# one command, so say so rather than reporting "no displays".
has_accessible_bus() {
local dev
for dev in "$DEV_ROOT"/i2c-*; do
[[ -r "$dev" && -w "$dev" ]] && return 0
done
return 1
}
# The I2C bus that carries DDC/CI for one connector.
#
# There are two, and picking the wrong one finds no monitor at all:
#
# DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a
# child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and
# is the one ddcutil talks to.
#
# The connector's `ddc` symlink points at the classic I2C line used for EDID
# on HDMI and DVI. On a DisplayPort connector it still exists and still
# resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5,
# where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10
# immediately.
#
# So prefer the AUX child and fall back to the symlink. Note the readlink: the
# entries under /sys/class/drm are symlinks, and `find` does not follow the path
# it is given, so searching the unresolved path silently finds nothing.
bus_for_connector() {
local path="$1" real aux ddc
real="$(readlink -f "$path")"
aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)"
if [[ -n "$aux" ]]; then
printf '%s' "${aux#i2c-}"
return 0
fi
ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1
[[ -n "$ddc" ]] || return 1
ddc="$(basename "$ddc")"
printf '%s' "${ddc#i2c-}"
}
cmd_list() {
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
local rows=() connector bus value path
for path in "$DRM_ROOT"/card*-*; do
[[ -e "$path/ddc" ]] || continue
[[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue
# card1-DP-2 -> DP-2, the name Hyprland uses.
connector="$(basename "$path")"
connector="${connector#card*-}"
bus="$(bus_for_connector "$path")"
[[ "$bus" =~ ^[0-9]+$ ]] || continue
# A monitor that does not implement 0x10 is not an error; it simply
# cannot be controlled, and is left out rather than shown as a slider
# that does nothing.
value="$(cmd_get "$bus")" || continue
[[ -n "$value" ]] || continue
rows+=("$(jq -cn \
--argjson bus "$bus" \
--arg connector "$connector" \
--argjson value "$value" \
'{bus: $bus, connector: $connector, value: $value}')")
done
if [[ ${#rows[@]} -eq 0 ]]; then
emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.'
fi
printf '{"displays":[%s],"error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
}
# Prints the current brightness as a whole percent, or nothing when the display
# cannot report it. `getvcp --brief` is documented as machine readable and
# answers "VCP 10 C <current> <max>"; the max is almost always 100 but is not
# guaranteed to be, so it is read rather than assumed.
cmd_get() {
local bus="$1" out current max
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
read -r _ _ _ current max <<<"$out"
[[ "$current" =~ ^[0-9]+$ && "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || return 1
printf '%s' "$(( current * 100 / max ))"
}
cmd_set() {
local bus="$1" percent="$2" max out
[[ "$percent" =~ ^[0-9]+$ ]] || return 1
(( percent > 100 )) && percent=100
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
read -r _ _ _ _ max <<<"$out"
[[ "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || max=100
timeout 10 ddcutil --bus "$bus" setvcp "$VCP_BRIGHTNESS" "$(( percent * max / 100 ))" >/dev/null 2>&1
}
case "${1:-list}" in
list) cmd_list ;;
get) cmd_get "${2:?bus required}" ;;
set) cmd_set "${2:?bus required}" "${3:?percent required}" ;;
*) printf 'usage: panama-brightness [list|get <bus>|set <bus> <percent>]\n' >&2; exit 2 ;;
esac
+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, env: Mapping[str, str] | None = None,
legacy: Callable[[], Config] = load_legacy_config, legacy: Callable[[], Config] = load_legacy_config,
) -> 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 = 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() url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").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)
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# System locale, via localectl.
#
# panama-locale list -> [{value, label, detail}]
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale set <locale>
#
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
# already know, which defeats the point of a picker, so codes are resolved
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
# The code stays visible as the row's detail, because it is what actually gets
# written and someone choosing between two Spanish variants needs to see it.
#
# The join happens in a single jq pass. Doing it per locale meant 327 jq
# invocations, which took long enough to be visible when opening the page.
#
# Setting the locale is a privileged operation: localectl goes through polkit,
# which prompts. It also only takes effect for programs started afterwards, so
# the caller is responsible for saying a sign-out is needed -- this script does
# not pretend the running session changed.
set -uo pipefail
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
cmd_get() {
localectl status 2>/dev/null \
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
| tr -d '[:space:]'
}
cmd_list() {
local locales
locales="$(localectl list-locales 2>/dev/null)" || locales=""
if [[ -z "$locales" ]]; then
printf '[]\n'
return 0
fi
# Without iso-codes installed the codes are still perfectly usable; they
# just do not get friendly names. That is a degraded list, not a failure.
if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
jq -Rn --rawfile raw /dev/stdin \
'[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
<<<"$locales"
return 0
fi
jq -Rn \
--slurpfile languages "$ISO_LANG" \
--slurpfile countries "$ISO_COUNTRY" \
--rawfile raw /dev/stdin '
# alpha_2 -> name, for both databases. Languages without a two-letter
# code cannot appear in a locale name, so they are simply absent.
($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
| ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
| [ $raw
| split("\n")[]
| select(length > 0)
| . as $value
# en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
# not part of the human name.
| ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
| ($lang[$parts[0]] // $parts[0]) as $language
| (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
| {
value: $value,
label: (if $region then "\($language) (\($region))" else $language end),
detail: $value
}
]
| sort_by(.label)
' <<<"$locales"
}
cmd_set() {
local locale="${1:-}"
# Constrained rather than passed through: this reaches a privileged
# command, and the set of legal locale names is narrow and well known.
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
return 2
}
localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
return 2
}
localectl set-locale "LANG=$locale"
}
case "${1:-list}" in
list) cmd_list ;;
get) cmd_get ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
esac
+262
View File
@@ -0,0 +1,262 @@
#!/bin/bash
set -u
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
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
}
brightness_percent() {
local output="$1" percent
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$percent"
}
brightness_error() {
local detail="$1" label="External brightness unavailable"
if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then
label="Brightness needs permission"
fi
show_message dialog-warning-symbolic "$label" || true
if command -v notify-send >/dev/null 2>&1; then
notify-send --app-name=Panama --icon=display-brightness-symbolic \
"Brightness unavailable" "$detail" >/dev/null 2>&1 || true
fi
}
discover_ddc_bus() {
local helper="$1" cache_file="$2" list_json focused selected error bus connector
command -v jq >/dev/null 2>&1 || {
brightness_error "jq is required to discover DDC/CI displays."
return 1
}
list_json="$("$helper" list 2>/dev/null)" || {
brightness_error "The external brightness helper could not inspect connected displays."
return 1
}
if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then
brightness_error "The external brightness helper returned invalid display information."
return 1
fi
error="$(jq -r '.error // empty' <<<"$list_json")"
if [[ -n $error ]]; then
brightness_error "$error"
return 1
fi
focused="$(hyprctl -j monitors 2>/dev/null \
| jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \
| head -n1)"
selected="$(jq -r --arg connector "$focused" '
([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty)
| [.bus, .connector]
| @tsv
' <<<"$list_json")"
IFS=$'\t' read -r bus connector <<<"$selected"
if [[ ! $bus =~ ^[0-9]+$ ]]; then
brightness_error "No connected monitor exposes DDC/CI brightness control."
return 1
fi
umask 077
printf '%s\t%s\n' "$bus" "$connector" >"$cache_file"
printf '%s\n' "$bus"
}
adjust_ddc_brightness() {
local action="$1" step="$2"
local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}"
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock"
local bus="" connector="" current target lock_fd
[[ -x $helper ]] || {
brightness_error "The external brightness helper is not installed."
return 0
}
mkdir -p "$runtime_dir" || return 0
chmod 700 "$runtime_dir" 2>/dev/null || true
exec {lock_fd}>"$lock_file" || return 0
# DDC transactions on one I2C bus cannot safely overlap. A short wait also
# sheds an excessive key-repeat backlog instead of replaying it seconds later.
flock -w 2 "$lock_fd" || return 0
if [[ -r $cache_file ]]; then
IFS=$'\t' read -r bus connector <"$cache_file" || true
[[ $bus =~ ^[0-9]+$ ]] || bus=""
fi
if [[ -n $bus ]]; then
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
if [[ ! $current =~ ^[0-9]+$ ]]; then
: >"$cache_file"
bus=""
fi
fi
if [[ -z $bus ]]; then
bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
fi
if [[ ! $current =~ ^[0-9]+$ ]]; then
brightness_error "The selected monitor stopped responding over DDC/CI."
return 0
fi
if [[ $action == up ]]; then
target=$(( current + step ))
else
target=$(( current - step ))
fi
(( target > 100 )) && target=100
(( target < 0 )) && target=0
if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then
brightness_error "The selected monitor did not accept the brightness change."
return 0
fi
show_progress brightness "$target" "${target}%"
}
adjust_brightness() {
local action="${1:-}" step="${2:-5}" output percent
[[ $step =~ ^[0-9]+$ ]] || {
printf 'Usage: panama-osd brightness up|down [step]\n' >&2
return 2
}
case "$action" in
up|down) ;;
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
esac
# Laptop panels expose a kernel backlight class and remain the fastest,
# most reliable path. Desktops fall through to DDC/CI monitor control.
output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
if percent="$(brightness_percent "$output")"; then
if [[ $action == up ]]; then
brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0
else
brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0
fi
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
percent="$(brightness_percent "$output")" || return 0
show_progress brightness "$percent" "${percent}%"
return
fi
adjust_ddc_brightness "$action" "$step"
}
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
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Device security facts, as JSON.
#
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
# disk encryption, SELinux mode and the firewall are set in firmware, at install
# time, or by system policy -- none of them is a desktop preference, and a
# settings app that offered to toggle them would either fail or do something
# far-reaching from a switch that looks like any other.
#
# What it is for is answering "is this machine set up the way I think it is",
# which is the question GNOME's Device Security panel exists to answer and which
# otherwise needs five commands and root.
#
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
# the UI can highlight what deserves attention without hard-coding the meaning
# of each string. Anything that cannot be determined reports "Unknown" with
# ok:false rather than guessing, because a security readout that quietly reports
# "fine" when it failed to look is worse than no readout.
set -uo pipefail
fact() {
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
'{label: $label, value: $value, ok: $ok, detail: $detail}'
}
facts=()
# ── Secure Boot ──────────────────────────────────────────────────────────────
if command -v mokutil >/dev/null 2>&1; then
case "$(mokutil --sb-state 2>/dev/null)" in
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
esac
elif [[ -d /sys/firmware/efi ]]; then
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
else
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
fi
# ── TPM ──────────────────────────────────────────────────────────────────────
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
if [[ -n "$tpm_major" ]]; then
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
elif [[ -e /sys/class/tpm/tpm0 ]]; then
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
else
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
fi
# ── Disk encryption ──────────────────────────────────────────────────────────
# Counts LUKS mappings rather than naming them: which volume is encrypted is
# more detail than this readout needs, and device names are not meaningful here.
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
if (( crypt_count > 0 )); then
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
else
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
fi
# ── SELinux ──────────────────────────────────────────────────────────────────
if command -v getenforce >/dev/null 2>&1; then
case "$(getenforce 2>/dev/null)" in
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
esac
fi
# ── Firewall ─────────────────────────────────────────────────────────────────
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
else
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
fi
fi
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
@@ -572,6 +572,28 @@ def command_restore(arguments: list[str]) -> None:
home_present = False home_present = False
home_data = None 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 # Restoring remains undoable, but a corrupt current file must not prevent a
# known-good snapshot from recovering the desktop. # known-good snapshot from recovering the desktop.
save_snapshot(require_any=False, validate=False) save_snapshot(require_any=False, validate=False)
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Propagates the colour scheme to applications that do not read the desktop
# portal.
#
# Most modern applications DO follow org.freedesktop.portal.Settings and need
# nothing from us: GTK4/libadwaita, Qt6, Chromium and Electron all read
# org.freedesktop.appearance color-scheme, which xdg-desktop-portal-gtk serves
# from gsettings. Panama sets that, so those follow automatically.
#
# Terminals are the notable exception -- they predate the standard and carry
# their own palettes. kitty is handled here.
#
# panama-theme-apps dark|light
#
# kitty gets it twice: the generated include file so terminals opened later
# start correct, and a live `set-colors` over its control socket so terminals
# already open change now. Without the second, a scheme change appears to do
# nothing until you open a new window.
set -euo pipefail
scheme="${1:-dark}"
case "$scheme" in
dark|light) ;;
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
esac
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
status_kitty="skipped"
if [[ -r "$theme_file" ]]; then
# Written atomically: kitty may read this while a new window is starting.
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
status_kitty="written"
fi
# Live-apply to running terminals. kitty appends its PID to the socket name
# from listen_on, so there is one socket per instance -- "unix:@mykitty"
# alone reaches nothing, which is exactly how this looked like remote
# control being disabled when it was not.
if command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
applied=0
while read -r socket; do
[[ -n "$socket" ]] || continue
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
applied=$((applied + 1))
fi
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
(( applied > 0 )) && status_kitty="applied to $applied"
fi
fi
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty"
@@ -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,163 @@
pragma Singleton
// Panel brightness for external monitors, over DDC/CI.
//
// brightnessctl covers laptop panels through the kernel's backlight class. A
// desktop driving a DisplayPort monitor has no such device, so it has no
// brightness control at all -- the only way to dim the screen is the buttons on
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
// over the same I2C lines that carry EDID.
//
// Two things shape everything here:
//
// Detection is slow. Probing every I2C bus takes on the order of a second,
// which is far too slow to sit in front of a settings page opening. It runs
// once, on demand, and afterwards each display is addressed by its bus number
// directly.
//
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
// emits values continuously; sending each one produces a queue the panel
// works through seconds after the user let go, and some monitors drop or
// garble writes that arrive too fast. So `value` updates immediately for the
// UI and the hardware write is debounced, with only the latest value sent.
//
// Displays are keyed by DRM connector name (DP-2) so they line up with what
// Hyprland, the Displays page, and the monitor list already call them.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
// [{ bus, connector, model, value }] where value is 0..100.
property var displays: []
property bool scanning: false
// Empty when everything is fine. Carries the helper's explanation
// otherwise -- most usefully the udev command that grants I2C access,
// which is the difference between "brightness is unavailable" and
// "brightness is one command away".
property string lastError: ""
readonly property bool available: root.displays.length > 0
// True once a scan has completed, however it went. Lets the UI tell "not
// looked yet" apart from "looked and found nothing", which otherwise render
// identically and leave a permanently empty panel with no explanation.
property bool scanned: false
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
// one entry no matter how many values the slider emits.
property var pending: ({})
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
scan.running = true;
}
function displayFor(connector: string): var {
return root.displays.find(display => display.connector === connector) ?? null;
}
// Sets brightness for one display. The stored value moves at once so the
// slider tracks the pointer; the hardware follows when the drag settles.
function set(bus: int, percent: int): void {
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
root.displays = root.displays.map(display =>
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
const next = Object.assign({}, root.pending);
next[String(bus)] = clamped;
root.pending = next;
writeDebounce.restart();
}
Process {
id: scan
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.displays = [];
root.lastError = "Could not read the brightness helper's output.";
console.warn("Brightness: could not parse helper output:", error);
}
root.scanning = false;
root.scanned = true;
}
}
}
// Long enough that a drag produces one write rather than dozens, short
// enough that a single click still feels immediate.
Timer {
id: writeDebounce
interval: 120
onTriggered: root.pump()
}
// Writes run one at a time, and each is read back.
//
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
// processes talking to the same monitor interleave their exchanges and both
// can come back with garbage. Read back because a write is not a promise --
// panels clamp to their own range, ignore values while waking from standby,
// and drop writes that arrive too quickly. Without the read the slider shows
// what Panama asked for rather than what the monitor did, which is the same
// class of lie as trusting `hyprctl keyword` to have applied something.
property int writingBus: -1
function pump(): void {
if (writer.running || reader.running)
return;
for (const bus in root.pending) {
const value = root.pending[bus];
const remaining = Object.assign({}, root.pending);
delete remaining[bus];
root.pending = remaining;
root.writingBus = parseInt(bus);
writer.command = [root.helperPath, "set", bus, String(value)];
writer.running = true;
return;
}
}
Process {
id: writer
onExited: {
reader.command = [root.helperPath, "get", String(root.writingBus)];
reader.running = true;
}
}
Process {
id: reader
stdout: StdioCollector {
onStreamFinished: {
const actual = parseInt(this.text.trim());
if (!isNaN(actual)) {
root.displays = root.displays.map(display =>
display.bus === root.writingBus
? Object.assign({}, display, { value: actual })
: display);
}
root.writingBus = -1;
// Anything queued while this write was in flight goes now.
root.pump();
}
}
}
}
@@ -0,0 +1,105 @@
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 string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
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}" } } })`]);
// Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
// write above and needs nothing here.
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
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()
}
@@ -0,0 +1,54 @@
pragma Singleton
// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux,
// firewall.
//
// Nothing here is a preference. These are set in firmware, at install time, or
// by system policy, and a settings app that offered to change them from a
// switch would either fail or do something far-reaching from a control that
// looks like every other control. What this answers is "is this machine set up
// the way I think it is", which otherwise takes five commands and root.
//
// Read on demand. None of these can change while the desktop is running,
// short of a reboot.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security"
// [{ label, value, ok, detail }]
property var facts: []
property bool scanned: false
// The facts that are not in their reassuring state. The page leads with the
// count so a machine that is entirely fine says so in one line instead of
// making the user read five rows to find out.
readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("DeviceSecurity: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
}
@@ -41,6 +41,7 @@ Singleton {
property bool revertVerificationActive: false property bool revertVerificationActive: false
property int operationGeneration: 0 property int operationGeneration: 0
property int revertGeneration: -1 property int revertGeneration: -1
property bool externalChangeBlocked: false
property int secondsLeft: 0 property int secondsLeft: 0
readonly property bool awaitingConfirmation: root.pendingOutput !== "" readonly property bool awaitingConfirmation: root.pendingOutput !== ""
@@ -271,6 +272,10 @@ Singleton {
// Applies immediately and starts the countdown. Nothing is stored yet: the // Applies immediately and starts the countdown. Nothing is stored yet: the
// settings file is only written by confirm(). // settings file is only written by confirm().
function apply(output: string, mode: string, scale: real, transform: int): bool { 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) { if (root.busy) {
root.lastError = "Wait for the current display operation to finish."; root.lastError = "Wait for the current display operation to finish.";
return false; return false;
+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()
}
@@ -0,0 +1,66 @@
pragma Singleton
// What input hardware this machine actually has.
//
// Exists so pages can hide controls for hardware that is not present. A
// touchpad card on a desktop is not merely useless -- it is misleading, because
// every switch on it appears to work: the preference is stored and Hyprland
// accepts the option for a device class it has no member of. The user is left
// toggling settings that will never affect anything, with nothing to say so.
//
// Touchpads are identified by name. libinput exposes them through the same
// "mice" list as everything else that reports pointer motion, and Hyprland
// passes the device name through, so an Elan or Synaptics touchpad arrives as
// something like "elan-touchpad". There is no device-class field to consult.
//
// Read on demand rather than polled. Input devices do come and go -- a mouse is
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
// the device list changed, which is the only moment the answer can differ.
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
Singleton {
id: root
property var mice: []
property var keyboards: []
// True when anything that looks like a touchpad is attached.
readonly property bool hasTouchpad: root.mice.some(name =>
name.includes("touchpad") || name.includes("trackpad"))
readonly property bool hasMouse: root.mice.length > 0
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
running: true
command: ["hyprctl", "-j", "devices"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
} catch (error) {
console.warn("InputDevices: could not parse hyprctl devices:", error);
}
}
}
}
Connections {
target: Hyprland
function onRawEvent(event: var): void {
if (event.name === "device" || event.name === "configreloaded")
root.refresh();
}
}
}
@@ -0,0 +1,47 @@
pragma Singleton
// What this machine is: model, processor, memory, disk, OS, kernel.
//
// Read once, on demand. None of it changes while the desktop is running except
// free disk space, and About is not a monitor -- the vitals readout on the Home
// page is where live figures belong.
//
// Graphics is not here. GraphicsDevices already enumerates GPUs for the vitals
// readout, and naming them again would be a second source of truth that could
// disagree with the first; the About page joins the two instead.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-about"
// [{ label, value }] in display order.
property var facts: []
property bool scanned: false
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("MachineInfo: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
}
+46 -1
View File
@@ -26,6 +26,7 @@ Singleton {
id: root id: root
property bool initialized: false property bool initialized: false
property bool suppressStatusEvents: false
// The manual switch. Ignored while `automatic` is on. // The manual switch. Ignored while `automatic` is on.
property bool enabled: Settings.nightLightEnabledByDefault 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 // The window wraps midnight (17:00 → 10:00), so the comparison flips when
// `from` is later in the day than `to`. // `from` is later in the day than `to`.
function inWindow(hour: real): bool { function inWindow(hour: real): bool {
@@ -89,8 +106,36 @@ Singleton {
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled) onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic) 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: { onActiveChanged: {
if (!root.initialized) if (!root.initialized || root.suppressStatusEvents)
return; return;
StatusEvents.publish({ StatusEvents.publish({
key: "display-night-light", 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. // Cleared when the notification centre is opened. The bar binds to this.
property int unreadCount: 0 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 // Arrival times, keyed by notification id — the protocol carries no
// timestamp. Deliberately formatted once at arrival rather than shown as // timestamp. Deliberately formatted once at arrival rather than shown as
// "5 minutes ago", which would need a clock ticking behind every card. // "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 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 // history grouped by app, in most-recent-app-first order — the shape
// NotificationCenter.qml renders directly. // NotificationCenter.qml renders directly.
readonly property var groups: { readonly property var groups: {
@@ -85,13 +191,20 @@ Singleton {
actionIconsSupported: true actionIconsSupported: true
inlineReplySupported: 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 // Replayed from before a shell reload. Letting these through would
// re-toast and re-list everything on every edit, so they are left // re-toast and re-list everything on every edit, so they are left
// untracked and allowed to die. // untracked and allowed to die.
if (notification.lastGeneration) if (notification.lastGeneration)
return; return;
const appId = root.rememberApplication(notification);
if (!root.appRule(appId).enabled)
return;
// Without this the object is destroyed the instant this returns. // Without this the object is destroyed the instant this returns.
notification.tracked = true; notification.tracked = true;
root.arrivals[notification.id] = new Date(); root.arrivals[notification.id] = new Date();
@@ -109,11 +222,10 @@ Singleton {
if (!root.doNotDisturb) if (!root.doNotDisturb)
root.popups = [notification].concat(root.popups); root.popups = [notification].concat(root.popups);
} }
}
// ── Mutation ──────────────────────────────────────────────────────────── // ── Mutation ────────────────────────────────────────────────────────────
function pushHistory(n: Notification): void { function pushHistory(n: var): void {
const next = [n].concat(root.history); const next = [n].concat(root.history);
// Anything past the cap is released, otherwise it stays tracked // 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 // Called from the `closed` signal — the object is on its way out, so this
// only ever removes references, never touches the notification. // only ever removes references, never touches the notification.
function forget(n: Notification): void { function forget(n: var): void {
delete root.arrivals[n.id]; delete root.arrivals[n.id];
if (root.history.indexOf(n) !== -1) if (root.history.indexOf(n) !== -1)
root.history = root.history.filter(x => x !== n); 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
}
}
@@ -38,6 +38,10 @@ Singleton {
property var initializeHome: function(ids) { HomePreferences.initialize(ids); } property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); } property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
property var reloadDesktop: function() { DesktopPreferences.reload(); } 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 applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
property var reloadKeybinds: function() { Keybinds.applyReload(); } property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; } property var keybindsReloading: function() { return Keybinds.reloading; }
@@ -47,6 +51,7 @@ Singleton {
} }
property var applyWallpaper: function(path) { Wallpaper.set(path); } property var applyWallpaper: function(path) { Wallpaper.set(path); }
property var reloadShell: function() { Quickshell.reload(false); } property var reloadShell: function() { Quickshell.reload(false); }
property var protectedDisplays: ({})
readonly property bool busy: listQuery.running || actionRun.running readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running || applyRestoredState.running || settleReload.running
@@ -81,6 +86,10 @@ Singleton {
root.lastError = actionRun.restoring root.lastError = actionRun.restoring
? "That snapshot could not be restored." ? "That snapshot could not be restored."
: "The settings could not be backed up."; : "The settings could not be backed up.";
if (actionRun.restoring) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
}
return; return;
} }
root.lastAction = actionRun.restoring ? "restored" : "saved"; root.lastAction = actionRun.restoring ? "restored" : "saved";
@@ -89,6 +98,10 @@ Singleton {
root.lastError = homeReloaded root.lastError = homeReloaded
? "" ? ""
: "Desktop settings were restored, but Home favourites could not be reloaded."; : "Desktop settings were restored, but Home favourites could not be reloaded.";
if (!homeReloaded) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
}
} else } else
root.lastError = ""; root.lastError = "";
root.refresh(); root.refresh();
@@ -124,6 +137,8 @@ Singleton {
// from leaving restored Home state stale indefinitely. // from leaving restored Home state stale indefinitely.
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) { if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
stop(); stop();
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.reloadShell(); root.reloadShell();
} }
} }
@@ -162,6 +177,8 @@ Singleton {
if (!root.reloadHomeState(text)) if (!root.reloadHomeState(text))
return false; return false;
root.reloadDesktop(); root.reloadDesktop();
if (!root.protectDisplays(root.protectedDisplays))
return false;
applyRestoredState.restart(); applyRestoredState.restart();
return true; return true;
} }
@@ -222,10 +239,18 @@ Singleton {
function restore(name: string): bool { function restore(name: string): bool {
if (actionRun.running) if (actionRun.running)
return false; 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)) { if (!root.snapshots.some(snapshot => snapshot.name === name)) {
root.lastError = "That snapshot is not in the list."; root.lastError = "That snapshot is not in the list.";
return false; return false;
} }
const currentDisplays = root.readDisplays();
root.protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.setDisplayBlocked(true);
actionRun.restoring = true; actionRun.restoring = true;
actionRun.exec([root.helperPath, "restore", name]); actionRun.exec([root.helperPath, "restore", name]);
return true; return true;
@@ -29,9 +29,13 @@ Singleton {
"dock": "desktop", "dock": "desktop",
"focus": "desktop", "focus": "desktop",
"display": "displays", "display": "displays",
"nightLight": "displays",
"idle": "power", "idle": "power",
"accessibility": "accessibility", "accessibility": "accessibility",
"input": "shortcuts", "input": "shortcuts",
"pointer": "mouse",
"touchpad": "mouse",
"multitasking": "desktop",
"weather": "appearance", "weather": "appearance",
"notifications": "notifications", "notifications": "notifications",
"capture": "screen-intelligence" "capture": "screen-intelligence"
@@ -92,7 +92,7 @@ Singleton {
} }
function openSettings(page: string): void { function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"]; const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accessibility", "power", "datetime", "applications", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage); DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true; 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()
}
@@ -0,0 +1,99 @@
pragma Singleton
// The system locale.
//
// Named SystemLocale, not Locale: QML has a built-in Locale value type, and a
// singleton of that name is silently shadowed by it. Every binding then reads
// properties off the wrong thing and the page renders empty with only
// "Cannot read property of undefined" to show for it.
//
// Changing it is privileged: localectl goes through polkit, which prompts
// (hyprpolkitagent serves that in this session). It also only applies to
// programs started afterwards, so `pendingRestart` goes true once a change is
// accepted and the page says a sign-out is needed. Reporting the new locale as
// simply "in effect" would be wrong -- almost nothing on screen would be using
// it yet.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale"
// [{ value, label, detail }]
property var locales: []
property string current: ""
property bool scanning: false
property string lastError: ""
// True once a change has been accepted but the session has not restarted,
// so the UI can stop claiming the new locale is already in use.
property bool pendingRestart: false
readonly property string currentLabel: {
const match = root.locales.find(locale => locale.value === root.current);
return match ? match.label : root.current;
}
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
readCurrent.running = true;
list.running = true;
}
function set(value: string): void {
if (value === root.current)
return;
apply.command = [root.helperPath, "set", value];
apply.pendingValue = value;
apply.running = true;
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.locales = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.locales = [];
console.warn("SystemLocale: could not parse the locale list:", error);
}
root.scanning = false;
}
}
}
Process {
id: readCurrent
command: [root.helperPath, "get"]
stdout: StdioCollector {
onStreamFinished: root.current = this.text.trim()
}
}
Process {
id: apply
property string pendingValue: ""
// A refused change -- polkit dismissed, or an unknown locale -- must not
// move the UI. The value is only adopted on a zero exit.
onExited: code => {
if (code === 0) {
root.current = apply.pendingValue;
root.pendingRestart = true;
root.lastError = "";
} else {
root.lastError = "The system did not accept that language. It may have needed a password.";
}
}
}
}
@@ -33,6 +33,16 @@ Singleton {
property string quickshellVersion: "0.3.0" property string quickshellVersion: "0.3.0"
property string lastError: "" 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 readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running || configWrite.running || configVerify.running || bluebubblesQuery.running
@@ -196,6 +206,11 @@ Singleton {
} }
// ── Applying options ──────────────────────────────────────────────────── // ── 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 }. // `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 // The whole batch is validated before anything is sent, so one bad value
// rejects the batch rather than half-applying it. // rejects the batch rather than half-applying it.
@@ -217,6 +232,9 @@ Singleton {
if (Object.keys(requested).length === 0) if (Object.keys(requested).length === 0)
return false; return false;
if (root.compositorApplyOverride !== null)
return root.compositorApplyOverride(requested);
// A write in flight is queued rather than refused. Options are applied // 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 verified one batch at a time, but the callers are a settings UI
// and a startup replay of every compositor-backed preference -- they // and a startup replay of every compositor-backed preference -- they
@@ -389,8 +407,22 @@ Singleton {
// //
// Compositor-backed values are re-applied afterwards, since resetting the // Compositor-backed values are re-applied afterwards, since resetting the
// stored value does not by itself tell Hyprland anything. // stored value does not by itself tell Hyprland anything.
function restoreDefaults(): void { 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(); 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 // Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised // that only cleared the schema store would silently leave a customised
@@ -401,12 +433,33 @@ Singleton {
HomePreferences.resetHomeDefaults(); HomePreferences.resetHomeDefaults();
resettleTimer.restart(); resettleTimer.restart();
return true;
} }
Timer { Timer {
id: resettleTimer id: resettleTimer
interval: 60 interval: 60
onTriggered: root.applyPersistedDisplayPolicy() 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 { function setAutoHdr(enabled: bool): void {
@@ -444,18 +497,35 @@ Singleton {
].indexOf(panel) >= 0; ].indexOf(panel) >= 0;
} }
function openGnomePanel(panel: string): bool { // `subpage` reaches the panels GNOME 50 nests under System -- users,
// about, datetime, region -- which its own desktop entries open as
// `gnome-control-center system users`. Without it, a row labelled "Users"
// lands on System's front page and leaves the user to navigate, which is
// most of the way to a broken button.
function openGnomePanel(panel: string, subpage: string): bool {
if (!root.isGnomePanelAllowed(panel)) { if (!root.isGnomePanelAllowed(panel)) {
root.lastError = "That GNOME Settings panel is not available."; root.lastError = "That GNOME Settings panel is not available.";
return false; return false;
} }
const command = ["gnome-control-center", panel];
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
command.push(subpage);
Quickshell.execDetached({ Quickshell.execDetached({
command: ["gnome-control-center", panel], command: command,
environment: { "XDG_CURRENT_DESKTOP": "GNOME" } environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
}); });
return true; return true;
} }
// Only System nests panels, and only these. Read off the Exec lines of the
// gnome-*-panel desktop entries rather than guessed, for the same reason
// the panel list above was.
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
if (panel !== "system")
return false;
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
}
function openApplication(id: string): bool { function openApplication(id: string): bool {
const commands = { const commands = {
"nextcloud": ["nextcloud"], "nextcloud": ["nextcloud"],
+8 -9
View File
@@ -33,6 +33,7 @@ Singleton {
property bool scanning: false property bool scanning: false
readonly property string configured: DesktopPreferences.get("wallpaperPath") 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 // Directories searched for wallpapers, in order. Screenshots are
// deliberately excluded: a folder of 300 screenshots is not a wallpaper // deliberately excluded: a folder of 300 screenshots is not a wallpaper
@@ -85,6 +86,7 @@ Singleton {
id: apply id: apply
property string requested: "" property string requested: ""
property string storedValue: ""
property var remaining: [] property var remaining: []
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
@@ -100,7 +102,7 @@ Singleton {
return; return;
} }
root.lastError = ""; root.lastError = "";
DesktopPreferences.set("wallpaperPath", apply.requested); DesktopPreferences.set("wallpaperPath", apply.storedValue);
root.refreshActive(); root.refreshActive();
} }
} }
@@ -140,19 +142,16 @@ Singleton {
// Applies to every connected output. Returns false when the path is not one // Applies to every connected output. Returns false when the path is not one
// the schema will accept, so a caller can report the refusal. // the schema will accept, so a caller can report the refusal.
function set(path: string): bool { function set(path: string): bool {
if (PreferenceSchema.coerce("wallpaperPath", path) === undefined) { const effectivePath = path === "" ? root.shippedPath : path;
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
root.lastError = "That file path cannot be used as a wallpaper."; root.lastError = "That file path cannot be used as a wallpaper.";
return false; return false;
} }
if (apply.running) if (apply.running)
return false; return false;
apply.requested = path; apply.requested = effectivePath;
// "" clears the preference without touching what is on screen. apply.storedValue = path;
if (path === "") {
DesktopPreferences.set("wallpaperPath", "");
return true;
}
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name); const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
if (outputs.length === 0) { if (outputs.length === 0) {
@@ -161,7 +160,7 @@ Singleton {
} }
apply.remaining = outputs.slice(1); apply.remaining = outputs.slice(1);
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${path}`]); apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
return true; return true;
} }
@@ -13,6 +13,9 @@ ShellRoot {
property var calls: [] property var calls: []
property bool homeInitialized: false property bool homeInitialized: false
property var homeFavorites: [] 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 { function record(name: string): void {
const next = root.calls.slice(); const next = root.calls.slice();
@@ -43,6 +46,17 @@ ShellRoot {
favorite.id === id ? { id: id, alias: alias } : favorite); favorite.id === id ? { id: id, alias: alias } : favorite);
}; };
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); }; 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.applyCompositor = function() { root.record("system.apply"); };
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); }; SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
SettingsBackup.keybindsReloading = function() { return false; }; SettingsBackup.keybindsReloading = function() { return false; };
@@ -59,17 +73,29 @@ ShellRoot {
root.calls = []; root.calls = [];
root.homeInitialized = false; root.homeInitialized = false;
root.homeFavorites = []; root.homeFavorites = [];
root.displayOperationBusy = false;
root.displayBlocked = false;
SettingsBackup.protectedDisplays = root.displayGeneration;
} }
function apply(output: string): bool { function apply(output: string): bool {
return SettingsBackup.handleRestoreOutput(output); 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 { function status(): string {
return JSON.stringify({ return JSON.stringify({
calls: root.calls, calls: root.calls,
initialized: root.homeInitialized, initialized: root.homeInitialized,
favorites: root.homeFavorites favorites: root.homeFavorites,
displayBlocked: root.displayBlocked,
displays: root.displayGeneration,
lastError: SettingsBackup.lastError
}); });
} }
} }
@@ -6,6 +6,48 @@ import qs.config
import qs.services import qs.services
ShellRoot { 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 { IpcHandler {
target: "settings-system-test" target: "settings-system-test"
@@ -52,7 +94,22 @@ ShellRoot {
}); });
} }
function restoreDefaults(): void { SystemSettings.restoreDefaults(); } 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 { function panelAllowed(panel: string): bool {
return SystemSettings.isGnomePanelAllowed(panel); return SystemSettings.isGnomePanelAllowed(panel);
+67 -2
View File
@@ -38,6 +38,7 @@ import qs.modules.datemenu
import qs.modules.focus import qs.modules.focus
import qs.modules.signals import qs.modules.signals
import qs.modules.settings import qs.modules.settings
import qs.modules.osd
// Clipboard and datemenu ship explicit qmldir manifests because they were // Clipboard and datemenu ship explicit qmldir manifests because they were
// added while this daily-driver shell was already running; that also keeps // added while this daily-driver shell was already running; that also keeps
@@ -72,6 +73,11 @@ ShellRoot {
SignalGlass {} SignalGlass {}
} }
Variants {
model: Quickshell.screens
Osd {}
}
// ── Single-instance overlays ──────────────────────────────────────────── // ── Single-instance overlays ────────────────────────────────────────────
// These are always constructed but only *visible* when ShellState says so. // These are always constructed but only *visible* when ShellState says so.
// They're cheap while hidden, and keeping them alive means opening the // They're cheap while hidden, and keeping them alive means opening the
@@ -108,7 +114,10 @@ ShellRoot {
IpcHandler { IpcHandler {
target: "focus" target: "focus"
function start(): void { FocusSession.startDefault(); } function start(): bool {
FocusSession.startDefault();
return FocusSession.active;
}
function reveal(): void { FocusSession.reveal(); } function reveal(): void { FocusSession.reveal(); }
function dismiss(): void { FocusSession.dismiss(); } function dismiss(): void { FocusSession.dismiss(); }
function pause(): void { FocusSession.pauseOrResume(); } function pause(): void { FocusSession.pauseOrResume(); }
@@ -117,7 +126,12 @@ ShellRoot {
ShellState.openOverview(FocusSession.workspaceId); ShellState.openOverview(FocusSession.workspaceId);
FocusSession.dismiss(); 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 { function status(): string {
return JSON.stringify({ return JSON.stringify({
active: FocusSession.active, 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 { IpcHandler {
target: "activity" 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 { IpcHandler {
target: "kdeconnect" target: "kdeconnect"
function fixture(name: string): void { KdeConnect.applyFixture(name); } 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))
});
}
}
}
@@ -0,0 +1,50 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "weather-gpu-test"
function gpuStatus(): string {
return JSON.stringify({
count: GraphicsDevices.devices.length,
names: GraphicsDevices.devices.map(d => GraphicsDevices.shortName(d.name)),
paths: GraphicsDevices.devices.map(d => d.path),
selected: GraphicsDevices.selectedPath,
resolved: GraphicsDevices.selected !== null,
missing: GraphicsDevices.selectionMissing,
error: GraphicsDevices.lastError
});
}
function selectGpu(path: string): bool { return GraphicsDevices.select(path); }
function geoSearch(query: string): void { Geocoding.search(query); }
function geoStatus(): string {
return JSON.stringify({
searching: Geocoding.searching,
count: Geocoding.results.length,
top: Geocoding.results.length > 0 ? Geocoding.results[0].label : "",
error: Geocoding.lastError
});
}
function geoChooseTop(): bool {
if (Geocoding.results.length === 0) return false;
return Geocoding.choose(Geocoding.results[0]);
}
function storedLocation(): string {
return JSON.stringify({
label: DesktopPreferences.get("weatherLocation"),
lat: DesktopPreferences.get("weatherLatitude"),
lon: DesktopPreferences.get("weatherLongitude")
});
}
}
}

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