Author SHA1 Message Date
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 f22e405b50 fix: resolve persisted notification app labels 2026-08-18 05:57:10 -04:00
Gabriel Brown c415c4f176 fix: harden notification application rule integration 2026-08-18 05:51:32 -04:00
Gabriel Brown c087998710 feat: add notification application rules 2026-08-18 05:38:39 -04:00
78 changed files with 4489 additions and 153 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
+16 -13
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
@@ -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",
@@ -386,6 +386,56 @@ Singleton {
detail: "A time earlier than the start simply means the next morning" detail: "A time earlier than the start simply means the next morning"
}, },
// ── Colour scheme ───────────────────────────────────────────────────
// 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.
@@ -419,6 +469,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,
@@ -493,13 +584,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",
+4 -4
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,7 +41,7 @@ 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.
+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,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
@@ -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)
}
}
} }
} }
@@ -49,9 +49,18 @@ SettingsPage {
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." : "Applied to every display. Panama looks 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,7 +162,24 @@ 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 {
@@ -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.
@@ -203,7 +206,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(); }
@@ -200,6 +207,45 @@ SettingsPage {
SliderRow { setting: "nightLightTemperature"; divider: false } 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 Panama starts."
@@ -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
}
}
@@ -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 }
} }
@@ -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
}
}
@@ -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
@@ -44,3 +44,5 @@ AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml SoundDeviceList 1.0 SoundDeviceList.qml
SoundDeviceRow 1.0 SoundDeviceRow.qml SoundDeviceRow 1.0 SoundDeviceRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
+375
View File
@@ -0,0 +1,375 @@
// A living reference for Panama's shared visual language. This deliberately
// renders the production widgets instead of lookalike mock components, so a
// change to a control or Theme token is visible here immediately.
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.config
import qs.widgets
ShellRoot {
FloatingWindow {
id: galleryWindow
title: "Panama Prism Gallery"
visible: true
implicitWidth: 1120
implicitHeight: 760
minimumSize: Qt.size(920, 640)
color: Theme.bg
Rectangle {
anchors.fill: parent
color: Theme.bg
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: 0
}
RowLayout {
anchors.fill: parent
anchors.margins: 34
spacing: 34
// Quiet editorial rail: enough context to make screenshots
// self-explanatory without competing with the components.
ColumnLayout {
Layout.preferredWidth: 222
Layout.fillHeight: true
spacing: 0
Rectangle {
Layout.preferredWidth: 38
Layout.preferredHeight: 38
radius: 12
color: Theme.alpha(Theme.fg, 0.07)
ThemedIcon {
anchors.centerIn: parent
size: 20
icon: "applications-graphics-symbolic"
tint: Theme.accent
}
}
Text {
Layout.topMargin: 20
text: "PRISM"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 26
font.weight: Font.DemiBold
font.letterSpacing: 1.4
}
Text {
Layout.topMargin: 5
Layout.maximumWidth: 210
text: "Panama's living interface reference. Calm by default, vivid with purpose."
color: Theme.fgDim
wrapMode: Text.WordWrap
lineHeight: 1.25
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Item { Layout.preferredHeight: 36 }
Repeater {
model: [
{ label: "Tokyo Night Moon", color: Theme.bgHighlight },
{ label: "Signal blue", color: Theme.accent },
{ label: "Prism orchid", color: Theme.accentSecondary }
]
RowLayout {
required property var modelData
Layout.topMargin: 10
spacing: 10
Rectangle {
Layout.preferredWidth: 12
Layout.preferredHeight: 12
radius: 6
color: modelData.color
}
Text {
text: modelData.label
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Item { Layout.fillHeight: true }
Text {
text: "REAL COMPONENTS · LIVE TOKENS"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Flickable {
anchors.fill: parent
anchors.margins: 30
contentWidth: width
contentHeight: content.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ColumnLayout {
id: content
width: parent.width
spacing: 28
RowLayout {
Layout.fillWidth: true
ColumnLayout {
spacing: 4
Text {
text: "Interface inventory"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
text: "Production controls in their canonical states"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Item { Layout.fillWidth: true }
Pill {
interactive: false
horizontalPadding: 12
ThemedIcon {
size: 14
icon: "emblem-ok-symbolic"
tint: Theme.green
}
Text {
text: "TOKYO NIGHT MOON"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 0.8
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "QUICK CONTROLS"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
RowLayout {
Layout.fillWidth: true
spacing: 12
Toggle {
id: wifiToggle
Layout.fillWidth: true
icon: "network-wireless-signal-excellent-symbolic"
label: "Wi-Fi"
sublabel: active ? "Connected" : "Off"
active: true
onToggled: active = !active
}
Toggle {
id: bluetoothToggle
Layout.fillWidth: true
icon: "bluetooth-active-symbolic"
label: "Bluetooth"
sublabel: active ? "2 devices" : "Off"
active: false
onToggled: active = !active
}
Toggle {
id: focusToggle
Layout.fillWidth: true
icon: "weather-clear-night-symbolic"
label: "Focus"
sublabel: active ? "Until 5:30" : "Available"
active: false
onToggled: active = !active
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "CONTINUOUS VALUES"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 126
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.045)
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
RowLayout {
Layout.fillWidth: true
Text {
text: "System audio"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Item { Layout.fillWidth: true }
Text {
text: Math.round(volumeSlider.value * 100) + "%"
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
}
ValueSlider {
id: volumeSlider
Layout.fillWidth: true
value: 0.68
icon: "audio-volume-high-symbolic"
onMoved: newValue => volumeSlider.value = newValue
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "BAR MATERIAL"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 74
radius: Theme.cardRadius
color: Theme.alpha(Theme.bg, 0.62)
Row {
anchors.centerIn: parent
spacing: 8
Pill {
ThemedIcon {
size: 15
icon: "view-grid-symbolic"
tint: Theme.accent
}
Text {
text: "1 2 3"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Pill {
ThemedIcon {
size: 15
icon: "edit-copy-symbolic"
tint: Theme.fgDim
}
Text {
text: "Clipboard"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Pill {
ThemedIcon {
size: 15
icon: "appointment-soon-symbolic"
tint: Theme.fgDim
}
Text {
text: "Tue Aug 18 · 4:46 AM"
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
}
}
}
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# One stable command boundary for launcher actions. Vicinae scripts stay tiny,
# while the actual mapping to Quickshell IPC remains testable in one place.
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
action="${1:-}"
helper_dir="${PANAMA_ACTION_HELPER_DIR:-$script_dir}"
confirm_attempts="${PANAMA_ACTION_CONFIRM_ATTEMPTS:-20}"
confirm_delay="${PANAMA_ACTION_CONFIRM_DELAY:-0.1}"
kill_command="${PANAMA_ACTION_KILL_COMMAND:-/usr/bin/kill}"
osd_command=("$helper_dir/panama-osd")
gallery_command=("$helper_dir/panama-prism-gallery")
report_failure() {
local status=$?
trap - EXIT
if (( status != 0 )) && command -v notify-send >/dev/null 2>&1; then
notify-send -a Panama -i dialog-error-symbolic \
"Panama action failed" \
"The “${action//-/ }” action could not be completed." || true
fi
exit "$status"
}
trap report_failure EXIT
show_state() {
local state="$1" on_icon="$2" off_icon="$3" label="$4"
case "$state" in
true) PANAMA_OSD_STRICT=1 "${osd_command[@]}" message "$on_icon" "$label On" ;;
false) PANAMA_OSD_STRICT=1 "${osd_command[@]}" message "$off_icon" "$label Off" ;;
*)
printf 'Panama action returned an invalid state: %s\n' "$state" >&2
return 1
;;
esac
}
state_is_active() {
local target="$1"
case "$target" in
caffeine)
systemd-inhibit --list --no-pager --no-legend 2>/dev/null \
| awk '$1 == "Panama" && $(NF - 1) == "Caffeine" && $NF == "block" { found = 1 } END { exit !found }'
;;
night-light)
pgrep -u "$(id -u)" -x hyprsunset >/dev/null 2>&1
;;
*) return 2 ;;
esac
}
release_caffeine_inhibitors() {
local inhibitors pid uid
uid="$(id -u)"
inhibitors="$(systemd-inhibit --list --no-pager --no-legend 2>/dev/null \
| awk -v uid="$uid" '$1 == "Panama" && $2 == uid && $(NF - 1) == "Caffeine" && $NF == "block" { print $4 }')"
while IFS= read -r pid; do
[[ $pid =~ ^[0-9]+$ ]] || continue
"$kill_command" "$pid" 2>/dev/null || true
done <<<"$inhibitors"
}
wait_for_confirmed_state() {
local target="$1" expected="$2" attempt
for ((attempt = 0; attempt < confirm_attempts; attempt++)); do
if [[ $expected == true ]] && state_is_active "$target"; then
return 0
fi
if [[ $expected == false ]] && ! state_is_active "$target"; then
return 0
fi
sleep "$confirm_delay"
done
printf 'Panama action could not confirm %s reached state %s\n' "$target" "$expected" >&2
return 1
}
toggle_state() {
local target="$1" on_icon="$2" off_icon="$3" label="$4" state
state="$(qs ipc call "$target" toggle)"
case "$state" in true|false) ;; *) show_state "$state" "$on_icon" "$off_icon" "$label"; return ;; esac
if [[ $target == caffeine && $state == false ]]; then
release_caffeine_inhibitors
fi
wait_for_confirmed_state "$target" "$state"
show_state "$state" "$on_icon" "$off_icon" "$label"
}
wait_for_shell() {
local attempt
for ((attempt = 0; attempt < confirm_attempts; attempt++)); do
if qs ipc show >/dev/null 2>&1; then
return 0
fi
sleep "$confirm_delay"
done
printf 'Panama shell did not become ready after restart\n' >&2
return 1
}
case "$action" in
control-center) qs ipc call quicksettings open ;;
notifications) qs ipc call notifications open ;;
calendar) qs ipc call calendar-agenda open ;;
clipboard) qs ipc call clipboard open ;;
overview) qs ipc call overview open ;;
settings) qs ipc call settings open ;;
dnd)
state="$(qs ipc call notifications dnd)"
show_state "$state" notifications-disabled-symbolic notifications-symbolic "Do Not Disturb"
;;
caffeine)
toggle_state caffeine weather-clear-symbolic weather-clear-night-symbolic Caffeine
;;
night-light)
toggle_state night-light night-light-symbolic night-light-disabled-symbolic "Night Light"
;;
focus-start)
state="$(qs ipc call focus start)"
[[ $state == true ]]
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message preferences-system-time-symbolic "Focus Session Started"
;;
focus-end)
state="$(qs ipc call focus end)"
if [[ $state == true ]]; then
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message media-playback-stop-symbolic "Focus Session Ended"
else
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message dialog-information-symbolic "No Focus Session Running"
fi
;;
capture) qs ipc call capture open ;;
intelligence) qs ipc call screen-intelligence open ;;
screenshot) qs ipc call capture screenNow ;;
microphone) PANAMA_OSD_STRICT=1 "${osd_command[@]}" microphone toggle ;;
gallery) "${gallery_command[@]}" ;;
restart-shell)
qs kill >/dev/null 2>&1 || true
quickshell --daemonize
wait_for_shell
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message view-refresh-symbolic "Panama Restarted"
;;
*)
printf 'Usage: panama-action {%s}\n' \
'control-center|notifications|calendar|clipboard|overview|settings|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
exit 2
;;
esac
+125
View File
@@ -0,0 +1,125 @@
#!/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
}
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="$(basename "$(readlink -f "$path/ddc")")"
bus="${bus#i2c-}"
[[ "$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'
+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
+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,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);
}
}
+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*$/, "");
}
}
+18 -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 {
@@ -118,7 +135,7 @@ Singleton {
} }
onActiveChanged: { onActiveChanged: {
if (!root.initialized) if (!root.initialized || root.suppressStatusEvents)
return; return;
StatusEvents.publish({ StatusEvents.publish({
key: "display-night-light", key: "display-night-light",
@@ -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
}
}
+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,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")
});
}
}
}
@@ -4,7 +4,7 @@ Name=Panama Settings
GenericName=System Settings GenericName=System Settings
Comment=Configure the Panama Hyprland desktop Comment=Configure the Panama Hyprland desktop
Exec=qs ipc call settings open Exec=qs ipc call settings open
Icon=preferences-system-symbolic Icon=panama-settings
Terminal=false Terminal=false
StartupNotify=false StartupNotify=false
Categories=Settings; Categories=Settings;
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Panama Settings.
A gear, because a settings icon has to be recognisable at 48px in a dock
before it is anything else - but rendered in the Prism identity rather than
the flat monochrome symbolic icon this replaces. Blue leads into orchid, the
same pair that marks the focused window, the active workspace, and the
hairline along every glass surface.
The gear is a real toothed outline. An earlier version drew a ring with radial
strokes; at dock size the strokes merged into the ring and it read as an X.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="prism" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#82aaff"/>
<stop offset="55%" stop-color="#9b8ed8"/>
<stop offset="100%" stop-color="#b172b0"/>
</linearGradient>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2b2e45"/>
<stop offset="100%" stop-color="#1e2030"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#82aaff" stop-opacity="0"/>
<stop offset="22%" stop-color="#82aaff" stop-opacity="0.9"/>
<stop offset="78%" stop-color="#b172b0" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#b172b0" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#tile)"/>
<rect x="4.5" y="4.5" width="119" height="119" rx="27.5" fill="none"
stroke="#c8d3f5" stroke-opacity="0.10" stroke-width="1"/>
<path d="M34 6.5 H94" stroke="url(#edge)" stroke-width="1.6" stroke-linecap="round"/>
<path d="M 52.53 29.88 L 56.45 18.62 L 71.55 18.62 L 75.47 29.88 L 80.02 31.76 L 90.75 26.57 L 101.43 37.25 L 96.24 47.98 L 98.12 52.53 L 109.38 56.45 L 109.38 71.55 L 98.12 75.47 L 96.24 80.02 L 101.43 90.75 L 90.75 101.43 L 80.02 96.24 L 75.47 98.12 L 71.55 109.38 L 56.45 109.38 L 52.53 98.12 L 47.98 96.24 L 37.25 101.43 L 26.57 90.75 L 31.76 80.02 L 29.88 75.47 L 18.62 71.55 L 18.62 56.45 L 29.88 52.53 L 31.76 47.98 L 26.57 37.25 L 37.25 26.57 L 47.98 31.76 Z" fill="url(#prism)" stroke="url(#prism)" stroke-width="5"
stroke-linejoin="round"/>
<circle cx="64" cy="64" r="15" fill="#1e2030"/>
<circle cx="64" cy="64" r="15" fill="none" stroke="#c8d3f5" stroke-opacity="0.18" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Panama Settings.
A gear, because a settings icon has to be recognisable at 48px in a dock
before it is clever — but rendered in the Prism identity rather than the flat
monochrome of the symbolic icon this replaces. Blue leads into orchid across
the gradient, the same pair that marks the focused window, the active
workspace and the hairline on every glass surface.
Drawn on the dark tile (#222436) so it reads as an application icon among
full-colour ones, rather than as a UI glyph that wandered out of a toolbar.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="prism" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#82aaff"/>
<stop offset="55%" stop-color="#9b8ed8"/>
<stop offset="100%" stop-color="#b172b0"/>
</linearGradient>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2b2e45"/>
<stop offset="100%" stop-color="#1e2030"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#82aaff" stop-opacity="0"/>
<stop offset="22%" stop-color="#82aaff" stop-opacity="0.85"/>
<stop offset="78%" stop-color="#b172b0" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#b172b0" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#tile)"/>
<rect x="4.5" y="4.5" width="119" height="119" rx="27.5" fill="none"
stroke="#c8d3f5" stroke-opacity="0.10" stroke-width="1"/>
<!-- The prism hairline, as it appears along the top edge of every glass
surface in the shell. -->
<path d="M32 6.5 H96" stroke="url(#edge)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Gear: eight teeth, drawn as a ring with radial spokes so it stays legible
when the icon is scaled down to a dock tile. -->
<g transform="translate(64 66)">
<g fill="none" stroke="url(#prism)" stroke-width="9" stroke-linecap="round">
<circle r="24"/>
<g>
<path d="M0 -38 V-30"/>
<path d="M0 38 V30"/>
<path d="M-38 0 H-30"/>
<path d="M38 0 H30"/>
<path d="M-26.9 -26.9 L-21.2 -21.2"/>
<path d="M26.9 26.9 L21.2 21.2"/>
<path d="M-26.9 26.9 L-21.2 21.2"/>
<path d="M26.9 -26.9 L21.2 -21.2"/>
</g>
</g>
<circle r="7.5" fill="#c8d3f5" fill-opacity="0.92"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Screenshot and Recording
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open the polished screenshot and screen-recording picker.
# @vicinae.keywords ["capture", "record", "selection", "window", "screen"]
exec "$HOME/.config/quickshell/scripts/panama-action" capture
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: End Focus Session
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description End the current focus timer and restore its previous states.
# @vicinae.keywords ["stop timer", "deep work", "pomodoro", "restore"]
exec "$HOME/.config/quickshell/scripts/panama-action" focus-end
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Open Calendar
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open the calendar, synchronized agenda, and ongoing activity.
# @vicinae.keywords ["agenda", "events", "appointments", "date", "nextcloud"]
exec "$HOME/.config/quickshell/scripts/panama-action" calendar
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Open Clipboard
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Panama's compact clipboard history surface.
# @vicinae.keywords ["copy", "paste", "history", "text"]
exec "$HOME/.config/quickshell/scripts/panama-action" clipboard
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Open Control Center
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Wi-Fi, Bluetooth, audio, phone, and Home controls.
# @vicinae.keywords ["quick settings", "wifi", "bluetooth", "audio", "home", "phone"]
exec "$HOME/.config/quickshell/scripts/panama-action" control-center
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Open Mission Control
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description See every workspace, tiled window, and scratchpad window.
# @vicinae.keywords ["overview", "workspaces", "windows", "expose", "scratchpad"]
exec "$HOME/.config/quickshell/scripts/panama-action" overview
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Open Notifications
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open notification history and the unified date center.
# @vicinae.keywords ["alerts", "messages", "history", "date center"]
exec "$HOME/.config/quickshell/scripts/panama-action" notifications
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Open Prism Gallery
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open the living gallery of Panama's production components.
# @vicinae.keywords ["design system", "theme", "components", "tokyo night", "style"]
exec "$HOME/.config/quickshell/scripts/panama-action" gallery
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Open Settings
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Configure the Panama desktop from its native Settings app.
# @vicinae.keywords ["preferences", "configure", "desktop", "system"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Capture Entire Screen
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Save an immediate screenshot of the complete desktop.
# @vicinae.keywords ["quick screenshot", "full screen", "capture", "grim"]
exec "$HOME/.config/quickshell/scripts/panama-action" screenshot
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Restart Desktop Shell
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Restart Quickshell if a desktop surface becomes unresponsive.
# @vicinae.keywords ["reload", "repair", "quickshell", "restart", "recover"]
exec "$HOME/.config/quickshell/scripts/panama-action" restart-shell
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Screen Intelligence
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Select part of the screen for OCR, QR, or code recognition.
# @vicinae.keywords ["ocr", "text", "qr", "barcode", "recognize", "scan"]
exec "$HOME/.config/quickshell/scripts/panama-action" intelligence
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Start Focus Session
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Start the configured focus timer with DND and Caffeine.
# @vicinae.keywords ["timer", "deep work", "pomodoro", "concentrate", "workspace"]
exec "$HOME/.config/quickshell/scripts/panama-action" focus-start
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Toggle Caffeine
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Keep the display awake and block automatic sleep.
# @vicinae.keywords ["awake", "sleep", "inhibit", "presentation", "idle"]
exec "$HOME/.config/quickshell/scripts/panama-action" caffeine
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Toggle Do Not Disturb
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Pause or resume notification banners, with OSD confirmation.
# @vicinae.keywords ["dnd", "notifications", "quiet", "silence", "alerts"]
exec "$HOME/.config/quickshell/scripts/panama-action" dnd
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Toggle Microphone Mute
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Mute or unmute the default microphone with OSD feedback.
# @vicinae.keywords ["mic", "audio input", "privacy", "mute", "unmute"]
exec "$HOME/.config/quickshell/scripts/panama-action" microphone
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Toggle Night Light
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Toggle the warm display color temperature.
# @vicinae.keywords ["warm", "temperature", "blue light", "display", "hyprsunset"]
exec "$HOME/.config/quickshell/scripts/panama-action" night-light
+26
View File
@@ -0,0 +1,26 @@
# Upstream inspiration
Panama is its own desktop experience, but it should learn from strong open-source work without obscuring where an idea came from. This ledger records upstream projects that materially influence its architecture or interaction design.
## Omarchy
- Project: [basecamp/omarchy](https://github.com/basecamp/omarchy)
- Reviewed revision: `f32ebbdb730c4e8fe11e4046cef4267e466264ea`
- License: MIT, copyright David Heinemeier Hansson
- Local reference at review time: `/home/gib/Documents/Code/Projects/omarchy`
### Adopted ideas, rebuilt for Panama
- `shell/plugins/osd/Osd.qml` and `OsdModel.js`: inspired the separation between a pure presentation model and a single, click-through per-monitor OSD surface. Panama rebuilt the behavior around freedesktop symbolic icons, Tokyo Night Moon tokens, its Prism glass material, Hyprland's focused monitor, and the existing `wpctl`/`brightnessctl`/`playerctl` keybindings.
- `test/shell.d/osd-test.sh` and Omarchy's broader shell contract suite: reinforced Panama's use of fast model and integration contracts alongside live compositor verification.
- Omarchy's small shared visual primitives: reinforced the decision to maintain `prism-gallery.qml`, which renders Panama's production widgets and Theme tokens instead of duplicating mock-only controls.
- `shell/plugins/menu/Menu.qml`: inspired a searchable desktop-action inventory. Panama keeps Vicinae as its one launcher and exposes native script commands backed by a single `panama-action` dispatcher, rather than adding Omarchy's separate shell menu or plugin host.
No Omarchy user-facing layout or styling was transplanted. Panama keeps its GNOME-derived information architecture, Prism material, Tokyo Night Moon palette, app model, and established interaction choices.
### Candidates still under evaluation
- A plugin boundary for optional bar widgets and services once Panama has enough third-party modules to justify one.
- A small first-run health check for missing desktop dependencies and broken service integrations.
These are research directions, not commitments. They should only land when they improve the daily-driver experience without adding visible complexity or parallel configuration systems.
+3 -2
View File
@@ -21,8 +21,9 @@ fi
gsettings set org.gnome.desktop.screensaver lock-enabled false gsettings set org.gnome.desktop.screensaver lock-enabled false
gsettings set org.gnome.desktop.session idle-delay 0 gsettings set org.gnome.desktop.session idle-delay 0
# Run scripts # Run each setup stage in its own process. This keeps strict-shell options and
for script in ~/.local/share/Panama/setup/scripts/*; do source $script; done # helper variables local to the script that owns them.
for script in ~/.local/share/Panama/setup/scripts/*; do "$script"; done
# Revert to normal idle settings # Revert to normal idle settings
gsettings set org.gnome.desktop.screensaver lock-enabled true gsettings set org.gnome.desktop.screensaver lock-enabled true
+40
View File
@@ -70,6 +70,27 @@ for dir in "${dirs[@]}"; do
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir" log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
done done
# kitty.conf ends with `include current-theme.conf`, and that file is generated
# from the desktop colour scheme rather than committed -- it is machine state.
# A fresh checkout therefore has no such file, and kitty starts by complaining
# about a missing include and falling back to its stock colours. Seed it from
# the scheme in settings.json (dark unless the user has chosen otherwise) so a
# first launch is themed; ColorScheme.qml overwrites it on every change after.
KITTY_THEME="$PANAMA_DOT/kitty/current-theme.conf"
if [ -e "$KITTY_THEME" ]; then
log "Keeping existing kitty theme at $KITTY_THEME"
else
scheme="dark"
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
if [ -r "$settings" ]; then
stored="$(jq -r '.colorScheme // "dark"' "$settings" 2>/dev/null || echo dark)"
[ "$stored" = "light" ] && scheme="light"
fi
[ "$scheme" = "light" ] && theme="tokyonight-day" || theme="tokyonight-moon"
cp "$PANAMA_DOT/kitty/themes/$theme.conf" "$KITTY_THEME"
log "Seeded kitty $scheme theme ($theme) → $KITTY_THEME"
fi
# Vicinae 0.26 discovers user themes from its XDG data directory rather than # Vicinae 0.26 discovers user themes from its XDG data directory rather than
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of # from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
# the launcher config and expose only that file at Vicinae's runtime path. # the launcher config and expose only that file at Vicinae's runtime path.
@@ -91,6 +112,25 @@ fi
# Panama-native applications live in the user data directory so launchers can # Panama-native applications live in the user data directory so launchers can
# discover them alongside system desktop entries. Keep each authored file in # discover them alongside system desktop entries. Keep each authored file in
# the repository and expose it with a narrow per-file symlink. # the repository and expose it with a narrow per-file symlink.
# Panama ships its own application icons. Without these the desktop entries fall
# back to a generic symbolic glyph, which is drawn for 16px toolbar use and
# looks wrong beside full-colour application icons in the dock.
PANAMA_ICON_DIR="$PANAMA_PATH/config/local/share/icons/hicolor/scalable/apps"
USER_ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/apps"
mkdir -p "$USER_ICON_DIR"
for icon_file in "$PANAMA_ICON_DIR"/*.svg; do
[[ -e "$icon_file" ]] || continue
icon_name="$(basename "$icon_file")"
icon_target="$USER_ICON_DIR/$icon_name"
if [[ -L "$icon_target" ]]; then
rm "$icon_target"
elif [[ -e "$icon_target" ]]; then
mv "$icon_target" "$icon_target.bak"
fi
ln -s "$icon_file" "$icon_target"
done
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
PANAMA_APPLICATION_DIR="$PANAMA_PATH/config/local/share/applications" PANAMA_APPLICATION_DIR="$PANAMA_PATH/config/local/share/applications"
USER_APPLICATION_DIR="$HOME/.local/share/applications" USER_APPLICATION_DIR="$HOME/.local/share/applications"
mkdir -p "$USER_APPLICATION_DIR" mkdir -p "$USER_APPLICATION_DIR"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Install Panama's searchable launcher actions as one narrow directory link.
# Keeping the runtime path stable lets Vicinae watch it while every command
# remains authored and reviewed in the Panama repository.
set -euo pipefail
panama_path="${PANAMA_PATH:-$HOME/.local/share/Panama}"
vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}"
source_dir="$panama_path/config/local/share/vicinae/scripts"
scripts_dir="$vicinae_data_dir/scripts"
target_dir="$scripts_dir/panama"
backup_root="$vicinae_data_dir/backups"
backup_dir="$backup_root/panama.pre-panama"
legacy_backup="$scripts_dir/panama.pre-panama"
[[ -d "$source_dir" ]] || {
printf 'Panama command source is missing: %s\n' "$source_dir" >&2
exit 1
}
mkdir -p "$scripts_dir" "$backup_root"
# Older Panama builds preserved this directory alongside searchable commands.
# Move it out before reloading so those scripts cannot appear twice.
if [[ -e "$legacy_backup" ]]; then
if [[ -e "$backup_dir" ]]; then
printf 'Refusing to move %s; backup already exists at %s\n' \
"$legacy_backup" "$backup_dir" >&2
exit 1
fi
mv "$legacy_backup" "$backup_dir"
fi
if [[ -L "$target_dir" ]]; then
rm "$target_dir"
elif [[ -e "$target_dir" ]]; then
if [[ -e "$backup_dir" ]]; then
printf 'Refusing to replace %s; backup already exists at %s\n' \
"$target_dir" "$backup_dir" >&2
exit 1
fi
mv "$target_dir" "$backup_dir"
fi
ln -s "$source_dir" "$target_dir"
# The server also rescans periodically, but an explicit reload makes a setup
# run deterministic. If Vicinae is not active yet, its startup scan is enough.
if command -v vicinae >/dev/null 2>&1 && vicinae ping >/dev/null 2>&1; then
vicinae cmd launch core:reload-scripts >/dev/null 2>&1 || true
fi
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# panama-brightness enumerates monitors from sysfs and speaks DDC/CI to them.
#
# The parts worth pinning down are the ones that decide whether a slider appears
# at all, and whether it appears attached to the right screen:
#
# * only connectors with something plugged in are probed, because probing an
# empty bus costs a timeout each and there are fourteen of them here;
# * a panel that cannot report brightness is omitted rather than shown as a
# control that does nothing;
# * the connector name matches what Hyprland calls the output, since the UI
# joins on it to get the monitor's description;
# * no I2C access produces the udev command that fixes it, not "no displays".
#
# Runs entirely against fixtures. Real monitors are never touched: both the
# sysfs root and the device root are redirected, and ddcutil is replaced on PATH.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-brightness"
fail() {
printf 'brightness helper contract: %s\n' "$1" >&2
exit 1
}
fixture="$(mktemp -d /tmp/panama-brightness.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT
mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
# must never be probed at all.
make_connector() {
local name="$1" bus="$2" status="$3"
mkdir -p "$fixture/drm/$name"
printf '%s\n' "$status" >"$fixture/drm/$name/status"
mkdir -p "$fixture/i2c/i2c-$bus"
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
}
make_connector card1-DP-1 4 disconnected
make_connector card1-DP-2 5 connected
make_connector card1-DP-3 6 connected
make_connector card1-HDMI-A-1 7 disconnected
# has_accessible_bus only needs one readable/writable node to exist.
touch "$fixture/dev/i2c-5"
# Stub ddcutil. Records every bus it is asked about so the test can prove the
# disconnected ones were skipped. Bus 6 refuses, standing in for a panel without
# VCP 0x10.
#
# Bus 5 reports its brightness out of 200 rather than 100. Most panels do use
# 100, which is exactly the problem: with a maximum of 100 the scaling
# arithmetic is the identity, so a helper that ignored the reported maximum
# entirely would pass every assertion. 200 makes reads and writes that skip the
# conversion visibly wrong.
cat >"$fixture/bin/ddcutil" <<'STUB'
#!/usr/bin/env bash
bus=""
args=("$@")
for ((i = 0; i < ${#args[@]}; i++)); do
[[ "${args[$i]}" == "--bus" ]] && bus="${args[$((i + 1))]}"
done
printf '%s\n' "$bus" >>"$DDCUTIL_PROBE_LOG"
for arg in "$@"; do
if [[ "$arg" == "setvcp" ]]; then
printf 'set %s %s\n' "$bus" "${args[-1]}" >>"$DDCUTIL_SET_LOG"
exit 0
fi
done
case "$bus" in
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
*) exit 1 ;;
esac
STUB
chmod +x "$fixture/bin/ddcutil"
export DDCUTIL_PROBE_LOG="$fixture/probes.log"
export DDCUTIL_SET_LOG="$fixture/sets.log"
: >"$DDCUTIL_PROBE_LOG"
: >"$DDCUTIL_SET_LOG"
run_helper() {
PATH="$fixture/bin:$PATH" \
PANAMA_BRIGHTNESS_DRM_ROOT="$fixture/drm" \
PANAMA_BRIGHTNESS_DEV_ROOT="$fixture/dev" \
"$helper" "$@"
}
# ── Enumeration ──────────────────────────────────────────────────────────────
listing="$(run_helper list)"
jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
[[ "$(jq -r '.displays | length' <<<"$listing")" == "1" ]] \
|| fail "expected exactly one controllable display, got: $listing"
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|| fail "the connector name must match Hyprland's output name: $listing"
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|| fail "the display was mapped to the wrong I2C bus: $listing"
# 120 of a maximum of 200 is 60%.
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|| fail "brightness was not read as a percent of the reported maximum: $listing"
[[ "$(jq -r '.error' <<<"$listing")" == "" ]] \
|| fail "a successful listing must not carry an error: $listing"
# A connected panel that cannot report brightness is dropped, not listed.
jq -e '.displays | map(.connector) | index("DP-3") == null' >/dev/null <<<"$listing" \
|| fail 'a display without VCP 0x10 was listed as controllable'
# ── Disconnected connectors are never probed ─────────────────────────────────
if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
fail "a disconnected connector was probed -- each empty bus costs a timeout: $(tr '\n' ' ' <"$DDCUTIL_PROBE_LOG")"
fi
# ── Writes scale to the reported maximum ─────────────────────────────────────
run_helper set 5 40
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
run_helper set 5 500
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
# ── No I2C access explains itself ────────────────────────────────────────────
rm -f "$fixture/dev"/i2c-*
denied="$(run_helper list)"
[[ "$(jq -r '.displays | length' <<<"$denied")" == "0" ]] \
|| fail "displays were reported without I2C access: $denied"
grep -q 'udevadm' <<<"$(jq -r '.error' <<<"$denied")" \
|| fail "the no-access error must name the command that fixes it, got: $(jq -r '.error' <<<"$denied")"
printf 'brightness helper contract: PASS\n'
+19
View File
@@ -113,6 +113,25 @@ config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; } run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
status() { run ipc call displays-test status; } status() { run ipc call displays-test status; }
# The shipped geometry, read from the Hyprland config rather than from the
# running compositor.
#
# Everything below captures "original" from what it observes at start, which is
# correct only if the display is already in a good state. A previous run that
# failed mid-revert leaves the display changed, and the next run then captures
# THAT as the original and faithfully restores the desktop to a broken value.
# One flake becomes permanent. So refuse to run from a state that does not match
# what the config says, rather than laundering it.
shipped_scale="$(sed -n 's/^local shipped_scale *= *\([0-9.]*\).*/\1/p' \
"$repo_dir/config/dot/hypr/monitors.lua" | head -1)"
[[ -n "$shipped_scale" ]] || fail 'could not read the shipped scale from monitors.lua -- the guard below depends on it, and skipping it silently is how a dirty baseline gets laundered'
if [[ -n "$shipped_scale" ]]; then
live_scale="$(hyprctl -j monitors | jq -r '.[0].scale')"
if ! awk -v a="$live_scale" -v b="$shipped_scale" 'BEGIN { exit !(a == b) }'; then
fail "the display is at scale $live_scale but the config ships $shipped_scale -- refusing to capture a dirty state as the baseline. Restore it first: hyprctl eval 'hl.monitor({ output = \"DP-2\", mode = \"4500x3000@60\", scale = $shipped_scale, transform = 0 })'"
fi
fi
original_mode="" original_mode=""
original_scale="" original_scale=""
original_transform="" original_transform=""
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Every pinned application must resolve to an installed desktop entry.
#
# DockBody drops a pin it cannot resolve rather than drawing a broken icon,
# which is the right behaviour at runtime and a terrible one to debug: a typo in
# the shipped list, or an application that changed its desktop id, simply
# removes an icon from the dock with nothing logged.
#
# Also asserts the Settings entry resolves and carries Panama's own icon, since
# it is the first pin and the one most likely to be wrong after a rename.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/dock-pin-harness.qml"
config_home="$(mktemp -d /tmp/panama-dockpins.XXXXXX)"
fail() {
printf 'dock pins contract: %s\n' "$1" >&2
exit 1
}
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
harness_pid=""
cleanup() {
# By PID, never `pkill -f dock-pin-harness`: that pattern also matches the
# shell running this script.
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
rm -rf "$config_home"
}
trap cleanup EXIT
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
run ipc show 2>/dev/null | rg -q '^target dock-pin-test$' && break
sleep 0.1
done
run ipc show 2>/dev/null | rg -q '^target dock-pin-test$' || fail 'test IPC target did not start'
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
# DesktopEntries populates asynchronously; asking before it has finished reports
# every pin as missing, which is a false failure rather than a real one.
loaded=0
for _ in $(seq 1 60); do
loaded="$(run ipc call dock-pin-test count 2>/dev/null || printf 0)"
[[ "$loaded" =~ ^[0-9]+$ ]] && (( loaded > 0 )) && break
sleep 0.25
done
(( loaded > 0 )) || fail 'no desktop entries were discovered at all'
state="$(run ipc call dock-pin-test resolve)"
missing="$(jq -r '.missing | join(", ")' <<<"$state")"
[[ -z "$missing" ]] || fail "these pinned applications do not resolve and would vanish from the dock: $missing"
jq -e '.first.id == "panama-settings" and .first.found == true' <<<"$state" >/dev/null \
|| fail "Panama Settings is not the first pin, or does not resolve: $(jq -c .first <<<"$state")"
jq -e '.first.icon == "panama-settings"' <<<"$state" >/dev/null \
|| fail "Settings is not using Panama's own icon: $(jq -r .first.icon <<<"$state")"
icon="$HOME/.local/share/icons/hicolor/scalable/apps/panama-settings.svg"
[[ -r "$icon" ]] || fail "the icon it names is not installed at $icon"
trap - EXIT
cleanup
printf 'dock pins contract: PASS (%s pins, all resolve)\n' "$(jq -r .total <<<"$state")"
+14
View File
@@ -13,6 +13,20 @@ cleanup() {
trap cleanup EXIT trap cleanup EXIT
qs ipc call focus end >/dev/null 2>&1 || true qs ipc call focus end >/dev/null 2>&1 || true
# focus start records the caffeine state it found so end() can hand it back, and
# the assertion below requires caffeine to finish off. Both are only true if
# caffeine starts off.
#
# This test kills and restarts the live shell, so an interrupted run leaves
# caffeine on with nothing to turn it back off. The next run then records
# "previously on", faithfully restores it, and fails -- and every run after
# that fails identically, with the desktop quietly unable to idle or lock the
# whole time. Refuse to start from that state rather than latching it in.
if [[ "$(qs ipc call caffeine status 2>/dev/null)" != "false" ]]; then
fail 'caffeine is on before the session starts -- this test cannot distinguish "restored correctly" from "never released". Turn it off first: qs ipc call caffeine toggle'
fi
qs ipc call focus start >/dev/null qs ipc call focus start >/dev/null
state_file=$(find "${XDG_STATE_HOME:-$HOME/.local/state}/quickshell/by-shell" -name focus-session.json -print -quit) state_file=$(find "${XDG_STATE_HOME:-$HOME/.local/state}/quickshell/by-shell" -name focus-session.json -print -quit)
+268
View File
@@ -0,0 +1,268 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-osd"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin"
log="$scratch/calls"
cat >"$scratch/bin/wpctl" <<'SH'
#!/bin/bash
printf 'wpctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ $1 == "get-volume" ]]; then
printf '%s\n' "${WPCTL_OUTPUT:-Volume: 0.58}"
fi
SH
cat >"$scratch/bin/brightnessctl" <<'SH'
#!/bin/bash
printf 'brightnessctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
fi
SH
cat >"$scratch/bin/panama-brightness" <<'SH'
#!/bin/bash
printf 'panama-brightness' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
case "${1:-}" in
list)
if [[ -n ${DDC_LIST_JSON:-} ]]; then
printf '%s\n' "$DDC_LIST_JSON"
else
printf '%s\n' '{"displays":[],"error":"No displays"}'
fi
;;
get)
[[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1
if [[ -s $OSD_DDC_STATE ]]; then
cat "$OSD_DDC_STATE"
else
printf '%s\n' "${DDC_GET_VALUE:-40}"
fi
;;
set)
if [[ -n ${DDC_SET_DELAY:-} ]]; then
if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then
printf 'ddc-overlap\n' >>"$OSD_TEST_LOG"
fi
sleep "$DDC_SET_DELAY"
rmdir "$OSD_DDC_PROBE" 2>/dev/null || true
fi
printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE"
;;
*) exit 2 ;;
esac
SH
cat >"$scratch/bin/hyprctl" <<'SH'
#!/bin/bash
printf 'hyprctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}"
SH
cat >"$scratch/bin/notify-send" <<'SH'
#!/bin/bash
printf 'notify-send' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
SH
cat >"$scratch/bin/playerctl" <<'SH'
#!/bin/bash
printf 'playerctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ $1 == "metadata" ]]; then
printf '%s\n' "${PLAYER_OUTPUT:-Horizon — Tycho}"
elif [[ $1 == "status" ]]; then
printf '%s\n' "${PLAYER_STATUS:-Playing}"
fi
SH
cat >"$scratch/bin/qs" <<'SH'
#!/bin/bash
printf 'qs' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
[[ ${OSD_TEST_FAIL_QS:-false} != true ]]
SH
chmod +x "$scratch/bin/"*
run_helper() {
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
mkdir -p "$runtime"
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
PANAMA_OSD_RUNTIME_DIR="$runtime" \
OSD_DDC_STATE="$runtime/ddc-state" \
OSD_DDC_PROBE="$runtime/ddc-probe" \
BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \
DDC_LIST_JSON="${DDC_LIST_JSON:-}" \
DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \
DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \
DDC_SET_DELAY="${DDC_SET_DELAY:-}" \
FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \
"$helper" "$@"
}
assert_line() {
local expected="$1"
grep -Fqx -- "$expected" "$log" || {
printf 'osd helper contract: missing call\n%s\nactual:\n' "$expected" >&2
cat "$log" >&2
exit 1
}
}
: >"$log"
run_helper volume up 6
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
assert_line 'wpctl <get-volume> <@DEFAULT_AUDIO_SINK@>'
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
: >"$log"
WPCTL_OUTPUT='Volume: 0.58 [MUTED]' run_helper volume toggle
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SINK@> <toggle>'
assert_line 'qs <ipc> <call> <osd> <progress> <volume-muted> <58> <100> <Muted>'
: >"$log"
WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SOURCE@> <toggle>'
assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Muted>'
: >"$log"
run_helper brightness up 5
assert_line 'brightnessctl <-m> <-c> <backlight>'
assert_line 'brightnessctl <-e4> <-n2> <-c> <backlight> <set> <5%+>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
if grep -Fq 'panama-brightness' "$log"; then
printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2
exit 1
fi
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5
assert_line 'hyprctl <-j> <monitors>'
assert_line 'panama-brightness <list>'
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <45>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <45> <100> <45%>'
# A cached bus avoids the expensive display scan on subsequent key presses.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \
run_helper brightness down 5
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <40>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <40> <100> <40%>'
if grep -Fq 'panama-brightness <list>' "$log"; then
printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2
exit 1
fi
# A disconnected cached monitor is discarded and rediscovered once.
mkdir -p "$scratch/runtime-ddc-stale"
printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus"
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \
BACKLIGHT_AVAILABLE=false \
DDC_FAIL_GET_BUS=9 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5
assert_line 'panama-brightness <get> <9>'
assert_line 'panama-brightness <list>'
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <45>'
# If the focused output is not DDC-capable, use the first discovered display.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \
BACKLIGHT_AVAILABLE=false \
FOCUSED_MONITOR='eDP-1' \
DDC_GET_VALUE=35 \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness down 10
assert_line 'panama-brightness <get> <3>'
assert_line 'panama-brightness <set> <3> <25>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <25> <100> <25%>'
# Permission and discovery errors must be visible, never masquerade as 0%.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \
run_helper brightness up 5
assert_line 'qs <ipc> <call> <osd> <message> <dialog-warning-symbolic> <Brightness needs permission>'
assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> <Brightness unavailable> <Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm>'
if grep -Fq 'osd> <progress> <brightness>' "$log"; then
printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2
exit 1
fi
# Separate key-repeat processes must not overlap their DDC transactions.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
BACKLIGHT_AVAILABLE=false \
DDC_SET_DELAY=0.15 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5 &
first_pid=$!
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
BACKLIGHT_AVAILABLE=false \
DDC_SET_DELAY=0.15 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5 &
second_pid=$!
wait "$first_pid"
wait "$second_pid"
if grep -Fqx 'ddc-overlap' "$log"; then
printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2
exit 1
fi
if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then
printf 'osd helper contract: serialized key repeats did not both apply\n' >&2
exit 1
fi
: >"$log"
run_helper media next
assert_line 'playerctl <next>'
assert_line 'qs <ipc> <call> <osd> <message> <media-next> <Horizon — Tycho>'
: >"$log"
run_helper message notifications-disabled-symbolic 'Do Not Disturb On'
assert_line 'qs <ipc> <call> <osd> <message> <notifications-disabled-symbolic> <Do Not Disturb On>'
: >"$log"
if OSD_TEST_FAIL_QS=true PANAMA_OSD_STRICT=1 run_helper message dialog-error-symbolic 'Strict failure'; then
printf 'osd helper contract: strict delivery swallowed an IPC failure\n' >&2
exit 1
fi
: >"$log"
OSD_TEST_FAIL_QS=true run_helper message dialog-information-symbolic 'Best effort'
printf 'osd helper contract: PASS\n'
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
model="$repo_dir/config/dot/quickshell/modules/osd/OsdModel.js"
node - "$model" <<'JS'
const assert = require('node:assert/strict')
const model = require(process.argv[2])
assert.equal(model.iconFor('volume', 0), 'audio-volume-muted-symbolic')
assert.equal(model.iconFor('volume', 0.2), 'audio-volume-low-symbolic')
assert.equal(model.iconFor('volume', 0.5), 'audio-volume-medium-symbolic')
assert.equal(model.iconFor('volume', 0.9), 'audio-volume-high-symbolic')
assert.equal(model.iconFor('microphone-muted', 0.7), 'microphone-sensitivity-muted-symbolic')
assert.equal(model.iconFor('brightness', 0.4), 'display-brightness-symbolic')
assert.deepEqual(
model.progressState('volume', 140, 100, '', 900),
{
kind: 'volume',
value: 100,
maximum: 100,
ratio: 1,
label: '100%',
icon: 'audio-volume-high-symbolic',
duration: 900,
progress: true
}
)
assert.deepEqual(
model.progressState('volume-muted', 43, 100, 'Muted', -50),
{
kind: 'volume-muted',
value: 43,
maximum: 100,
ratio: 0.43,
label: 'Muted',
icon: 'audio-volume-muted-symbolic',
duration: 0,
progress: true
}
)
assert.deepEqual(
model.messageState('media-next', 'Glass Beams', 'invalid'),
{
kind: 'media-next',
value: 0,
maximum: 100,
ratio: 0,
label: 'Glass Beams',
icon: 'media-skip-forward-symbolic',
duration: 1400,
progress: false
}
)
console.log('osd model contract: PASS')
JS
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
qs_dir="$repo_dir/config/dot/quickshell"
osd="$qs_dir/modules/osd/Osd.qml"
state="$qs_dir/services/OsdState.qml"
shell_file="$qs_dir/shell.qml"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
fail() {
printf 'osd ui contract: %s\n' "$1" >&2
exit 1
}
[[ -f "$osd" ]] || fail 'Prism OSD surface is missing'
[[ -f "$state" ]] || fail 'OSD presentation state is missing'
[[ -f "$qs_dir/modules/osd/qmldir" ]] || fail 'OSD module manifest is missing'
rg -q '^import Quickshell$' "$state" \
|| fail 'OSD singleton does not import the Quickshell Singleton type'
rg -Fq 'import qs.modules.osd' "$shell_file" || fail 'shell does not import the OSD module'
[[ "$(rg -c '^[[:space:]]*Osd \{\}' "$shell_file")" -eq 1 ]] \
|| fail 'shell does not create exactly one OSD per screen'
rg -Fq 'target: "osd"' "$shell_file" || fail 'OSD IPC target is missing'
rg -Fq 'OsdState.progress(kind, value, maximum, label)' "$shell_file" \
|| fail 'progress IPC is not wired to OSD state'
rg -Fq 'OsdState.message(kind, label)' "$shell_file" \
|| fail 'message IPC is not wired to OSD state'
rg -Fq 'WlrLayershell.namespace: "qs-popover-osd"' "$osd" \
|| fail 'OSD does not use the existing Prism blur namespace'
rg -Fq 'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' "$osd" \
|| fail 'OSD may steal keyboard focus'
rg -Fq 'mask: Region {}' "$osd" || fail 'OSD may intercept pointer input'
rg -Fq 'PrismEdge {' "$osd" || fail 'OSD is missing the Prism signature edge'
rg -Fq 'font.features: Theme.tabularFigures' "$osd" \
|| fail 'changing percentages do not use tabular figures'
rg -Fq '$HOME/.config/quickshell/scripts/panama-osd' "$keybinds" \
|| fail 'keybinds do not use the deployed Panama OSD helper'
for action in \
'volume up 6' \
'volume down 6' \
'volume toggle' \
'microphone toggle' \
'volume up 1' \
'volume down 1' \
'media play-pause' \
'media next' \
'media previous' \
'media stop' \
'brightness up 5' \
'brightness down 5'; do
rg -Fq "osd(\"$action\")" "$keybinds" \
|| fail "keybind is not routed through panama-osd $action"
done
printf 'osd ui contract: PASS\n'
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
dispatcher="$repo_dir/config/dot/quickshell/scripts/panama-action"
work="$(mktemp -d /tmp/panama-action.XXXXXX)"
fake_bin="$work/bin"
command_log="$work/commands.log"
fail() {
printf 'Panama action contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
rm -rf "$work"
}
trap cleanup EXIT
[[ -x "$dispatcher" ]] || fail 'dispatcher is missing or not executable'
mkdir -p "$fake_bin" "$work/home/.config/quickshell/scripts"
make_recorder() {
local command_name="$1"
cp /dev/stdin "$fake_bin/$command_name"
chmod +x "$fake_bin/$command_name"
}
make_recorder qs <<'EOF'
#!/usr/bin/env bash
printf 'qs' >>"$PANAMA_ACTION_TEST_LOG"
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
case "$*" in
'ipc call notifications dnd'|'ipc call caffeine toggle'|'ipc call night-light toggle')
printf '%s\n' "${PANAMA_ACTION_TEST_STATE:-true}"
;;
'ipc call focus start'|'ipc call focus end')
printf '%s\n' "${PANAMA_ACTION_TEST_FOCUS_STATE:-true}"
;;
esac
if [[ ${PANAMA_ACTION_TEST_FAIL_KILL:-false} == true && ${1:-} == kill ]]; then
exit 1
fi
[[ ${PANAMA_ACTION_TEST_FAIL_QS:-false} != true ]]
EOF
make_recorder quickshell <<'EOF'
#!/usr/bin/env bash
printf 'quickshell' >>"$PANAMA_ACTION_TEST_LOG"
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
EOF
make_recorder panama-osd <<'EOF'
#!/usr/bin/env bash
printf 'panama-osd' >>"$PANAMA_ACTION_TEST_LOG"
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
[[ ${PANAMA_ACTION_TEST_FAIL_OSD:-false} != true ]]
EOF
make_recorder panama-prism-gallery <<'EOF'
#!/usr/bin/env bash
printf 'panama-prism-gallery' >>"$PANAMA_ACTION_TEST_LOG"
if (( $# > 0 )); then
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
fi
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
EOF
make_recorder notify-send <<'EOF'
#!/usr/bin/env bash
printf 'notify-send' >>"$PANAMA_ACTION_TEST_LOG"
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
EOF
make_recorder systemd-inhibit <<'EOF'
#!/usr/bin/env bash
if [[ -n ${PANAMA_ACTION_TEST_INHIBITOR_FILE:-} && -s $PANAMA_ACTION_TEST_INHIBITOR_FILE ]]; then
while IFS= read -r pid; do
printf 'Panama 1000 gib %s systemd-inhibit sleep:idle Caffeine block\n' "$pid"
done <"$PANAMA_ACTION_TEST_INHIBITOR_FILE"
exit 0
fi
if [[ ${PANAMA_ACTION_TEST_CONFIRMED_STATE:-${PANAMA_ACTION_TEST_STATE:-true}} == true ]]; then
printf 'Panama 1000 gib 42 systemd-inhibit sleep:idle Caffeine block\n'
fi
EOF
make_recorder kill <<'EOF'
#!/usr/bin/env bash
printf 'kill' >>"$PANAMA_ACTION_TEST_LOG"
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
if [[ -n ${PANAMA_ACTION_TEST_INHIBITOR_FILE:-} ]]; then
: >"$PANAMA_ACTION_TEST_INHIBITOR_FILE"
fi
EOF
make_recorder pgrep <<'EOF'
#!/usr/bin/env bash
[[ ${PANAMA_ACTION_TEST_CONFIRMED_STATE:-${PANAMA_ACTION_TEST_STATE:-true}} == true ]]
EOF
make_recorder id <<'EOF'
#!/usr/bin/env bash
printf '1000\n'
EOF
run_action() {
: >"$command_log"
HOME="$work/home" PATH="$fake_bin:$PATH" \
PANAMA_ACTION_HELPER_DIR="$fake_bin" \
PANAMA_ACTION_KILL_COMMAND="$fake_bin/kill" \
PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \
PANAMA_ACTION_CONFIRM_DELAY=0 \
PANAMA_ACTION_TEST_LOG="$command_log" \
"$dispatcher" "$1"
}
assert_commands() {
local expected="$1"
local actual
actual="$(cat "$command_log")"
[[ "$actual" == "$expected" ]] \
|| fail "wrong command sequence; expected [$expected], got [$actual]"
}
run_action control-center
assert_commands 'qs <ipc> <call> <quicksettings> <open>'
run_action notifications
assert_commands 'qs <ipc> <call> <notifications> <open>'
run_action calendar
assert_commands 'qs <ipc> <call> <calendar-agenda> <open>'
run_action clipboard
assert_commands 'qs <ipc> <call> <clipboard> <open>'
run_action overview
assert_commands 'qs <ipc> <call> <overview> <open>'
run_action settings
assert_commands 'qs <ipc> <call> <settings> <open>'
run_action dnd
assert_commands $'qs <ipc> <call> <notifications> <dnd>\npanama-osd <message> <notifications-disabled-symbolic> <Do Not Disturb On>'
PANAMA_ACTION_TEST_STATE=false run_action caffeine
assert_commands $'qs <ipc> <call> <caffeine> <toggle>\npanama-osd <message> <weather-clear-night-symbolic> <Caffeine Off>'
printf '4242\n' >"$work/inhibitors"
PANAMA_ACTION_TEST_STATE=false PANAMA_ACTION_TEST_INHIBITOR_FILE="$work/inhibitors" run_action caffeine
assert_commands $'qs <ipc> <call> <caffeine> <toggle>\nkill <4242>\npanama-osd <message> <weather-clear-night-symbolic> <Caffeine Off>'
run_action night-light
assert_commands $'qs <ipc> <call> <night-light> <toggle>\npanama-osd <message> <night-light-symbolic> <Night Light On>'
run_action focus-start
assert_commands $'qs <ipc> <call> <focus> <start>\npanama-osd <message> <preferences-system-time-symbolic> <Focus Session Started>'
run_action focus-end
assert_commands $'qs <ipc> <call> <focus> <end>\npanama-osd <message> <media-playback-stop-symbolic> <Focus Session Ended>'
PANAMA_ACTION_TEST_FOCUS_STATE=false run_action focus-end
assert_commands $'qs <ipc> <call> <focus> <end>\npanama-osd <message> <dialog-information-symbolic> <No Focus Session Running>'
run_action capture
assert_commands 'qs <ipc> <call> <capture> <open>'
run_action intelligence
assert_commands 'qs <ipc> <call> <screen-intelligence> <open>'
run_action screenshot
assert_commands 'qs <ipc> <call> <capture> <screenNow>'
run_action microphone
assert_commands 'panama-osd <microphone> <toggle>'
run_action gallery
assert_commands 'panama-prism-gallery'
run_action restart-shell
assert_commands $'qs <kill>\nquickshell <--daemonize>\nqs <ipc> <show>\npanama-osd <message> <view-refresh-symbolic> <Panama Restarted>'
PANAMA_ACTION_TEST_FAIL_KILL=true run_action restart-shell
assert_commands $'qs <kill>\nquickshell <--daemonize>\nqs <ipc> <show>\npanama-osd <message> <view-refresh-symbolic> <Panama Restarted>'
if HOME="$work/home" PATH="$fake_bin:$PATH" PANAMA_ACTION_HELPER_DIR="$fake_bin" \
PANAMA_ACTION_CONFIRM_ATTEMPTS=1 PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
"$dispatcher" definitely-not-an-action >/dev/null 2>&1; then
fail 'unknown action was accepted'
fi
: >"$command_log"
if PANAMA_ACTION_TEST_FAIL_QS=true HOME="$work/home" PATH="$fake_bin:$PATH" \
PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \
PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
"$dispatcher" control-center >/dev/null 2>&1; then
fail 'failed shell action returned success'
fi
assert_commands $'qs <ipc> <call> <quicksettings> <open>\nnotify-send <-a> <Panama> <-i> <dialog-error-symbolic> <Panama action failed> <The “control center” action could not be completed.>'
: >"$command_log"
if PANAMA_ACTION_TEST_FAIL_OSD=true HOME="$work/home" PATH="$fake_bin:$PATH" \
PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \
PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
"$dispatcher" microphone >/dev/null 2>&1; then
fail 'failed Prism OSD delivery returned success'
fi
assert_commands $'panama-osd <microphone> <toggle>\nnotify-send <-a> <Panama> <-i> <dialog-error-symbolic> <Panama action failed> <The “microphone” action could not be completed.>'
: >"$command_log"
if PANAMA_ACTION_TEST_CONFIRMED_STATE=false HOME="$work/home" PATH="$fake_bin:$PATH" \
PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \
PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
"$dispatcher" caffeine >/dev/null 2>&1; then
fail 'unconfirmed Caffeine state returned success'
fi
grep -Fqx 'notify-send <-a> <Panama> <-i> <dialog-error-symbolic> <Panama action failed> <The “caffeine” action could not be completed.>' "$command_log" \
|| fail 'unconfirmed state did not send a failure notification'
printf 'Panama action contract: PASS\n'
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'Panama action IPC contract: %s\n' "$1" >&2
exit 1
}
active_target=""
original_state=""
night_light_snapshot=""
restore_target() {
local target="$1" expected="$2" current
current="$(qs ipc call "$target" status 2>/dev/null || true)"
if [[ $current != "$expected" && ( $expected == true || $expected == false ) ]]; then
qs ipc call "$target" toggle >/dev/null 2>&1 || true
fi
}
cleanup() {
if [[ -n $active_target ]]; then
restore_target "$active_target" "$original_state"
fi
if [[ -n $night_light_snapshot ]]; then
local enabled automatic
enabled="$(jq -r '.enabled' <<<"$night_light_snapshot")"
automatic="$(jq -r '.automatic' <<<"$night_light_snapshot")"
qs ipc call night-light restore "$enabled" "$automatic" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
exercise_toggle() {
local target="$1" before after restored
before="$(qs ipc call "$target" status)"
active_target="$target"
original_state="$before"
[[ $before == true || $before == false ]] \
|| fail "$target returned a non-boolean status: $before"
after="$(qs ipc call "$target" toggle)"
[[ $after != "$before" ]] \
|| fail "$target did not change state: before=$before after=$after"
restored="$(qs ipc call "$target" toggle)"
[[ $restored == "$before" ]] \
|| fail "$target did not restore its original state: before=$before restored=$restored"
active_target=""
original_state=""
}
ipc="$(qs ipc show)"
rg -q '^target caffeine$' <<<"$ipc" || fail 'caffeine IPC target is missing'
rg -q '^target night-light$' <<<"$ipc" || fail 'night-light IPC target is missing'
exercise_toggle caffeine
night_light_snapshot="$(qs ipc call night-light settings)"
jq -e '.enabled | type == "boolean"' <<<"$night_light_snapshot" >/dev/null \
|| fail 'night-light settings omitted enabled state'
jq -e '.automatic | type == "boolean"' <<<"$night_light_snapshot" >/dev/null \
|| fail 'night-light settings omitted automatic state'
exercise_toggle night-light
original_enabled="$(jq -r '.enabled' <<<"$night_light_snapshot")"
original_automatic="$(jq -r '.automatic' <<<"$night_light_snapshot")"
qs ipc call night-light restore "$original_enabled" "$original_automatic" >/dev/null
restored_snapshot="$(qs ipc call night-light settings)"
[[ "$(jq -r '.enabled' <<<"$restored_snapshot")" == "$original_enabled" ]] \
|| fail 'night-light enabled state was not restored'
[[ "$(jq -r '.automatic' <<<"$restored_snapshot")" == "$original_automatic" ]] \
|| fail 'night-light automatic schedule was not restored'
night_light_snapshot=""
printf 'Panama action IPC contract: PASS\n'
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
installer="$repo_dir/setup/scripts/link-vicinae-scripts"
dotfile_installer="$repo_dir/setup/scripts/link-dotfiles"
top_level_installer="$repo_dir/install"
work="$(mktemp -d /tmp/panama-command-install.XXXXXX)"
data_dir="$work/share/vicinae"
fake_bin="$work/bin"
vicinae_log="$work/vicinae.log"
fail() {
printf 'Panama command install contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
rm -rf "$work"
}
trap cleanup EXIT
[[ -x "$installer" ]] || fail 'Vicinae script installer is missing or not executable'
if rg -q 'setup/scripts/link-vicinae-scripts' "$dotfile_installer"; then
fail 'link-dotfiles also invokes the command installer'
fi
rg -Fq 'do "$script"; done' "$top_level_installer" \
|| fail 'top-level installer sources setup scripts into one shared shell'
mkdir -p "$data_dir/scripts/panama" "$fake_bin"
printf 'user-owned\n' >"$data_dir/scripts/panama/keep.sh"
cat >"$fake_bin/vicinae" <<'EOF'
#!/usr/bin/env bash
printf 'vicinae' >>"$PANAMA_VICINAE_TEST_LOG"
printf ' <%s>' "$@" >>"$PANAMA_VICINAE_TEST_LOG"
printf '\n' >>"$PANAMA_VICINAE_TEST_LOG"
[[ ${1:-} == ping || ${1:-} == cmd ]]
EOF
chmod +x "$fake_bin/vicinae"
PATH="$fake_bin:$PATH" PANAMA_PATH="$repo_dir" VICINAE_DATA_DIR="$data_dir" \
PANAMA_VICINAE_TEST_LOG="$vicinae_log" "$installer" >/dev/null
target="$data_dir/scripts/panama"
backup="$data_dir/backups/panama.pre-panama"
[[ -L "$target" ]] || fail 'Panama command directory was not linked'
[[ "$(readlink -f "$target")" == "$(readlink -f "$repo_dir/config/local/share/vicinae/scripts")" ]] \
|| fail 'runtime command link points outside the tracked source'
[[ -f "$backup/keep.sh" ]] || fail 'pre-existing user command directory was not preserved'
[[ "$(cat "$backup/keep.sh")" == user-owned ]] || fail 'preserved user command changed'
[[ ! -e "$data_dir/scripts/panama.pre-panama" ]] \
|| fail 'preserved commands remained inside Vicinae search paths'
expected_calls=$'vicinae <ping>\nvicinae <cmd> <launch> <core:reload-scripts>'
[[ "$(cat "$vicinae_log")" == "$expected_calls" ]] \
|| fail "Vicinae reload sequence was wrong: $(cat "$vicinae_log")"
# A second setup pass must update the same narrow link without creating more
# backups or disturbing the preserved directory.
: >"$vicinae_log"
PATH="$fake_bin:$PATH" PANAMA_PATH="$repo_dir" VICINAE_DATA_DIR="$data_dir" \
PANAMA_VICINAE_TEST_LOG="$vicinae_log" "$installer" >/dev/null
[[ -L "$target" ]] || fail 'second install did not leave the command link intact'
[[ -f "$backup/keep.sh" ]] || fail 'second install disturbed the original backup'
[[ ! -e "$data_dir/backups/panama.pre-panama.pre-panama" ]] \
|| fail 'second install created a duplicate backup'
printf 'Panama command install contract: PASS\n'
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
commands_dir="$repo_dir/config/local/share/vicinae/scripts"
work="$(mktemp -d /tmp/panama-commands.XXXXXX)"
dispatch_log="$work/dispatch.log"
fail() {
printf 'Panama commands contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
rm -rf "$work"
}
trap cleanup EXIT
declare -A expected=(
[open-control-center.sh]=control-center
[open-notifications.sh]=notifications
[open-calendar.sh]=calendar
[open-clipboard.sh]=clipboard
[open-mission-control.sh]=overview
[open-settings.sh]=settings
[toggle-dnd.sh]=dnd
[toggle-caffeine.sh]=caffeine
[toggle-night-light.sh]=night-light
[start-focus.sh]=focus-start
[end-focus.sh]=focus-end
[capture.sh]=capture
[screen-intelligence.sh]=intelligence
[quick-screenshot.sh]=screenshot
[toggle-microphone.sh]=microphone
[open-prism-gallery.sh]=gallery
[restart-shell.sh]=restart-shell
)
mkdir -p "$work/home/.config/quickshell/scripts"
cat >"$work/home/.config/quickshell/scripts/panama-action" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$PANAMA_COMMAND_TEST_LOG"
EOF
chmod +x "$work/home/.config/quickshell/scripts/panama-action"
[[ -d "$commands_dir" ]] || fail 'Vicinae command directory is missing'
mapfile -t actual_files < <(find "$commands_dir" -maxdepth 1 -type f -name '*.sh' -printf '%f\n' | sort)
[[ ${#actual_files[@]} -eq ${#expected[@]} ]] \
|| fail "expected ${#expected[@]} commands, found ${#actual_files[@]}"
declare -A seen_titles=()
for script_name in "${!expected[@]}"; do
script="$commands_dir/$script_name"
[[ -x "$script" ]] || fail "$script_name is missing or not executable"
vicinae script check "$script" >/dev/null \
|| fail "$script_name is rejected by Vicinae"
title="$(sed -n 's/^# @vicinae.title //p' "$script")"
[[ $title == 'Panama: '* ]] || fail "$script_name has an ungrouped title: $title"
[[ -z ${seen_titles[$title]+x} ]] || fail "duplicate launcher title: $title"
seen_titles[$title]=1
grep -Fxq '# @vicinae.mode silent' "$script" \
|| fail "$script_name does not close quietly after success"
grep -Fq '# @vicinae.description ' "$script" \
|| fail "$script_name has no searchable description"
grep -Fq '# @vicinae.keywords ' "$script" \
|| fail "$script_name has no search vocabulary"
grep -Fxq '# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg' "$script" \
|| fail "$script_name does not use the Panama application identity"
: >"$dispatch_log"
HOME="$work/home" PANAMA_COMMAND_TEST_LOG="$dispatch_log" "$script"
dispatched="$(cat "$dispatch_log")"
[[ $dispatched == "${expected[$script_name]}" ]] \
|| fail "$script_name dispatched [$dispatched], expected [${expected[$script_name]}]"
done
printf 'Panama commands contract: PASS (%d commands)\n' "${#expected[@]}"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
gallery="$repo_dir/config/dot/quickshell/prism-gallery.qml"
launcher="$repo_dir/config/dot/quickshell/scripts/panama-prism-gallery"
fail() {
printf 'Prism gallery contract: %s\n' "$1" >&2
exit 1
}
[[ -f "$gallery" ]] || fail 'the real-component gallery is missing'
[[ -x "$launcher" ]] || fail 'the gallery launcher is missing or not executable'
rg -Fq 'import qs.widgets' "$gallery" || fail 'gallery does not use the shared widget module'
for component in Pill Toggle ValueSlider ThemedIcon PrismEdge; do
rg -q "^[[:space:]]*${component}[[:space:]]*\\{" "$gallery" \
|| fail "gallery does not render the real ${component} component"
done
rg -Fq 'title: "Panama Prism Gallery"' "$gallery" \
|| fail 'gallery does not expose a stable compositor window title'
rg -Fq 'FloatingWindow {' "$gallery" \
|| fail 'gallery must be a normal tiled application window'
if rg -q '#[0-9a-fA-F]{3,8}' "$gallery"; then
fail 'gallery hardcodes a colour instead of using the Theme vocabulary'
fi
if rg -q 'loops:[[:space:]]*(Animation\.Infinite|-1)|running:[[:space:]]*true' "$gallery"; then
fail 'gallery contains an always-running animation'
fi
rg -Fq 'qs -p "$gallery" --daemonize' "$launcher" \
|| fail 'launcher does not start the isolated Quickshell gallery config'
rg -Fq 'qs -p "$gallery" kill' "$launcher" \
|| fail 'launcher cannot close an already-open gallery instance'
printf 'Prism gallery contract: PASS\n'