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 94353aa0bd Close the gaps the cross-UI audit found
Audited bar, dock, quick settings, date menu and Settings for three
things: a setting reachable in one UI but not another, a setting that
exists but is unreachable anywhere, and UI that states something false.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Four defects found while building:

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

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

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

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

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:24:51 -04:00
Gabriel Brown 8a04e4f9d1 Record live Home accessory verification 2026-08-17 23:33:11 -04:00
Gabriel Brown 5347954a88 Merge branch 'feat/home-accessories-customization'
# Conflicts:
#	config/dot/quickshell/config/qmldir
#	config/dot/quickshell/services/SystemSettings.qml
#	tests/quickshell/settings-pages-contract.sh
2026-08-17 23:31:33 -04:00
Gabriel Brown 00a81edadd Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because
they had been typed to agree: looks.lua hardcoded values, DesktopPreferences
independently defaulted the same values, and SystemSettings replayed them at
startup. Nothing kept them in sync, and the Lua side read no shared state at
all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl
keyword refuses the write, prints the refusal to stdout, and still exits 0, so
the HDR, VRR, and direct-scanout toggles persisted their value and reported
success while the compositor never changed. Writes now go through hyprctl eval,
which has the same hazard on syntax and runtime errors, so success is defined
as reading the value back and finding it equal. The existing contract passed
throughout the outage because it re-applied the values already in place; the
new one flips each value to something it does not hold.

Derive preferences from a schema. Every setting used to be restated four times
-- a property alias, a JSON adapter property, a change handler, and a line in
reset -- where omitting any one failed silently. PreferenceSchema.qml is now
the single source, and persistence, validation, reset, and the Hyprland mapping
all derive from it. Unknown keys on disk survive a write so a rollback does not
discard a newer build's settings, and a corrupt file falls back to shipped
defaults. The store moved to ~/.config/panama/settings.json, migrating from the
old state directory without deleting it.

Share that file with Hyprland. prefs.lua reads it at config time with every
shipped literal kept as the fallback, so the config still stands alone. The Lua
is the default, the JSON is the truth, and Settings is the editor. The
compositor-adjustable surface goes from 3 keys to 23.

Also fixes two test-hygiene bugs found by running the suite end to end for the
first time: settings-pages-contract could see the window settings-window-contract
leaves behind, and the new write contract was persisting its deliberately-wrong
values into the user's real store.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-17 23:26:56 -04:00
Gabriel Brown 4bd3638012 Cover partial Phone capabilities 2026-08-17 19:50:27 -04:00
Gabriel Brown 7f540a58e5 Avoid visual Phone harness teardown 2026-08-17 19:42:15 -04:00
Gabriel Brown 7ac5355a77 Record focused verification completion 2026-08-17 19:26:57 -04:00
Gabriel Brown a9eb90f457 Fix Home Assistant composition contract 2026-08-17 19:21:17 -04:00
Gabriel Brown b5c9156e64 Document Home accessory customization 2026-08-17 19:18:45 -04:00
Gabriel Brown 3213a0989a Fix Phone controls runtime accessibility 2026-08-17 19:01:42 -04:00
Gabriel Brown fbf729b25c Add BlueBubbles to Phone controls 2026-08-17 18:48:28 -04:00
Gabriel Brown e37df9d838 Fix Home shelf slider interactions 2026-08-17 18:34:48 -04:00
Gabriel Brown 673aac6aba Build the Home accessory shelf 2026-08-17 18:22:27 -04:00
Gabriel Brown c8eb344a0b Fix Home and Phone settings states 2026-08-17 18:02:09 -04:00
Gabriel Brown 65966200fe Add Home and Phone settings 2026-08-17 17:48:05 -04:00
Gabriel Brown 2130d6a1a5 Block Home actions during fixture transitions 2026-08-17 17:27:43 -04:00
Gabriel Brown 9a99c6064e Fix nested fixture transition draining 2026-08-17 17:16:36 -04:00
Gabriel Brown 4f93522f7d Fix fixture process transitions 2026-08-17 16:59:15 -04:00
Gabriel Brown a325ec807e Fix Home action completion and test isolation 2026-08-17 16:49:50 -04:00
Gabriel Brown c2b19f7545 Compose Home catalog with accessory preferences 2026-08-17 16:35:41 -04:00
Gabriel Brown 925fc0276f Persist Home accessory preferences 2026-08-17 16:24:09 -04:00
Gabriel Brown 65a8197419 Fix Home Assistant catalog QML compatibility 2026-08-17 16:06:05 -04:00
Gabriel Brown ff67387c42 Add Home Assistant light catalog and dimming 2026-08-17 16:01:50 -04:00
Gabriel Brown c42794c5e2 Ignore local feature worktrees 2026-08-17 15:53:11 -04:00
Gabriel Brown 0964af7b73 Plan Home accessory customization 2026-08-17 15:49:20 -04:00
Gabriel Brown 15fb008954 Design Home accessory customization 2026-08-17 14:36:30 -04:00
Gabriel Brown f4c3ffed43 Document the Panama Control Center 2026-08-17 13:47:50 -04:00
Gabriel Brown 5f965daa6a Sanitize Control Center test fixtures 2026-08-17 13:44:23 -04:00
Gabriel Brown 5940d6b4bd Build the Panama Control Center 2026-08-17 13:44:18 -04:00
Gabriel Brown 190e3200e0 Keep offline phone capabilities visible 2026-08-17 13:42:31 -04:00
Gabriel Brown 49ec4d8f6a Add Control Center integration services 2026-08-17 13:31:27 -04:00
Gabriel Brown 173c00fca7 Add the Panama Home Assistant bridge 2026-08-17 13:27:11 -04:00
Gabriel Brown 3a903934c0 Add the Panama KDE Connect bridge 2026-08-17 13:22:31 -04:00
Gabriel Brown dc6da5af34 Plan the Panama Control Center 2026-08-17 13:18:21 -04:00
Gabriel Brown 922395d2b4 Design the Panama Control Center 2026-08-17 13:01:42 -04:00
Gabriel Brown a5450fdb6d Add Ongoing to the Panama Daybook 2026-08-17 12:18:04 -04:00
Gabriel Brown 1ce5c732b7 Document the Panama calendar agenda 2026-08-17 11:49:41 -04:00
Gabriel Brown f9cc15fb96 Fix GNOME Calendar date handoff 2026-08-17 11:48:11 -04:00
Gabriel Brown 2728fb08fb Make shell restart tests reliable 2026-08-17 11:45:03 -04:00
Gabriel Brown f27a1dca11 Add the quiet calendar capsule 2026-08-17 11:41:58 -04:00
Gabriel Brown 03914e23af Build the Prism calendar Daybook 2026-08-17 11:38:24 -04:00
Gabriel Brown 6a1bec849d Route the interactive Panama date menu 2026-08-17 11:32:57 -04:00
Gabriel Brown fb27c5b309 Add live calendar state to Quickshell 2026-08-17 11:31:15 -04:00
Gabriel Brown d37d4a87de Add the EDS calendar bridge 2026-08-17 11:28:12 -04:00
Gabriel Brown 530a5590a6 Plan the Panama calendar agenda 2026-08-17 11:15:19 -04:00
Gabriel Brown e92c2ee5f9 Design the Panama calendar agenda 2026-08-17 11:04:05 -04:00
Gabriel Brown 5248883e4b Build the Panama Hyprland desktop 2026-08-17 10:32:55 -04:00
375 changed files with 46856 additions and 93 deletions
+10
View File
@@ -6,3 +6,13 @@
/config/wg/** /config/wg/**
# Ignore Neovim lazy-lock file # Ignore Neovim lazy-lock file
/config/dot/nvim/lazy-lock.json /config/dot/nvim/lazy-lock.json
# Local visual-companion sessions and generated design mocks.
/.superpowers/
# Local linked worktrees used for reviewed feature development.
/.worktrees/
# Python helper bytecode is local runtime state.
__pycache__/
*.py[cod]
# Generated from the colour scheme; machine state, not configuration.
/config/dot/kitty/current-theme.conf
+61 -1
View File
@@ -1,2 +1,62 @@
# Panama # Panama
## Formerly Sunhat, this is a personal config for Fedora, with the intention of helping a user set up their Fedora system with one command.
Formerly Sunhat. A personal config for Fedora, with the intention of helping a
user set up their Fedora system with one command.
```sh
git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama
~/.local/share/Panama/install
```
`install` runs everything in `setup/scripts/` in order:
| Script | Does |
|---|---|
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the four package lists in `setup/packages/` |
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>` |
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
Existing configs are moved to `config/old/` rather than overwritten.
## Desktops
Panama configures **two** desktops that coexist. Both sessions stay available in
GDM, so you can switch back and forth while you settle in.
### GNOME
The original setup: Forge for tiling, Dash-to-Dock, Openbar, Vitals,
AppIndicator support. Configured through `config/dot/forge/` and gsettings.
### Hyprland
A from-scratch replacement built to reproduce the GNOME setup closely enough
that muscle memory transfers — same keybinds, same panel contents, same dock,
same Tokyo Night Moon palette.
| Piece | What it is |
|---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
| `config/dot/xdg-desktop-portal/` | Portal backend routing |
**Start here: [`config/dot/hypr/README.md`](config/dot/hypr/README.md)** — it
covers the Lua migration, the uwsm environment gotcha, the HDR decision, the
full keymap, and troubleshooting.
Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland".
## Layout
```
bin/ Small user-facing commands on PATH
config/
bash/ .bashrc, aliases, env (env is gitignored)
copy/ Files copied verbatim over / (needs sudo)
dot/ Symlinked into ~/.config
old/ Backups of whatever was replaced (gitignored)
setup/
packages/ One package per line
scripts/ Run in order by ./install
```
+5
View File
@@ -24,3 +24,8 @@ else
fi fi
export PATH export PATH
fi fi
. "$HOME/.cargo/env"
# >>> Codex installer >>>
export PATH="/home/gib/.local/bin:$PATH"
# <<< Codex installer <<<
@@ -0,0 +1,18 @@
# Stable, colon-free symlinks for the DRM devices.
#
# Why this exists: Hyprland's AQ_DRM_DEVICES selects which GPU renders, but
# - /dev/dri/cardN numbering is assigned at boot and is not stable, and
# - /dev/dri/by-path/ names contain colons, which AQ_DRM_DEVICES itself uses
# as its separator, so they cannot be used.
#
# These rules are keyed to specific PCI addresses and simply do not match on
# other machines; hypr/env.lua checks that the symlink exists before setting
# AQ_DRM_DEVICES, so a non-match is harmless.
#
# Apply without rebooting: sudo udevadm control --reload && sudo udevadm trigger
# desktop-gib: Radeon RX 7800 XT (discrete, drives DP-2)
KERNEL=="card*", KERNELS=="0000:03:00.0", SUBSYSTEM=="drm", SUBSYSTEMS=="pci", SYMLINK+="dri/amd-dgpu"
# desktop-gib: Granite Ridge integrated graphics
KERNEL=="card*", KERNELS=="0000:12:00.0", SUBSYSTEM=="drm", SUBSYSTEMS=="pci", SYMLINK+="dri/amd-igpu"
+4
View File
@@ -67,6 +67,10 @@
{ {
"wmClass": "Spotify", "wmClass": "Spotify",
"mode": "float" "mode": "float"
},
{
"wmClass": "bitwarden",
"mode": "float"
} }
] ]
} }
@@ -107,7 +107,7 @@
} }
.window-floated-border { .window-floated-border {
border-width: 1px; border-width: 3px;
border-color: rgb(98,160,234); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
@@ -29,41 +29,47 @@
} }
.window-tiled-border { .window-tiled-border {
border-width: 4px; border-width: 3px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
.window-split-border { .window-split-border {
border-width: 4px; border-width: 3px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
/* forge-36le: St does not paint an asymmetric (single-side) border when a
* border-radius is set, so the directional hint never rendered. Drop the radius
* on the one-edge variants; St paints a straight single-side border reliably. */
.window-split-horizontal { .window-split-horizontal {
border-left-width: 0; border-left-width: 0;
border-top-width: 0; border-top-width: 0;
border-bottom-width: 0; border-bottom-width: 0;
border-radius: 0;
} }
.window-split-vertical { .window-split-vertical {
border-left-width: 0; border-left-width: 0;
border-top-width: 0; border-top-width: 0;
border-right-width: 0; border-right-width: 0;
border-radius: 0;
} }
.window-stacked-border { .window-stacked-border {
border-width: 4px; border-width: 3px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
.window-tabbed-border { .window-tabbed-border {
border-width: 4px; border-width: 3px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
@@ -74,7 +80,7 @@
.window-tabbed-tab { .window-tabbed-tab {
background-color: rgba(54, 47, 45, 1); background-color: rgba(54, 47, 45, 1);
border-color: rgba(130,170,255,0.6); border-color: rgba(98,160,234,0.6);
border-width: 1px; border-width: 1px;
border-radius: 8px; border-radius: 8px;
color: white; color: white;
@@ -83,7 +89,7 @@
} }
.window-tabbed-tab-active { .window-tabbed-tab-active {
background-color: rgb(130,170,255); background-color: rgb(98,160,234);
color: black; color: black;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
} }
@@ -101,32 +107,84 @@
} }
.window-floated-border { .window-floated-border {
border-width: 4px; border-width: 1px;
border-color: rgb(130,170,255); border-color: rgb(98,160,234);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
} }
.window-tilepreview-tiled { .window-tilepreview-tiled {
border-width: 1px; border-width: 1px;
border-color: rgba(130,170,255,0.3); border-color: rgba(98,160,234,0.3);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
background-color: rgba(130,170,255,0.2); background-color: rgba(98,160,234,0.2);
} }
.window-tilepreview-stacked { .window-tilepreview-stacked {
border-width: 1px; border-width: 1px;
border-color: rgba(130,170,255,0.3); border-color: rgba(98,160,234,0.3);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
background-color: rgba(130,170,255,0.2); background-color: rgba(98,160,234,0.2);
}
.window-tilepreview-swap {
border-width: 1px;
border-color: rgba(162, 247, 43, 0.4);
border-style: solid;
border-radius: 14px;
background-color: rgba(162, 247, 43, 0.4);
} }
.window-tilepreview-tabbed { .window-tilepreview-tabbed {
border-width: 1px; border-width: 1px;
border-color: rgba(130,170,255,0.3); border-color: rgba(98,160,234,0.3);
border-style: solid; border-style: solid;
border-radius: 14px; border-radius: 14px;
background-color: rgba(130,170,255,0.2); background-color: rgba(98,160,234,0.2);
}
/* Keybindings Cheatsheet Overlay */
.forge-cheatsheet {
background-color: #2d2d2d;
border-radius: 16px;
padding: 24px 32px;
border: 1px solid #4d4d4d;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.forge-cheatsheet-title {
font-size: 16px;
font-weight: bold;
color: #ffffff;
margin-bottom: 20px;
text-align: center;
}
.forge-cheatsheet-category {
font-size: 13px;
font-weight: bold;
color: #78aeed;
margin-top: 16px;
margin-bottom: 8px;
}
.forge-cheatsheet-row {
font-size: 12px;
color: #eeeeec;
margin: 4px 0;
padding: 2px 0;
}
.forge-cheatsheet-key {
font-family: monospace;
font-weight: bold;
color: #f5c211;
min-width: 180px;
}
.forge-cheatsheet-desc {
color: #d0cfcc;
} }
+7 -7
View File
@@ -1,8 +1,8 @@
file:///home/gib/Documents file:///home/gib/Documents Documents
file:///home/gib/Downloads file:///home/gib/Documents/Code Code
file:///home/gib/Music file:///home/gib/Downloads Downloads
file:///home/gib/Pictures file:///home/gib/Music Music
file:///home/gib/Videos file:///home/gib/Pictures Pictures
file:///home/gib/Documents/Code file:///home/gib/Videos Videos
file:///home/gib/.local/share/Panama Panama file:///home/gib/.local/share/Panama Panama
smb://server.gib/ Gib's Server smb://server-gib.local/homes/ Gib's Server
+12 -4
View File
@@ -1,9 +1,18 @@
[Settings] [Settings]
gtk-theme-name=Tahoe-Dark # These values must match `gsettings get org.gnome.desktop.interface ...`.
#
# Under GNOME, gnome-settings-daemon publishes settings over XSETTINGS and this
# file is effectively ignored. Under Hyprland there is no settings daemon, so
# this file becomes authoritative for GTK3 — which is why it previously named
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
# appearing broken.
gtk-theme-name=adw-gtk3-dark
gtk-icon-theme-name=Adwaita gtk-icon-theme-name=Adwaita
gtk-font-name=Cantarell 11 gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=WhiteSur-cursors gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24 gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=1
gtk-toolbar-style=GTK_TOOLBAR_ICONS gtk-toolbar-style=GTK_TOOLBAR_ICONS
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
gtk-button-images=0 gtk-button-images=0
@@ -14,4 +23,3 @@ gtk-xft-antialias=1
gtk-xft-hinting=1 gtk-xft-hinting=1
gtk-xft-hintstyle=hintslight gtk-xft-hintstyle=hintslight
gtk-xft-rgba=rgb gtk-xft-rgba=rgb
gtk-application-prefer-dark-theme=0
+10 -1
View File
@@ -1,2 +1,11 @@
[Settings] [Settings]
gtk-application-prefer-dark-theme=0 # libadwaita apps normally take their dark preference from the
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
# window before the portal answers.
gtk-application-prefer-dark-theme=1
gtk-theme-name=adw-gtk3-dark
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
+89
View File
@@ -0,0 +1,89 @@
# Panama desktop parity
This is the living inventory for the GNOME-to-Hyprland transition. It records
real behavior, not a one-for-one count of extensions: an enabled extension with
no configured action is not treated as missing functionality.
Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
## Daily desktop surfaces
| Capability | Panama implementation | Status |
|---|---|---|
| App launcher and run dialog | Vicinae; Wofi emergency fallback | Live |
| App grid | Vicinae applications provider | Live |
| Hidden applications | GNOME App Hider list in Vicinae provider config | Live |
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
| Dock | Quickshell dock with GNOME favourites and intellihide | Live |
| Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live |
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live |
| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live |
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live |
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
| Screenshot and recording | Quickshell picker, grim/slurp/satty/wf-recorder | Live |
| Screen text and code recognition | Local Tesseract/ZBar Read mode, selectable Prism result sheet | Live after package install |
| Lock, idle, and power | hyprlock, hypridle, login1, Quickshell power menu | Live |
| Wallpaper | hyprpaper | Live |
| Keyring and authorization | gnome-keyring and hyprpolkitagent | Live |
| Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live |
| Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
## GNOME extension migration
| GNOME extension | Result |
|---|---|
| Forge | Replaced by Hyprland tiling and the original keymap |
| Dash to Dock | Replaced by the Quickshell dock |
| AppIndicator | Replaced by the Quickshell system tray |
| Clipboard Indicator | Replaced by the bar clipboard panel and Vicinae history |
| Emoji Copy | Replaced by Vicinae emoji search on `SUPER+.` |
| Vitals | Replaced by the CPU, memory, and GPU bar readouts |
| Weather O'Clock | Replaced by bar weather and the date-menu weather card |
| Caffeine | Replaced by a real logind inhibitor in quick settings |
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
| App Hider | Hidden app list is now applied in Vicinae |
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
## System UI still worth adding
These are not blockers for normal use, but they are the remaining places where
a polished general-purpose desktop can go beyond the current shell.
1. A real external RustDesk test while hyprlock is active. Local process state
cannot prove what a remote client sees or whether input is accepted.
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
tracked in `setup/packages/hyprland-packages`.
## Global menu decision
A macOS-style global application menu is not being shipped. Quickshell can
render a `DBusMenu` that an application already exports, but it does not provide
an application-menu registrar or a reliable focused-window-to-menu association.
Hyprland has no cross-toolkit global-menu protocol, and the daily applications
audited here do not consistently export one: GTK 4/Nautilus and Electron apps
are the important failures.
That would produce a premium-looking empty strip for most windows and a working
menu for a minority, which is worse than omitting it. Relevant references:
- <https://quickshell.org/docs/v0.3.0/types/Quickshell/DBusMenu/>
- <https://github.com/hyprwm/Hyprland/discussions/1358>
A future focused-window menu could expose compositor actions such as float,
move, fullscreen, and close. It should not be labelled as an application menu.
+409
View File
@@ -0,0 +1,409 @@
# Panama :: Hyprland
A Hyprland desktop built to reproduce the GNOME + Forge setup it replaces, so
that muscle memory transfers unchanged.
## The one thing to know first
**Hyprland 0.55 replaced hyprlang with Lua.** `hyprland.conf` still loads on
0.56 as a legacy fallback and is removed in 0.57. Everything here is Lua.
The authoritative option reference for the installed build is on disk:
| File | What it is |
|---|---|
| `/usr/share/hypr/stubs/hl.meta.lua` | Generated type stub — the exact option list for this build |
| `/usr/share/hypr/hyprland.lua` | Upstream example config |
Wire the stub into your editor with `.luarc.json` in this directory for
autocomplete.
**The Hypr ecosystem tools did NOT move to Lua.** `hyprlock.conf`,
`hypridle.conf`, `hyprpaper.conf` and `hyprtoolkit.conf` are still hyprlang.
Don't "fix" them.
## Layout
| File | Contents |
|---|---|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file Panama Settings writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
| `keybinds.lua` | The full keymap |
| `autostart.lua` | Session startup |
| `overrides.lua` | Per-machine escape hatch, loaded last |
| `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) |
Validate any change without leaving your session:
```sh
Hyprland --verify-config
```
## Generated configuration
Two ecosystem tools cannot read the shared settings file, and their configs live
in this directory — which is a symlink into the Panama repository, so writing to
them at runtime would put machine state into a tracked file. Both are therefore
generated elsewhere:
| Tool | Generated to | Pointed there by |
|---|---|---|
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
```sh
~/.config/quickshell/scripts/panama-idle remove
```
## Settings: one file, both sides
`~/.config/panama/settings.json` is shared with the Quickshell side. The
relationship is:
- **This config is the default.** Every adjustable value is written
`prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it.
- **Panama Settings is the editor.** It writes the file *and* applies the change
live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to
`quickshell/config/PreferenceSchema.qml` with a `hypr` block naming the
`hl.config` path, then read it here with `prefs.get`. Nothing else is needed —
persistence, validation, reset, and the live write are all derived from that
entry.
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
wrong-typed settings file costs you your customisations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states.
### Adding a keybind: use `bind`, not `hl.bind`
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
Only the **chord** is ever taken from settings — the action is always the Lua
value written here. A stored override can therefore move a shortcut but can
never make one do something else, which is what makes reading overrides from a
file the user can edit safe.
Overrides are keyed by the **shipped chord**, not the description. Descriptions
are not unique: "Calculator" is both `SUPER + C` and the `XF86Calculator`
hardware key, and keying by description moved both onto the same new chord,
silently costing the hardware key.
An override whose value is not a plausible chord is ignored in favour of the
shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
### Keybind descriptions are required
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the
Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently.
```lua
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
The page groups shortcuts by what the description says they do, so a new bind
lands in the right section with no change to the UI.
### Never use `hyprctl keyword`
On a Lua-configured Hyprland it refuses the write, prints
`keyword can't work with non-legacy parsers` to **stdout**, and still **exits 0**:
```sh
$ hyprctl getoption decoration:rounding -j # → "int": 18
$ hyprctl keyword decoration:rounding 4 # → the refusal above
$ echo $? # → 0
$ hyprctl getoption decoration:rounding -j # → "int": 18, unchanged
```
Use `hyprctl eval 'hl.config({ ... })'` instead. Note that `eval` *also* exits 0
on syntax and runtime errors, reporting them as an `error:` line on stdout — so
for either command, the only trustworthy signal that a write landed is reading
the value back with `hyprctl getoption`.
## The look
Tokyo Night Moon, with two accents that come from the tmux theme:
| Token | Value | Role |
|---|---|---|
| `accent` | `#82aaff` | Carries every state meaning — focused, active, on |
| `accentSecondary` | `#b172b0` | Never used alone; only the far end of a gradient |
The signature is **the prism**: the two accents meeting. It appears in exactly
four places, and nowhere else —
1. A one-pixel hairline along the top edge of every glass surface (the bar,
dock, popovers), running blue on the left to orchid on the right and fading
out before the corners. See `quickshell/widgets/PrismEdge.qml`.
2. The focused window's border — `general.col.active_border`, blue→orchid at
115°. Unfocused windows get no colour at all, because the gradient only
means something if one window on screen is wearing it.
3. The active workspace pill in the bar.
4. Slider fills.
The restraint is the point. The pink stops being special the moment it's used
as a flat fill, so it never is.
### Typography
**Adwaita Sans for everything the user reads as text — no monospace in the UI.**
A monospaced clock or percentage reads as terminal output pasted into a panel,
which is the opposite of the intent.
The Nerd Font is still used, but only to draw **icon glyphs** — it is the
pragmatic alternative to freedesktop symbolic icons, which ship with a
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolour. Where a
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
paints a palette colour through the icon's alpha.
Anything whose digits change in place — the clock, the vitals percentages, the
recording timer, the selection readout — sets `font.features:
Theme.tabularFigures`. Tabular figures share one advance width, so the text
stops twitching as numbers tick without reaching for a monospaced face.
Window rounding is 18, matching the shell's popover radius, so a window and a
panel next to each other read as the same family of object.
**On animated gradient borders:** Hyprland can rotate the border gradient
continuously with `borderangle` + `style = "loop"`. Don't. The wiki is explicit
that it forces a full-refresh-rate repaint forever, even when no border is
visible — at 4500×3000 that is pure idle GPU burn. This config uses
`style = "once"`, so the gradient sweeps into place when a window takes focus
and then costs nothing.
## Session: use the uwsm one
Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland".
`xdg-desktop-portal-hyprland`'s systemd unit requires `graphical-session.target`,
and Fedora ships no `hyprland-session.target`. Without uwsm that target never
activates and **screen sharing in OBS, Sunshine and Zoom silently fails.**
### The uwsm environment gotcha
`hl.env()` in `env.lua` exports into the *compositor's* environment, **not** the
systemd user manager. Anything started as a user unit — `vicinae.service`,
`hyprpaper.service`, `hyprpolkitagent.service`, `hypridle.service` — and every
app those launch will not see it.
That is why `~/.config/uwsm/env` and `~/.config/uwsm/env-hyprland` exist and
duplicate the important variables. Keep them in sync with `env.lua`; `env.lua`
remains the fallback for the plain session.
## HDR — read this before turning it on
GNOME ran this panel in bt2100/HDR. This config deliberately does not.
On Hyprland 0.56.x, `cm = "hdr"` currently breaks **screencopy**: `grim`
screenshots come back empty, and OBS/Sunshine capture and hyprlock's blurred
background go with them. Root cause is still open upstream.
So the desktop runs SDR at 10-bit (`cm = "auto"`) and HDR is handed to
fullscreen games only, via `render.cm_auto_hdr = 1` in `looks.lua`. Games get
HDR; screenshots keep working.
To try full-time HDR anyway, `overrides.lua` has the block and the ordered list
of workarounds to reach for when things break.
## Keymap
The mental model is unchanged from Forge:
- **SUPER** acts on windows
- **ALT** acts on workspaces
- **SUPER + CTRL** changes layout structure
### Windows
| Key | Action |
|---|---|
| `SUPER + H/J/K/L` (or arrows) | Focus |
| `SUPER + SHIFT + H/J/K/L` | Move window |
| `SUPER + CTRL + H/J/K/L` | Swap window |
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter |
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
| `SUPER + Q` | Close |
| `SUPER + U` | Fullscreen |
| `SUPER + CTRL + C` | Toggle float |
| `SUPER + CTRL + SHIFT + C` | Pin (nearest thing to Forge's "always float") |
| `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down |
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
| `SUPER + SHIFT + grave` | Last window |
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimise to scratchpad |
### Workspaces (dynamic, like GNOME)
| Key | Action |
|---|---|
| `ALT + H` / `ALT + L` | Previous / next workspace |
| `ALT + SHIFT + H/L` | Move window to previous / next |
| `ALT + 1..9`, `ALT + 0` | Jump to workspace |
| `ALT + SHIFT + 1..9` | Send window to workspace |
| `SUPER + scroll` | Change workspace |
### Launcher and shell
| Key | Action |
|---|---|
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite |
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
| `SUPER + V` | Clipboard history |
| `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings |
| `SUPER + I` | Panama Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre |
| `SUPER + grave` | Workspace overview |
| `Print` | Screenshot / record picker |
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
| `SUPER + SHIFT + P` | Colour picker |
| `CTRL + ALT + L` | Lock (SUPER+L is "focus right") |
| `CTRL + ALT + Delete` | Power menu |
### Apps
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
Settings remains searchable in Vicinae for hardware and account panels.
## Calendar and notifications
Click the clock to open the two-column Daybook on **Agenda**. The month grid,
today's schedule, Up Next, weather, and conditional media controls stay visible
together. Its right pane has three first-class pages:
- **Agenda** — the selected day's calendar.
- **Ongoing** — Focus, Caffeine, recording, screen sharing, camera, and
microphone activity, visible until each state ends.
- **Notifications** — grouped history, Do Not Disturb, and clear actions.
`SUPER + B` opens Notifications directly. Switching pages preserves the
selected date and visible month. Brief completed events such as screenshots and
device changes remain in Signal Glass instead of accumulating in Ongoing.
Calendar data comes from Evolution Data Server, so the Google, iCloud,
Nextcloud, and local calendars already configured for GNOME remain the source
of truth. Panama never stores account credentials. Use GNOME Calendar or Online
Accounts to add and manage accounts, and click an event in Daybook to hand off
editing and full detail to GNOME Calendar.
The right side of the bar shows a small calendar capsule only from 15 minutes
before a timed event until 5 minutes after it starts. It displays relative time
(`12m` or `Now`); the event title is available on hover but otherwise stays out
of the bar. All-day events never trigger the capsule.
## Control Center
Click the right-side status cluster or press `SUPER + S` for Panama's Control
Center. It is attached directly beneath the bar and keeps Wi-Fi, Bluetooth,
Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
one place. Home and Phone continue the same surface rather than opening extra
dashboard windows.
Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous
level.
Credentials stay private in the gitignored `config/bash/env` file, with the
existing GNOME extension and Secret Service setup retained as a compatibility
fallback. Favourites, aliases, and order live in Quickshell state. No shell
restart is required after changing credentials; close and reopen Control Center
to refresh. If Home Assistant is offline, the last known values stay visible
with a stale-state label and Retry action.
Phone uses KDE Connect for the capabilities the paired iPhone actually
advertises: Send File, Send Clipboard, and Ring. The device remains visible
while iOS suspends KDE Connect, but those actions stay disabled until it
reconnects. Messages opens BlueBubbles independently of KDE Connect. No battery
percentage is invented when iOS reports none. Active file sends appear in
Daybook's Ongoing page; successful sends become a quiet recent exchange.
## Screen Intelligence
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
mode. Read is also available beside Screenshot and Record under `Print`, and
works on a whole display, a window, or a region. Tesseract recognizes English
text locally; ZBar recognizes QR codes and barcodes. The result sheet can copy
text, search it, translate it, or open a detected web address. No pixels leave
the workstation unless one of those explicit network actions is selected.
## Deliberate deviations from GNOME
These are the places a 1:1 port was impossible, and what was done instead:
- **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an
axis and lets the layout pick the edge. The eight Forge keys collapse to four
behaviours, keeping the horizontal/vertical and grow/shrink pairing.
- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a
modifier misfires when you're quick with SUPER combos.
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap.
- **`SUPER + grave` was Forge's "cycle windows of same app"**, which Hyprland
has no equivalent for. "Last window" moved to `SUPER + SHIFT + grave`.
- **`gnome-control-center` is launched with `XDG_CURRENT_DESKTOP=GNOME`**,
because it hard-refuses to start otherwise. Panels backed by system services
work; panels backed by GNOME Shell (Displays, Keyboard Shortcuts,
Multitasking, Appearance) are inert — Hyprland owns those now.
## Gaming
`content = "game"` on the window rules in `rules.lua` is the keystone — it is
what `misc.vrr = 3`, `render.direct_scanout = 2` and `cursor.no_break_fs_vrr = 2`
key off. Games additionally get blur, animation, shadow and dim disabled, plus
`immediate` for tearing.
Tearing only takes effect when the game is fullscreen and the only thing on
screen — no bar, no notifications.
## Do not install a notification daemon
`mako`, `dunst` and `SwayNotificationCenter` all register
`Name=org.freedesktop.Notifications` for D-Bus activation. Installing any of
them creates a startup race against Quickshell's notification server, and
whoever wins keeps the name. If one gets installed as a dependency,
`systemctl --user mask <name>.service`.
## Troubleshooting
**Nothing autostarts** — check `systemctl --user status hyprland-session.target`
and `systemctl --user show-environment | grep WAYLAND_DISPLAY`. An empty
environment means uwsm didn't export it and units with
`ConditionEnvironment=WAYLAND_DISPLAY` silently skip.
**Screen sharing missing** — `busctl --user list | grep impl.portal` should show
`xdg-desktop-portal-hyprland`.
**Qt apps look wrong** — `QT_QPA_PLATFORMTHEME` must be `gtk3`. It is a single
value, not a list: Qt splits on `:` and uses only the first token.
**Wrong GPU / won't start** — `AQ_DRM_DEVICES` points at
`/dev/dri/amd-dgpu`, created by `config/copy/etc/udev/rules.d/99-panama-gpu.rules`.
Both `env.lua` and `uwsm/env-hyprland` guard on the symlink existing, so a
missing rule degrades to "let aquamarine choose" rather than failing to start.
+60
View File
@@ -0,0 +1,60 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Autostart
--
-- `exec-once` no longer exists in 0.56; startup is an event handler.
--
-- Session: Fedora installs two sessions, "Hyprland" and "Hyprland
-- (uwsm-managed)". Panama targets the uwsm one, because
-- xdg-desktop-portal-hyprland's systemd unit requires graphical-session.target
-- and Fedora ships no hyprland-session.target -- without uwsm, screen sharing
-- in OBS/Sunshine/Zoom silently fails. The target is started explicitly below
-- so the plain session works too.
--
-- Anything that ships a systemd user unit is started as a unit rather than as
-- a compositor child. That is not cosmetic: under uwsm, units get the correct
-- activation environment (see uwsm/env), they restart on failure, and they
-- shut down in order. Panama's `change-settings` script enables them once;
-- the explicit starts here make a fresh clone work before that has run.
-- ─────────────────────────────────────────────────────────────────────────────
hl.on("hyprland.start", function()
-- Publish the session environment to systemd and D-Bus so user units and
-- the portals can see WAYLAND_DISPLAY. Cheap, and essential without uwsm.
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
hl.exec_cmd("systemctl --user start hyprland-session.target")
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
-- The shell: bar, dock, overview, quick settings, notifications, capture.
-- No systemd unit ships with quickshell, so it runs as a compositor child.
hl.exec_cmd("quickshell --daemonize")
-- Removable-media automounting. GNOME did this invisibly via gvfs+udisks;
-- outside GNOME something has to ask udisks to mount. No tray icon: the
-- Quickshell bar already has a tray, and udiskie's own icon would be
-- redundant. -f opens Nautilus when you click the mount notification.
hl.exec_cmd("udiskie --automount --notify --no-tray --file-manager nautilus")
-- Keyring unlock, for Nextcloud and Bitwarden credential storage.
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
-- Tray applications carried over from ~/.config/autostart. These need the
-- Quickshell tray (an SNI host) to be up, hence starting after it.
--
-- Commands are copied verbatim from the GNOME .desktop files rather than
-- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and
-- its autostart entry launches a specific script with --autostart.
--
-- RustDesk is deliberately absent: it ships an enabled *system* service
-- (`rustdesk --service`) that spawns --server and --tray for the session on
-- its own. Starting it here as well would give you two trays.
hl.exec_cmd("nextcloud --background")
hl.exec_cmd("flatpak run --command=bitwarden.sh com.bitwarden.desktop --autostart")
end)
hl.on("hyprland.shutdown", function()
hl.exec_cmd("systemctl --user stop hyprland-session.target")
end)
return true
+84
View File
@@ -0,0 +1,84 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Environment
--
-- hl.env() sets variables before the display server initialises, which is what
-- makes it valid for AQ_* and cursor variables.
--
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
-- prefers these live in ~/.config/uwsm/env and ~/.config/uwsm/env-hyprland
-- instead. They still work here; see the session notes in autostart.lua.
-- ─────────────────────────────────────────────────────────────────────────────
-- ── GPU selection ───────────────────────────────────────────────────────────
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
--
-- /dev/dri/cardN numbering is not stable across boots, and the by-path names
-- contain colons -- which are AQ_DRM_DEVICES' own separator, so they cannot be
-- used directly. Panama installs a udev rule creating colon-free stable
-- symlinks (/dev/dri/amd-dgpu, /dev/dri/amd-igpu); see
-- config/copy/etc/udev/rules.d/99-panama-gpu.rules
--
-- First entry is the primary renderer. The iGPU stays in the list so anything
-- plugged into its outputs still lights up.
--
-- Guarded on the symlink actually existing: this config is shared across
-- machines, and pointing AQ_DRM_DEVICES at a device that isn't there stops
-- Hyprland from starting at all. If the udev rule hasn't been installed (or
-- this is a different box), we say nothing and let aquamarine choose.
local function exists(path)
local f = io.open(path, "r")
if f then f:close() return true end
return false
end
if exists("/dev/dri/amd-dgpu") then
local devices = "/dev/dri/amd-dgpu"
if exists("/dev/dri/amd-igpu") then
devices = devices .. ":/dev/dri/amd-igpu"
end
hl.env("AQ_DRM_DEVICES", devices)
end
-- ── Cursor ──────────────────────────────────────────────────────────────────
-- Theme carried over from GNOME.
--
-- Deliberately no HYPRCURSOR_THEME: oreo_blue_cursors is a plain XCursor theme
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
-- XCursor path, which is what we want.
hl.env("XCURSOR_THEME", "oreo_blue_cursors")
hl.env("XCURSOR_SIZE", "24")
-- ── Toolkits ────────────────────────────────────────────────────────────────
-- Wayland first, X11 only as a fallback for apps that genuinely need it.
hl.env("QT_QPA_PLATFORM", "wayland;xcb")
-- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3
-- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than
-- approximating it -- and it gives them GTK file/font/colour dialogs. It also
-- covers Qt5 and Qt6 with one value.
--
-- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses
-- only the first token, so the commonly-copied "qt5ct:qt6ct" silently means
-- just "qt5ct".
hl.env("QT_QPA_PLATFORMTHEME", "gtk3")
hl.env("QT_WAYLAND_DISABLE_WINDOWDECORATION", "1")
hl.env("QT_AUTO_SCREEN_SCALE_FACTOR", "1")
hl.env("GDK_BACKEND", "wayland,x11")
hl.env("SDL_VIDEODRIVER", "wayland")
hl.env("CLUTTER_BACKEND", "wayland")
hl.env("MOZ_ENABLE_WAYLAND", "1")
-- Electron apps (Slack, Obsidian, Claude, Codex, BlueBubbles, Podman Desktop)
-- pick Wayland from this rather than needing per-app flags.
hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")
-- ── Desktop identity ────────────────────────────────────────────────────────
-- Portals and some GNOME apps key off these. uwsm sets them itself, so these
-- are only load-bearing for the plain (non-uwsm) session.
hl.env("XDG_CURRENT_DESKTOP", "Hyprland")
hl.env("XDG_SESSION_TYPE", "wayland")
hl.env("XDG_SESSION_DESKTOP", "Hyprland")
return true
+39
View File
@@ -0,0 +1,39 @@
# ─────────────────────────────────────────────────────────────────────────────
# hypridle — idle behaviour
#
# Mirrors the GNOME power profile this replaces, exactly:
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
# org.gnome.desktop.screensaver lock-delay = 300 (lock 5min after that)
# org.gnome.settings-daemon.plugins.power
# sleep-inactive-ac-type = nothing (never auto-suspend)
# power-button-action = nothing
#
# So: screen off at 5 minutes, lock at 10, never suspend. This is a desktop.
#
# hyprlang syntax, not Lua — hypridle is a separate project from Hyprland.
# ─────────────────────────────────────────────────────────────────────────────
general {
# `pidof` guard prevents stacking lockers if this fires twice.
lock_cmd = pidof hyprlock || hyprlock
before_sleep_cmd = loginctl lock-session
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
# 2 = auto. Holds off sleep until the locker is actually up.
inhibit_sleep = 2
}
# 5 minutes -> screen off.
listener {
timeout = 300
on-timeout = hyprctl dispatch 'hl.dsp.dpms({ action = "off" })'
on-resume = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
}
# 10 minutes -> lock. Matches GNOME's lock-delay of 300s after blanking.
listener {
timeout = 600
on-timeout = loginctl lock-session
}
# Deliberately no suspend listener: sleep-inactive-ac-type was 'nothing'.
+31
View File
@@ -0,0 +1,31 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Panama :: Hyprland
--
-- Hyprland 0.55 replaced hyprlang (hyprland.conf) with Lua. The .conf format
-- still loads as a legacy fallback in 0.56 but is removed in 0.57, so this
-- config is Lua-native. Reference for every option below:
-- /usr/share/hypr/stubs/hl.meta.lua <- authoritative for THIS build
-- /usr/share/hypr/hyprland.lua <- upstream example
--
-- Entry point only. Each require() is its own error scope, so a mistake in one
-- module doesn't take the whole config down with it.
--
-- Reload: hyprctl reload
-- Check: Hyprland --verify-config
-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences first: looks/input/monitors read their defaults through it.
-- It never raises, so a missing or malformed settings file costs customisations
-- and nothing else.
require("prefs")
require("env")
require("monitors")
require("looks")
require("input")
require("rules")
require("keybinds")
require("autostart")
-- Per-machine overrides, loaded last so it wins. Ships effectively empty.
require("overrides")
+114
View File
@@ -0,0 +1,114 @@
# ─────────────────────────────────────────────────────────────────────────────
# hyprlock — lock screen
#
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
#
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
# ─────────────────────────────────────────────────────────────────────────────
general {
hide_cursor = true
# 2 = auto. Matters at the 1.5x fractional scale this display runs.
fractional_scaling = 2
# 0 = GPU-accelerated screencopy. 1 is a CPU fallback and is very slow at
# 4500x3000.
screencopy_mode = 0
fail_timeout = 2000
}
auth {
pam:enabled = true
pam:module = hyprlock
}
# ── Background ──────────────────────────────────────────────────────────────
# `path = screenshot` grabs the desktop at lock time and blurs it in place,
# which is what GNOME does.
#
# Caveat: this uses screencopy. If the desktop is ever switched to full-time
# HDR (see hypr/overrides.lua), this background may come back black — that is
# the same upstream screencopy bug, not a hyprlock misconfiguration.
background {
monitor =
path = screenshot
blur_passes = 3
blur_size = 8
noise = 0.0117
contrast = 0.9
brightness = 0.8
vibrancy = 0.17
vibrancy_darkness = 0.05
# Shown if the screenshot is unavailable.
color = rgba(34, 36, 54, 1.0)
zindex = -1
}
# ── Clock ───────────────────────────────────────────────────────────────────
label {
monitor =
text = cmd[update:1000] date +"%-I:%M"
color = rgba(200, 211, 245, 1.0)
font_size = 120
font_family = Adwaita Sans Light
position = 0, 260
halign = center
valign = center
}
label {
monitor =
text = cmd[update:60000] date +"%A, %B %-d"
color = rgba(130, 139, 184, 1.0)
font_size = 24
font_family = Adwaita Sans
position = 0, 160
halign = center
valign = center
}
# ── Password field ──────────────────────────────────────────────────────────
input-field {
monitor =
size = 340, 52
position = 0, -40
halign = center
valign = center
outline_thickness = 2
rounding = 26
outer_color = rgba(130, 170, 255, 0.9)
inner_color = rgba(46, 47, 61, 0.85)
font_color = rgba(200, 211, 245, 1.0)
check_color = rgba(130, 170, 255, 1.0)
fail_color = rgba(255, 117, 127, 1.0)
dots_size = 0.25
dots_spacing = 0.3
dots_center = true
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
fade_on_empty = false
hide_input = false
}
# ── User ────────────────────────────────────────────────────────────────────
label {
monitor =
text = $USER
color = rgba(200, 211, 245, 0.9)
font_size = 16
font_family = Adwaita Sans
position = 0, -110
halign = center
valign = center
}
+36
View File
@@ -0,0 +1,36 @@
# ─────────────────────────────────────────────────────────────────────────────
# hyprpaper — static wallpaper
#
# NOTE: hyprpaper, hypridle, hyprlock and hyprtoolkit are separate projects and
# still use hyprlang. Only Hyprland itself moved to Lua. Don't "fix" these.
#
# SYNTAX NOTE: hyprpaper 0.8 was rewritten on hyprtoolkit/hyprwire and replaced
# the old flat syntax with blocks. The pre-0.8 form
#
# preload = /path/to.jpg
# wallpaper = DP-2,/path/to.jpg
#
# is silently ignored — it parses, then logs "Monitor DP-2 has no target: no wp
# will be created" and shows nothing. Almost every guide online still uses it.
#
# Static only, by choice: animated wallpapers repaint continuously and cost
# frame time for nothing.
# ─────────────────────────────────────────────────────────────────────────────
wallpaper {
monitor = DP-2
path = ~/Pictures/Wallpapers/faroe_islands.jpg
# cover = fill the output, cropping overflow. Matches the "zoom" picture
# option this setup used under GNOME.
fit_mode = cover
}
# Fallback for any output without its own block above.
wallpaper {
monitor =
path = ~/Pictures/Wallpapers/faroe_islands.jpg
fit_mode = cover
}
ipc = on
splash = false
+34
View File
@@ -0,0 +1,34 @@
# ─────────────────────────────────────────────────────────────────────────────
# hyprtoolkit — shared theme for the first-party Hypr GUI apps
#
# One file themes all of these, which is why they're preferred over the usual
# grab-bag of GTK/Qt utilities:
# hyprshutdown session exit dialog (bound to the power menu)
# hyprpwcenter PipeWire / audio routing GUI (replaces a pavucontrol/TUI)
# hyprsysteminfo system info panel
# hyprland-guiutils hyprland-dialog / hyprland-run
# hyprpolkitagent authentication prompts (via hyprland-qt-support)
#
# Colours are Tokyo Night Moon, matching quickshell/config/Theme.qml and
# hypr/looks.lua. Format is hyprlang; path is fixed at
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
# ─────────────────────────────────────────────────────────────────────────────
background = 0xFF1E2030 # bg_dark — window background
base = 0xFF222436 # bg — raised surfaces
alternate_base = 0xFF2F334D # bg_highlight — rows, hover
text = 0xFFC8D3F5 # fg
bright_text = 0xFFFFFFFF
link_text = 0xFF82AAFF # blue
accent = 0xFF82AAFF # blue — same accent as everything else
accent_secondary = 0xFFC099FF # magenta
font_family = Adwaita Sans
font_family_monospace = VictorMono Nerd Font
icon_theme = Adwaita
# Match the rounding used by the Quickshell popovers and the Hyprland windows.
rounding_large = 18
rounding_small = 8
+42
View File
@@ -0,0 +1,42 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Input
--
-- Deliberately GNOME-like: click-to-focus, no focus-follows-mouse, no mouse
-- acceleration changes. The goal is that muscle memory transfers untouched.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
hl.config({
input = {
kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "",
kb_model = "",
kb_options = "",
kb_rules = "",
numlock_by_default = prefs.get("numlockByDefault", true),
-- GNOME's defaults are 500ms delay / 33Hz repeat.
repeat_delay = prefs.get("keyRepeatDelay", 500),
repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
follow_mouse = prefs.getInt("followMouse", 1),
-- Don't refocus on mouse move alone -- only on click.
mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming.
sensitivity = prefs.get("pointerSensitivity", 0),
accel_profile = "flat",
-- Clicking a floating window raises and focuses it.
float_switch_override_focus = 2,
},
})
-- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever
-- runs this config, add touchpad settings in overrides.lua.
return true
+284
View File
@@ -0,0 +1,284 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Keybindings
--
-- Ported 1:1 from the GNOME + Forge setup this replaces. Where Hyprland has no
-- equivalent, the deviation is called out in a comment rather than silently
-- dropped.
--
-- The mental model, unchanged from Forge:
-- SUPER -> act on windows (focus / move / swap / resize)
-- ALT -> act on workspaces
-- SUPER + CTRL -> change layout structure (split, float, swap)
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
local mod = "SUPER"
-- Programs, matched to the GNOME custom keybindings and media-key settings.
local terminal = "kitty"
local editor = "kitty nvim ."
local browser = "helium-browser-bin"
local files = "nautilus --new-window"
local calculator = "gnome-calculator"
local mail = "flatpak run org.mozilla.thunderbird_esr"
-- Panama owns the Hyprland and shell controls. GNOME Settings remains installed
-- and searchable in Vicinae for its hardware/account panels.
local settings = "qs ipc call settings toggle"
local sysmonitor = "flatpak run io.missioncenter.MissionCenter"
-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
-- window against the already-running server (started in autostart.lua).
local launcher = "vicinae toggle"
local osd = function(action)
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
end
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
-- ── Applications ────────────────────────────────────────────────────────────
-- ── User rebinding ──────────────────────────────────────────────────────────
-- Every bind below goes through `bind` rather than `hl.bind` directly, so a
-- chord can be replaced from Panama Settings without this file changing.
--
-- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken
-- from settings -- the action is always the Lua value written here. A stored
-- override can therefore move a shortcut but can never make one do something
-- else, which is the property that makes reading them from a JSON file the
-- user can edit safe.
--
-- Keyed by chord rather than by description because chords are unique and
-- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator
-- hardware key, and keying by description moved both of them onto the same new
-- chord, silently costing the hardware key.
--
-- An override whose chord is not a plausible chord is ignored and the shipped
-- one is used, so a hand-edited settings file cannot cost you a keymap.
local overrides = prefs.get("keybindOverrides", {})
local function valid_chord(chord)
if type(chord) ~= "string" or chord == "" or #chord > 64 then
return false
end
-- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272"
return chord:match("^[%w_+%s:]+$") ~= nil
end
local function bind(chord, action, opts)
local override = overrides[chord]
if valid_chord(override) then
chord = override
end
return hl.bind(chord, action, opts)
end
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ────────────────────────────────────────────────────────────────
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
-- emoji and window switching, so a separate run dialog and app grid are gone.
bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- Emergency fallback launcher. Vicinae runs as a systemd user service and the
-- bar is Quickshell; if either fails to come up, this is how you start an
-- application without dropping to a TTY. Depends on nothing but wofi itself.
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
-- Clipboard history and emoji, straight into the relevant launcher view.
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
{ description = "Clipboard history" })
bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
{ description = "Emoji picker" })
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
-- SUPER+S was GNOME's quick settings; kept.
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
-- Start a 45-minute focus session on the current workspace, or reveal its
-- Signal Glass controls if one is already running.
bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a
-- modifier misfires when you're fast with SUPER+key combos, so it lives on a
-- real chord instead. SUPER+grave was Forge's "cycle windows of same app",
-- which Hyprland has no equivalent for.
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
-- Notification centre.
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
-- Screenshot / screen record. One key, then pick screen / window / region and
-- whether to capture or record -- reproducing GNOME's Print-screen UI.
bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
-- Local OCR and QR/barcode recognition through the same region picker. This
-- opens directly in Selection + Read mode; Print still exposes every mode.
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
{ description = "Screen Intelligence" })
-- Colour picker: copies the hex under the cursor to the clipboard.
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
-- ── Window management ───────────────────────────────────────────────────────
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
-- Forge: window-toggle-float / window-toggle-always-float.
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
-- is the nearest useful thing -- the window floats above every workspace.
bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical.
bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
-- Forge: window-shrink / window-expand / window-reset-sizes.
bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge.
bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
-- Move (Forge: window-move-*).
bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
-- Swap (Forge: window-swap-*).
bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
--
-- Forge resized one named edge at a time. Hyprland resizes the active window
-- along an axis and lets the layout decide which edge actually moves, so the
-- eight Forge keys collapse onto four behaviours. The pairing is kept
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
-- and "increase" always grows while "decrease" always shrinks.
local step = 60
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
-- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
-- Mouse: drag to move, right-drag to resize.
bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
-- ── Workspaces ──────────────────────────────────────────────────────────────
-- ALT is the workspace modifier, matching the GNOME setup.
--
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
-- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping.
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
-- GNOME also had these on CTRL+ALT+Up/Down.
bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
-- Direct jump. ALT+0 is workspace 10.
for i = 1, 10 do
local key = i % 10
bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
end
-- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
-- (Scrolling the workspace indicator in the bar does the same; that's handled
-- in quickshell/modules/bar/Workspaces.qml.)
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- Minimise, as far as Hyprland has one.
--
-- Hyprland has no minimise: it receives the request (the binary has
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
-- no dispatcher, no config option and not even an event to hook, so titlebar
-- minimise buttons are inert and cannot be made to work. A tiling WM has no
-- iconified state and no taskbar to restore from.
--
-- The scratchpad is the honest equivalent: the window goes away, and the same
-- key brings it back. Bound to X to match the muscle memory it replaces.
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
-- ── Session ─────────────────────────────────────────────────────────────────
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
-- CTRL+ALT+L -- the other binding most people already have in muscle memory.
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
-- ── Media and volume ────────────────────────────────────────────────────────
-- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting.
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
-- Hardware keys GNOME mapped that have obvious equivalents.
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
return true
+228
View File
@@ -0,0 +1,228 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Look and feel -- Tokyo Night Moon
--
-- Colours here must stay in sync with quickshell/config/Theme.qml.
-- accent #82aaff borders / focus
-- bg #222436 base
--
-- Performance note: every animation below is event-driven. Nothing uses the
-- "loop" style on borderangle/shadowangle/glowangle -- that style forces
-- Hyprland to render at full refresh rate forever, even when nothing is
-- visible, which is exactly the kind of idle GPU burn to avoid on a 4500x3000
-- display.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
hl.config({
general = {
gaps_in = prefs.get("gapsIn", 5),
gaps_out = prefs.get("gapsOut", 10),
border_size = prefs.get("borderSize", 2),
col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair
-- is visible on both a tall and a wide window. Same two colours as
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
-- tmux theme this palette came from.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means
-- something if exactly one window on screen is wearing it.
-- Follows the colour scheme: a dark neutral is invisible against a
-- light desktop. services/ColorScheme.qml applies changes live;
-- this is the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)",
},
resize_on_border = true,
-- Enables the per-window "immediate" rule used for games in rules.lua.
-- Harmless on its own; tearing only happens where a rule opts in.
allow_tearing = true,
layout = "dwindle",
snap = { enabled = true },
},
decoration = {
-- 18 to match the shell's popover radius, so a window and a panel sitting
-- next to each other read as the same object family.
rounding = prefs.get("windowRounding", 18),
rounding_power = 2,
active_opacity = 1.0,
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = {
enabled = prefs.get("blurEnabled", true),
size = prefs.get("blurSize", 8),
passes = prefs.get("blurPasses", 3),
-- Required for blur to be affordable. Never turn this off.
new_optimizations = true,
-- Floating windows skip blurring what's behind tiled ones. Big win.
xray = true,
ignore_opacity = true,
noise = 0.0117,
contrast = 0.9,
brightness = 0.85,
vibrancy = 0.17,
vibrancy_darkness = 0.05,
popups = true,
popups_ignorealpha = 0.2,
-- Blurring the special workspace is expensive and rarely seen.
special = false,
},
shadow = {
enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20),
render_power = 3,
sharp = false,
color = "rgba(15161eee)",
offset = { 0, 4 },
scale = 1.0,
},
-- New in 0.56. Kept deliberately faint: in this direction the gradient
-- border is the signature, and a strong halo would compete with it.
-- This is just enough to lift the focused window off the wallpaper.
glow = {
enabled = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 8),
render_power = 2,
color = "rgba(82aaff33)",
color_inactive = "rgba(00000000)",
},
-- Off: costs real frame time and reads as smeary on a 60Hz panel.
motion_blur = { enabled = false },
},
animations = { enabled = prefs.get("animationsEnabled", true) },
dwindle = {
-- Keep the split orientation a window was created with. Closest match
-- to how the Forge extension behaved on GNOME.
preserve_split = true,
smart_resizing = true,
},
misc = {
force_default_wallpaper = 0,
disable_hyprland_logo = true,
disable_splash_rendering = true,
-- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree
-- about the interface font and nobody would notice until a tooltip
-- rendered in a different typeface.
font_family = prefs.get("interfaceFont", "Adwaita Sans"),
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
-- content type is "video" or "game" -- the tag rules.lua applies to
-- games. Keeps VRR off the desktop, where it causes visible flicker.
vrr = prefs.getInt("vrrPolicy", 3),
-- Blur behind the lock screen.
session_lock_blur = true,
-- Let a crashed lock screen be recovered rather than stranding you.
allow_session_lock_restore = true,
-- Don't let apps steal focus by shouting; matches GNOME's behaviour.
focus_on_activate = false,
},
render = {
-- Colour management is stable and on by default in 0.56.
cm_enabled = true,
-- 1 = automatically flip the monitor into HDR for fullscreen content
-- that asks for it, and back out afterwards. This is how games get HDR
-- without the desktop paying the screencopy cost. See monitors.lua.
cm_auto_hdr = prefs.getInt("autoHdr", 1),
-- 2 = direct scanout only for windows tagged content = "game"
-- (set by the rules in rules.lua). Bypasses compositing for real
-- fullscreen games.
direct_scanout = prefs.getInt("directScanoutPolicy", 2),
},
cursor = {
-- 2 = auto. Disables the hardware cursor when tearing, which is what
-- keeps the cursor from ghosting in games.
no_hardware_cursors = 2,
-- Don't let the cursor break VRR on content tagged as a game.
no_break_fs_vrr = 2,
min_refresh_rate = 48,
-- Pull the cursor theme from gsettings so GTK apps and Hyprland agree.
sync_gsettings_theme = true,
-- Fade the cursor out after 4s of no movement, like GNOME does.
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
},
ecosystem = {
no_update_news = true,
no_donation_nag = true,
},
xwayland = {
-- Render X11 apps at 1:1 device pixels so they're sharp rather than
-- bilinearly upscaled. See the note in README.md about the unavoidable
-- sizing tradeoff at 1.5x.
force_zero_scaling = true,
},
quirks = {
-- 2 = report HDR as preferred to gamescope only. Stops gamescope from
-- white-screening when it expects HDR to already be live.
prefer_hdr = 2,
},
})
-- ── Motion ──────────────────────────────────────────────────────────────────
-- speed is in deciseconds: speed = 4 means 400ms. Higher = slower.
hl.curve("easeOutQuint", { type = "bezier", points = { { 0.23, 1 }, { 0.32, 1 } } })
hl.curve("almostLinear", { type = "bezier", points = { { 0.5, 0.5 }, { 0.75, 1 } } })
hl.curve("quick", { type = "bezier", points = { { 0.15, 0 }, { 0.1, 1 } } })
hl.curve("easy", { type = "spring", mass = 1, stiffness = 238.1191, dampening = 24.21279 })
-- A touch bouncier than "easy"; used for windows appearing.
hl.curve("snappy", { type = "spring", mass = 1, stiffness = 320, dampening = 22 })
hl.animation({ leaf = "global", enabled = true, speed = 10, bezier = "default" })
hl.animation({ leaf = "border", enabled = true, speed = 5.39, bezier = "easeOutQuint" })
-- The focus sweep: when a window takes focus, its gradient border rotates into
-- place once. Style "once" is load-bearing -- the alternative, "loop", makes
-- Hyprland render at full refresh rate forever, even when no border is visible.
-- That is exactly the idle GPU cost this config exists to avoid, so: never loop.
hl.animation({ leaf = "borderangle", enabled = true, speed = 6, bezier = "easeOutQuint", style = "once" })
hl.animation({ leaf = "windows", enabled = true, speed = 4.79, spring = "easy" })
hl.animation({ leaf = "windowsIn", enabled = true, speed = 4.1, spring = "snappy", style = "popin 90%" })
hl.animation({ leaf = "windowsOut", enabled = true, speed = 1.49, bezier = "almostLinear", style = "popin 90%" })
hl.animation({ leaf = "fade", enabled = true, speed = 3.03, bezier = "quick" })
-- Layer surfaces = the Quickshell bar, dock, popovers and overlays.
hl.animation({ leaf = "layers", enabled = true, speed = 3.81, bezier = "easeOutQuint" })
hl.animation({ leaf = "layersIn", enabled = true, speed = 3, bezier = "easeOutQuint", style = "fade" })
hl.animation({ leaf = "layersOut", enabled = true, speed = 2, bezier = "almostLinear", style = "fade" })
-- Workspace switching: horizontal slide, matching Alt+H / Alt+L direction.
hl.animation({ leaf = "workspaces", enabled = true, speed = 2.5, bezier = "easeOutQuint", style = "slidefade 15%" })
hl.animation({ leaf = "zoomFactor", enabled = true, speed = 7, bezier = "quick" })
return true
+138
View File
@@ -0,0 +1,138 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Monitors
--
-- Kuycon P20 on DP-2: 4500x3000 @ 60Hz, 1.5x fractional scale.
-- 4500/1.5 = 3000 and 3000/1.5 = 2000, both integers, so this is a "clean"
-- fractional scale and Hyprland will not complain.
--
-- On HDR --------------------------------------------------------------------
-- GNOME runs this panel in bt2100/HDR. Hyprland CAN do that (cm = "hdr"), but
-- on 0.56.x it currently breaks screencopy: grim screenshots come back empty,
-- and OBS/Sunshine capture and hyprlock's blurred background go with them.
-- Root cause is still open upstream. Since screen capture matters more here
-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to
-- fullscreen games only, via render.cm_auto_hdr in looks.lua.
--
-- To try full-time HDR anyway, set cm = "hdr" below (or override it in
-- overrides.lua) and read the notes in that file first.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- Per-output overrides written by Panama Settings, keyed by output name:
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
--
-- Only mode, scale, and transform are read. Colour management and bit depth
-- stay here, because those are the settings with a documented reason attached
-- (see the header) rather than preferences, and a settings page has no way to
-- explain the screencopy tradeoff at the moment you would be changing it.
local displays = prefs.get("displays", {})
if type(displays) ~= "table" then
displays = {}
end
local function mode_dimensions(mode)
if type(mode) ~= "string" then
return nil, nil
end
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
if width == nil then
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
end
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
if width == nil or height == nil or refresh == nil
or width <= 0 or height <= 0 or refresh <= 0 then
return nil, nil
end
return width, height
end
local function valid_mode(mode)
local width = mode_dimensions(mode)
return width ~= nil
end
local function valid_scale(mode, scale)
local width, height = mode_dimensions(mode)
if width == nil or type(scale) ~= "number" or scale ~= scale
or scale <= 0 or scale > 4 then
return false
end
local logical_width = width / scale
local logical_height = height / scale
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
end
local function valid_transform(transform)
return type(transform) == "number"
and transform == math.floor(transform)
and transform >= 0
and transform <= 3
end
local function display_entry(output)
if type(output) ~= "string" or output == ""
or output:match("^[%w_.-]+$") == nil then
return nil
end
local entry = displays[output]
if type(entry) ~= "table" then
return nil
end
if not valid_mode(entry.mode)
or not valid_scale(entry.mode, entry.scale)
or not valid_transform(entry.transform) then
return nil
end
return entry
end
local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5
local shipped_transform = 0
local dp2 = display_entry("DP-2")
hl.monitor({
output = "DP-2",
mode = dp2 and dp2.mode or shipped_mode,
position = "0x0",
scale = dp2 and dp2.scale or shipped_scale,
transform = dp2 and dp2.transform or shipped_transform,
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
-- falls back to a lower mode, drop this line first.
bitdepth = 10,
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
cm = "auto",
})
-- Other connected outputs use the same validated per-output store. They keep
-- automatic placement and the compositor's normal colour policy; DP-2 alone
-- carries the panel-specific 10-bit policy documented above.
for output, _ in pairs(displays) do
if output ~= "DP-2" then
local entry = display_entry(output)
if entry ~= nil then
hl.monitor({
output = output,
mode = entry.mode,
position = "auto",
scale = entry.scale,
transform = entry.transform,
})
end
end
end
-- Any monitor not named above: sane defaults rather than nothing.
hl.monitor({
output = "",
mode = "preferred",
position = "auto",
scale = "auto",
})
return true
+33
View File
@@ -0,0 +1,33 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Per-machine overrides
--
-- Loaded last, so anything here beats the modules above. Keep machine-specific
-- settings in this file rather than editing the others -- it keeps the rest of
-- the config portable between boxes.
--
-- Full-time HDR ---------------------------------------------------------------
-- The desktop ships SDR at 10-bit with HDR handed to fullscreen games (see
-- monitors.lua for why). To run the whole desktop in HDR, uncomment this:
--
-- hl.monitor({
-- output = "DP-2",
-- mode = "4500x3000@60",
-- position = "0x0",
-- scale = 1.5,
-- bitdepth = 10,
-- cm = "hdr",
-- sdrbrightness = 1.2, -- how bright SDR content appears, 1.0 - 2.0
-- sdrsaturation = 0.98,
-- })
--
-- Then expect to also need some of these, in this order:
-- hl.config({ render = { keep_unmodified_copy = 1 } }) -- if captures go transparent
-- hl.config({ render = { use_shader_blur_blend = true } }) -- if that kills blur
-- hl.config({ render = { direct_scanout = 0 } }) -- if screenshots come back empty
-- hl.config({ cursor = { no_hardware_cursors = 1 } }) -- if the cursor looks oversaturated
--
-- If screenshots, OBS or Sunshine break and stay broken, that is the known
-- upstream issue -- go back to cm = "auto".
-- ─────────────────────────────────────────────────────────────────────────────
return true
+252
View File
@@ -0,0 +1,252 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences
--
-- Reads the same file Panama Settings writes:
-- $XDG_CONFIG_HOME/panama/settings.json (default ~/.config/panama/settings.json)
--
-- This is what makes the desktop one product rather than two that happen to
-- agree. The relationship is:
--
-- * the Lua config is the DEFAULT -- every prefs.get() call passes the shipped
-- value as its fallback, so this config still works standalone with no JSON
-- file at all;
-- * the JSON file is the TRUTH -- both Hyprland and Quickshell read it;
-- * Panama Settings is the EDITOR -- it writes the file and applies the change
-- live through `hyprctl eval`, so nothing needs a reload and the two sides
-- cannot drift apart.
--
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
-- file must cost the user nothing worse than their customisations; it must
-- never cost them a working compositor. Every failure path returns the caller's
-- fallback.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = {}
-- ── A small JSON reader ─────────────────────────────────────────────────────
-- Hyprland's Lua has no JSON support and pulling in a rock for a flat object of
-- scalars is not worth the dependency. Handles the whole format apart from
-- non-ASCII \u escapes, which are replaced rather than decoded -- no setting is
-- a non-ASCII string, and mangling one is preferable to failing the parse.
local function decode(text)
local pos = 1
local function skipSpace()
pos = text:find("[^ \t\r\n]", pos) or #text + 1
end
local parseValue
local function parseString()
pos = pos + 1 -- opening quote
local parts = {}
while true do
local char = text:sub(pos, pos)
if char == "" then
error("unterminated string")
elseif char == '"' then
pos = pos + 1
break
elseif char == "\\" then
local escape = text:sub(pos + 1, pos + 1)
local simple = {
n = "\n", t = "\t", r = "\r", b = "\b", f = "\f",
['"'] = '"', ["\\"] = "\\", ["/"] = "/",
}
if simple[escape] then
parts[#parts + 1] = simple[escape]
pos = pos + 2
elseif escape == "u" then
local code = tonumber(text:sub(pos + 2, pos + 5), 16)
parts[#parts + 1] = (code and code < 128) and string.char(code) or "?"
pos = pos + 6
else
error("invalid escape")
end
else
parts[#parts + 1] = char
pos = pos + 1
end
end
return table.concat(parts)
end
local function parseNumber()
local literal = text:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos)
if not literal or literal == "" then
error("invalid number")
end
pos = pos + #literal
local value = tonumber(literal)
if not value then
error("invalid number")
end
return value
end
local function parseObject()
pos = pos + 1 -- opening brace
local out = {}
skipSpace()
if text:sub(pos, pos) == "}" then
pos = pos + 1
return out
end
while true do
skipSpace()
if text:sub(pos, pos) ~= '"' then
error("expected key")
end
local key = parseString()
skipSpace()
if text:sub(pos, pos) ~= ":" then
error("expected colon")
end
pos = pos + 1
out[key] = parseValue()
skipSpace()
local char = text:sub(pos, pos)
pos = pos + 1
if char == "}" then
return out
elseif char ~= "," then
error("expected comma or closing brace")
end
end
end
local function parseArray()
pos = pos + 1 -- opening bracket
local out = {}
skipSpace()
if text:sub(pos, pos) == "]" then
pos = pos + 1
return out
end
while true do
out[#out + 1] = parseValue()
skipSpace()
local char = text:sub(pos, pos)
pos = pos + 1
if char == "]" then
return out
elseif char ~= "," then
error("expected comma or closing bracket")
end
end
end
parseValue = function()
skipSpace()
local char = text:sub(pos, pos)
if char == "{" then
return parseObject()
elseif char == "[" then
return parseArray()
elseif char == '"' then
return parseString()
elseif text:sub(pos, pos + 3) == "true" then
pos = pos + 4
return true
elseif text:sub(pos, pos + 4) == "false" then
pos = pos + 5
return false
elseif text:sub(pos, pos + 3) == "null" then
pos = pos + 4
return nil
elseif char == "" then
error("unexpected end of input")
else
return parseNumber()
end
end
local value = parseValue()
if type(value) ~= "table" then
error("top level value is not an object")
end
return value
end
-- ── Loading ─────────────────────────────────────────────────────────────────
local function settingsPath()
local configHome = os.getenv("XDG_CONFIG_HOME")
if configHome == nil or configHome == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
configHome = home .. "/.config"
end
return configHome .. "/panama/settings.json"
end
local function read()
local path = settingsPath()
if not path then
return {}
end
local file = io.open(path, "r")
if not file then
return {} -- no file yet is the normal first-run case, not an error
end
local text = file:read("*a")
file:close()
if not text or text:match("^%s*$") then
return {}
end
local ok, parsed = pcall(decode, text)
if ok and type(parsed) == "table" then
return parsed
end
return {}
end
-- Loaded once at config time. A pcall around the whole thing so that even an
-- unanticipated failure in the reader degrades to shipped defaults.
local values = {}
do
local ok, parsed = pcall(read)
if ok and type(parsed) == "table" then
values = parsed
end
end
-- ── Public interface ────────────────────────────────────────────────────────
-- Returns the stored value for `key`, or `fallback` when it is absent or is not
-- the same type as the fallback. The type guard matters: a stale or hand-edited
-- file that puts a string where Hyprland needs a number would otherwise abort
-- the config, taking down far more than the one setting that was wrong.
function prefs.get(key, fallback)
local value = values[key]
if value == nil then
return fallback
end
if type(value) ~= type(fallback) then
return fallback
end
return value
end
-- Hyprland has no boolean-to-integer coercion for options that take 0/1, and
-- several of them read more naturally as a toggle in the settings UI.
function prefs.getInt(key, fallback)
local value = values[key]
if type(value) == "boolean" then
return value and 1 or 0
end
if type(value) ~= "number" then
return fallback
end
return math.floor(value + 0.5)
end
-- True when a settings file was actually read. Useful from overrides.lua.
function prefs.loaded()
return next(values) ~= nil
end
return prefs
+207
View File
@@ -0,0 +1,207 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Window, workspace and layer rules
--
-- Rules evaluate top to bottom, last match wins -- but every NAMED rule is
-- evaluated before every anonymous one, so a named rule can never override an
-- anonymous rule further down. Names are used sparingly here for that reason.
--
-- Matching is Google RE2 (no backtracking). Prefix a pattern with "negative:"
-- to invert it.
-- ─────────────────────────────────────────────────────────────────────────────
-- ── Upstream sanity rules ───────────────────────────────────────────────────
hl.window_rule({
name = "suppress-maximize-events",
match = { class = ".*" },
suppress_event = "maximize",
})
hl.window_rule({
name = "fix-xwayland-drags",
match = {
class = "^$",
title = "^$",
xwayland = true,
float = true,
fullscreen = false,
pin = false,
},
no_focus = true,
})
-- ── Floating apps ───────────────────────────────────────────────────────────
-- Carried over from the Forge windows.json override list, minus entries for
-- apps that are no longer installed.
hl.window_rule({
match = {
class = "^(jetbrains-toolbox|zoom|Cider|Hidamari|com\\.mattjakeman\\.ExtensionManager)$",
},
float = true,
})
hl.window_rule({
match = { class = "^([Bb]itwarden|com\\.bitwarden\\.desktop)$" },
float = true,
})
hl.window_rule({
match = { class = "^(com\\.nextcloud\\.desktopclient\\.nextcloud|Nextcloud)$" },
float = true,
})
hl.window_rule({
match = { class = "^(org\\.gnome\\.Calculator|gnome-calculator)$" },
float = true,
size = { 400, 600 },
center = true,
})
hl.window_rule({
match = { class = "^(mpv|io\\.mpv\\.Mpv)$" },
float = true,
-- Video should never be blurred or dimmed behind another window.
no_blur = true,
no_dim = true,
})
hl.window_rule({
match = { class = "^(com\\.spotify\\.Client|Spotify)$" },
float = true,
})
-- Settings-style utility windows and pickers: float and centre, like GNOME did.
hl.window_rule({
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
float = true,
size = { "monitor_w * 0.45", "monitor_h * 0.6" },
center = true,
})
-- Portal dialogs (file chooser, screen share picker) should always float.
hl.window_rule({
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
float = true,
center = true,
})
hl.window_rule({
match = { title = "^(Open File|Save File|Save As|Open Folder|Select a File|Choose Files)$" },
float = true,
center = true,
})
-- Polkit prompt.
hl.window_rule({
match = { class = "^(hyprpolkitagent|polkit-gnome-authentication-agent-1)$" },
float = true,
center = true,
})
-- ── Gaming ──────────────────────────────────────────────────────────────────
-- content = "game" is the keystone: misc.vrr = 3, render.direct_scanout = 2 and
-- cursor.no_break_fs_vrr = 2 all key off it. Blur, animation, shadow and dim are
-- all disabled so the compositor gets out of the way entirely.
hl.window_rule({
match = { class = "^(steam_app_\\d+|gamescope|lutris|net\\.lutris\\.Lutris|com\\.heroicgameslauncher\\.hgl|Minecraft.*|moonlight|com\\.moonlight_stream\\.Moonlight)$" },
content = "game",
immediate = true,
no_blur = true,
no_anim = true,
no_shadow = true,
no_dim = true,
})
-- Steam itself is a normal window, but its transient popups are a mess.
hl.window_rule({
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
float = true,
})
-- Fullscreen video in a browser also benefits from no blur / no dim.
hl.window_rule({
match = { fullscreen = true },
no_blur = true,
no_dim = true,
})
-- ── Workspace rules ─────────────────────────────────────────────────────────
-- Deliberately NO "smart gaps".
--
-- The usual trick is to strip gaps, border and rounding when a workspace holds
-- a single tiled window (`workspace = "w[tv1]"` + matching window rules). It
-- was here and it was wrong for this setup: the common case is one window on a
-- workspace, so the desktop spent most of its time square-cornered and
-- edge-to-edge -- the opposite of the intended look. Gaps and rounding are
-- constant now, however many windows are open.
-- ── Layer rules (Quickshell surfaces) ───────────────────────────────────────
-- Namespaces are set in QML via WlrLayershell.namespace; keep these regexes and
-- those strings in sync. `hyprctl layers` lists what is actually live.
--
-- The bar intentionally has no rule: it is a transparent edge-to-edge input
-- surface, not glass. Its individual controls paint their own hover feedback.
-- Keeping a full-width blur rule here would spend compositor work on pixels
-- the bar does not draw.
hl.layer_rule({
name = "qs-dock",
match = { namespace = "^qs-dock$" },
blur = true,
ignore_alpha = 0.3,
})
-- Popovers: calendar, quick settings, notification centre, tray menus.
hl.layer_rule({
name = "qs-popover",
match = { namespace = "^qs-popover" },
blur = true,
blur_popups = true,
ignore_alpha = 0.2,
})
-- Overview, capture and local screen-reading UI dim the desktop behind them.
hl.layer_rule({
name = "qs-overlay",
match = { namespace = "^qs-(overview|capture|screen-intelligence)$" },
blur = true,
ignore_alpha = 0.4,
dim_around = true,
no_screen_share = true,
})
-- Notification toasts. Blurred like every other shell surface -- without this
-- the cards are a near-transparent fill sitting directly on the wallpaper and
-- read as washed out rather than as glass.
--
-- no_anim because the toasts animate themselves in QML, and no_screen_share so
-- they don't leak into OBS or Sunshine streams.
hl.layer_rule({
name = "qs-notifications",
match = { namespace = "^qs-notifications$" },
blur = true,
ignore_alpha = 0.15,
no_anim = true,
no_screen_share = true,
})
-- Signal Glass provides its own event-driven motion, so the compositor only
-- supplies the material blur and privacy behavior.
hl.layer_rule({
name = "qs-signal-glass",
match = { namespace = "^qs-signal-glass$" },
blur = true,
ignore_alpha = 0.15,
no_anim = true,
no_screen_share = true,
})
-- The launcher blurs and dims whatever is behind it.
hl.layer_rule({
name = "launcher",
match = { namespace = "^(vicinae|wofi)$" },
blur = true,
ignore_alpha = 0.5,
dim_around = true,
})
return true
+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",
@@ -0,0 +1,158 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
//
// This object owns reading, validating, and writing. It knows nothing about
// which settings exist -- that is the schema's job -- so adding a setting never
// requires touching this file. That is the point: the previous implementation
// restated every key four times (a property alias, a JSON adapter property, a
// change handler, and a line in reset), and each omission failed silently.
//
// Reads go through get(), writes through set(). Typed accessors for the values
// the shell reads on every frame live in Settings.qml, which remains the stable
// public surface.
//
// The store lives at ~/.config/panama/settings.json rather than inside
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
// able to back it up, diff it, or keep it in a dotfiles repo.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
// Bumped on every accepted change. get() reads it so bindings built on get()
// have something to invalidate; a bare function call would otherwise capture
// no dependency and every reader would silently go stale.
property int revision: 0
// Everything currently on disk, including keys this build does not know
// about. Unknown keys are carried through untouched so that rolling back to
// an older Panama does not discard a newer version's settings.
property var values: ({})
property bool loaded: false
function get(key: string): var {
root.revision;
const stored = root.values[key];
if (stored === undefined)
return PreferenceSchema.defaultFor(key);
const coerced = PreferenceSchema.coerce(key, stored);
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
}
// Returns false when the key is unknown or the value cannot be represented,
// so a caller can surface the rejection instead of assuming it took.
function set(key: string, value: var): bool {
if (!PreferenceSchema.has(key))
return false;
const coerced = PreferenceSchema.coerce(key, value);
if (coerced === undefined)
return false;
if (root.values[key] === coerced)
return true;
// Reassign rather than mutate: QML does not notify on in-place changes
// to a var property's contents.
const next = Object.assign({}, root.values);
next[key] = coerced;
root.values = next;
root.revision++;
persistTimer.restart();
return true;
}
// Restores every schema default in one write. Complete by construction --
// there is no hand-maintained list to fall out of sync with the schema.
function resetDesktopDefaults(): void {
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
root.values = next;
root.revision++;
persistTimer.restart();
}
// Re-read the file from disk. Used after something outside the shell has
// rewritten it -- restoring a snapshot, or a hand edit -- so the running
// desktop reflects the new contents without waiting for the next change.
function reload(): void {
preferencesFile.reload();
root.load();
}
function load(): void {
let parsed = {};
try {
const text = preferencesFile.text();
if (text && text.trim().length > 0)
parsed = JSON.parse(text);
} catch (error) {
// A corrupt file must not cost the user a working desktop. Fall
// back to shipped defaults and let the next write replace it.
parsed = {};
}
root.values = (parsed && typeof parsed === "object") ? parsed : {};
root.revision++;
root.loaded = true;
}
FileView {
id: preferencesFile
path: root.path
blockLoading: true
printErrors: false
atomicWrites: true
onLoaded: root.load()
// No file yet is the normal first-run case, not an error.
onLoadFailed: root.load()
}
Component.onCompleted: {
migration.adopt();
root.load();
}
// Coalesce a burst of changes into one atomic write. Writing on every
// assignment races a second assignment and can reload the older value.
Timer {
id: persistTimer
interval: 0
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
}
// One-time move from the pre-Stage-1 location inside Quickshell's state
// directory. Reads the old file only when the new one does not exist yet, so
// it can never overwrite newer settings, and never deletes the original.
QtObject {
id: migration
function adopt(): void {
if (preferencesFile.text())
return;
try {
const legacy = legacyFile.text();
if (legacy && legacy.trim().length > 0)
preferencesFile.setText(legacy);
} catch (error) {
// Nothing to migrate.
}
}
}
FileView {
id: legacyFile
path: Quickshell.stateDir + "/panama-settings.json"
blockLoading: true
printErrors: false
}
}
@@ -0,0 +1,176 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property alias initialized: values.initialized
property alias favorites: values.favorites
property string saveError: ""
FileView {
id: preferencesFile
path: Quickshell.stateDir + "/panama-home.json"
blockLoading: true
printErrors: false
atomicWrites: true
onSaved: root.saveError = ""
onSaveFailed: error => root.saveError = "Could not save Home favourites."
JsonAdapter {
id: values
property bool initialized: false
property var favorites: []
}
}
Connections {
target: values
function onInitializedChanged(): void { persistTimer.restart(); }
function onFavoritesChanged(): void { persistTimer.restart(); }
}
Component.onCompleted: preferencesFile.reload()
Timer {
id: persistTimer
interval: 180
repeat: false
onTriggered: preferencesFile.writeAdapter()
}
function cloneFavorites(): var {
var clone = [];
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
clone.push({
id: favorite.id,
alias: favorite.alias
});
}
return clone;
}
function isValidEntityId(entityId: var): bool {
return typeof entityId === "string" && /^light\.[a-z0-9_]+$/.test(entityId);
}
function initialize(legacyIds: var): void {
if (values.initialized) {
return;
}
var seededFavorites = [];
var seen = {};
if (legacyIds && typeof legacyIds.length === "number") {
for (var index = 0; index < legacyIds.length; index++) {
var entityId = legacyIds[index];
if (!isValidEntityId(entityId) || seen[entityId]) {
continue;
}
seen[entityId] = true;
seededFavorites.push({ id: entityId, alias: "" });
}
}
values.favorites = seededFavorites;
values.initialized = true;
}
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) {
return true;
}
}
return false;
}
function aliasFor(entityId: string, sourceName: string): string {
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId && favorite.alias !== "") {
return favorite.alias;
}
}
return sourceName;
}
function add(entityId: string): void {
if (!isValidEntityId(entityId) || isSelected(entityId)) {
return;
}
var nextFavorites = cloneFavorites();
nextFavorites.push({ id: entityId, alias: "" });
values.favorites = nextFavorites;
}
function remove(entityId: string): void {
var nextFavorites = [];
var removed = false;
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId) {
removed = true;
continue;
}
nextFavorites.push({ id: favorite.id, alias: favorite.alias });
}
if (removed) {
values.favorites = nextFavorites;
}
}
function setAlias(entityId: string, alias: string): void {
var nextFavorites = cloneFavorites();
var updated = false;
var trimmedAlias = String(alias).trim();
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
nextFavorites[index] = { id: entityId, alias: trimmedAlias };
updated = true;
break;
}
}
if (updated) {
values.favorites = nextFavorites;
}
}
function move(entityId: string, targetIndex: int): void {
var nextFavorites = cloneFavorites();
var currentIndex = -1;
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
currentIndex = index;
break;
}
}
if (currentIndex === -1) {
return;
}
var favorite = nextFavorites.splice(currentIndex, 1)[0];
var clampedIndex = Math.max(0, Math.min(targetIndex, nextFavorites.length));
nextFavorites.splice(clampedIndex, 0, favorite);
values.favorites = nextFavorites;
}
function retrySave(): void {
preferencesFile.writeAdapter();
}
}
@@ -0,0 +1,749 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// The schema is the single source of truth for every user-changeable setting.
//
// One entry describes a setting completely: its name, type, default, valid
// range, which group it belongs to, and how to label it. Persistence,
// validation, reset, and (from Stage 3) the settings UI itself are all derived
// from these entries rather than restated.
//
// Adding a setting means adding one entry here. It does not mean editing a
// property alias, a JSON adapter, a change handler, and a reset function --
// which is what it used to mean, and why the settings app stalled at sixteen
// knobs while forty comparable values stayed hardcoded one file away.
//
// Entry fields
// key unique identifier; also the JSON key on disk
// type "bool" | "int" | "real" | "string" | "enum" | "json"
// def shipped default, used when the file is absent or a value is invalid
// min/max inclusive bounds for int and real; values outside are clamped
// step UI increment for int and real
// options for "enum": [{ value, label }], the only accepted values
// group grouping id, used to build settings pages
// label short UI name
// detail one line explaining what changing it does
// internal true for state the shell keeps but the user never edits directly
// pattern for "string": a regular expression the value must match in full
//
// "json" holds a structured value -- a list or an object -- that the schema
// stores and resets but does not validate field by field. It exists so that
// settings like the dock's pinned applications live in the same file, and are
// covered by the same reset, as everything else rather than growing a fourth
// preference store. The service that owns such a value is responsible for
// validating it; see services/Dock-related consumers.
// hypr present when the setting maps onto an Hyprland option:
// path the hl.config table path, e.g. ["decoration","blur","size"]
// option the getoption path used to read the value back
// readAs which field getoption returns it in -- "int", "bool",
// "float", "str", or "css" (gaps, returned as a box)
//
// Anything with a `hypr` block is applied live by services/SystemSettings.qml
// and read at startup by config/dot/hypr/prefs.lua, using the same key. The Lua
// keeps the shipped value as its fallback, so the config still works with no
// settings file at all.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import QtQuick
Singleton {
id: root
readonly property var entries: [
// ── Clock ───────────────────────────────────────────────────────────
{
key: "use24Hour", type: "bool", def: false, group: "clock",
label: "24-hour time",
detail: "Use 18:30 instead of 6:30 PM"
},
{
key: "showSeconds", type: "bool", def: true, group: "clock",
label: "Show seconds",
detail: "Keep a precise clock in the center of the bar"
},
{
key: "showWeekday", type: "bool", def: true, group: "clock",
label: "Show weekday",
detail: "Include the abbreviated weekday before the date"
},
// ── Vitals ──────────────────────────────────────────────────────────
{
key: "showCpu", type: "bool", def: true, group: "vitals",
label: "Processor",
detail: "Show processor usage beside the workspace indicator"
},
{
key: "showMemory", type: "bool", def: true, group: "vitals",
label: "Memory",
detail: "Show memory usage beside the workspace indicator"
},
{
key: "showGpu", type: "bool", def: true, group: "vitals",
label: "Graphics",
detail: "Show graphics usage beside the workspace indicator"
},
// ── Dock ────────────────────────────────────────────────────────────
{
key: "dockAutohide", type: "bool", def: true, group: "dock",
label: "Automatically hide the Dock",
detail: "Reveal it at the bottom edge when a workspace is occupied"
},
{
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
unit: "ms",
group: "dock",
label: "Reveal delay",
detail: "Zero reveals the Dock the instant the pointer reaches the edge"
},
{
key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
unit: "ms",
group: "dock",
label: "Hide delay",
detail: "Prevents flicker when crossing between icons"
},
// ── Focus ───────────────────────────────────────────────────────────
{
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min",
group: "focus",
label: "Focus session length",
detail: "How long a focus session runs before it ends itself"
},
// ── Display policy ──────────────────────────────────────────────────
// These three are written to the compositor and verified by read-back.
// See services/SystemSettings.qml for why the exit code cannot be
// trusted for either hyprctl keyword or hyprctl eval.
{
key: "autoHdr", type: "bool", def: true, group: "display",
label: "Game-aware HDR",
detail: "Hand HDR to fullscreen games while the desktop stays SDR",
hypr: { path: ["render", "cm_auto_hdr"], option: "render:cm_auto_hdr", readAs: "int" }
},
{
key: "vrrPolicy", type: "enum", def: 3, group: "display",
label: "Variable refresh rate",
detail: "Content-aware matches the display to what is on screen",
options: [
{ value: 0, label: "Off" },
{ value: 3, label: "Content-aware" }
],
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
},
{
key: "directScanoutPolicy", type: "enum", def: 2, group: "display",
label: "Direct scanout",
detail: "Lets fullscreen content bypass compositing",
options: [
{ value: 0, label: "Off" },
{ value: 2, label: "Automatic" }
],
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
},
// ── Window appearance ───────────────────────────────────────────────
// These adjust the parameters of the Prism identity -- how much space,
// how soft, how much motion -- rather than replacing it. Shipped values
// are duplicated as the fallbacks in config/dot/hypr/looks.lua so that
// the Hyprland config still stands on its own.
{
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
unit: "px",
group: "windows",
label: "Inner gaps",
detail: "Space between neighbouring tiled windows",
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
},
{
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
unit: "px",
group: "windows",
label: "Outer gaps",
detail: "Space between the tiled area and the screen edge",
hypr: { path: ["general", "gaps_out"], option: "general:gaps_out", readAs: "css" }
},
{
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
unit: "px",
group: "windows",
label: "Border width",
detail: "Thickness of the gradient border on the focused window",
hypr: { path: ["general", "border_size"], option: "general:border_size", readAs: "int" }
},
{
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
unit: "px",
group: "windows",
label: "Corner radius",
detail: "Matches the shell's popover radius so windows and panels agree",
hypr: { path: ["decoration", "rounding"], option: "decoration:rounding", readAs: "int" }
},
{
key: "inactiveOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Unfocused window opacity",
detail: "Fade windows that do not have focus",
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
},
// ── Effects ─────────────────────────────────────────────────────────
{
key: "blurEnabled", type: "bool", def: true, group: "effects",
label: "Blur",
detail: "Blur the desktop behind translucent surfaces",
hypr: { path: ["decoration", "blur", "enabled"], option: "decoration:blur:enabled", readAs: "bool" }
},
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
group: "effects",
label: "Blur radius",
detail: "Larger is softer and costs more frame time",
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
},
{
key: "blurPasses", type: "int", def: 3, min: 1, max: 5, step: 1,
group: "effects",
label: "Blur passes",
detail: "More passes look smoother and cost more frame time",
hypr: { path: ["decoration", "blur", "passes"], option: "decoration:blur:passes", readAs: "int" }
},
{
key: "shadowEnabled", type: "bool", def: true, group: "effects",
label: "Window shadows",
detail: "Lift windows off the wallpaper with a soft shadow",
hypr: { path: ["decoration", "shadow", "enabled"], option: "decoration:shadow:enabled", readAs: "bool" }
},
{
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
unit: "px",
group: "effects",
label: "Shadow size",
detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
},
{
key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow",
detail: "A faint halo behind the focused window",
hypr: { path: ["decoration", "glow", "enabled"], option: "decoration:glow:enabled", readAs: "bool" }
},
{
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
unit: "px",
group: "effects",
label: "Glow size",
detail: "Kept small deliberately: the gradient border is the signature",
hypr: { path: ["decoration", "glow", "range"], option: "decoration:glow:range", readAs: "int" }
},
{
key: "animationsEnabled", type: "bool", def: true, group: "effects",
label: "Animations",
detail: "Window, workspace, and panel motion",
hypr: { path: ["animations", "enabled"], option: "animations:enabled", readAs: "bool" }
},
// ── Input ───────────────────────────────────────────────────────────
{
key: "keyboardLayout", type: "string", def: "us", group: "input",
// Reaches an hl.config string, so it is constrained to the shape of
// an XKB layout list and nothing else.
pattern: "^[a-z]{2,8}(,[a-z]{2,8})*$",
label: "Keyboard layout",
detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
},
{
key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login",
detail: "Turn Num Lock on when the session starts",
hypr: { path: ["input", "numlock_by_default"], option: "input:numlock_by_default", readAs: "bool" }
},
{
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
unit: "ms",
group: "input",
label: "Repeat delay",
detail: "How long a key is held before it starts repeating",
hypr: { path: ["input", "repeat_delay"], option: "input:repeat_delay", readAs: "int" }
},
{
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
unit: "/s",
group: "input",
label: "Repeat rate",
detail: "How many characters a second a held key produces",
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
},
{
key: "followMouse", type: "enum", def: 1, group: "input",
label: "Focus follows pointer",
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
options: [
{ value: 0, label: "Never" },
{ value: 1, label: "Click to focus" },
{ value: 2, label: "Sloppy focus" }
],
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
},
{
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input",
label: "Pointer speed",
detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
},
{
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s",
group: "input",
label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
// Reported as a float even though it is only ever set to whole
// seconds. `readAs` describes what getoption answers with, not what
// the setting means -- getting this wrong makes every write to it
// look rejected.
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
},
// ── Night light ─────────────────────────────────────────────────────
{
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
label: "Night Light",
detail: "Shift the display warmer to reduce blue light"
},
{
key: "nightLightAutomatic", type: "bool", def: false, group: "nightLight",
label: "Schedule automatically",
detail: "Turn Night Light on and off at the scheduled hours"
},
{
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
unit: "K",
group: "nightLight",
label: "Color temperature",
detail: "Lower is warmer"
},
// ── Desktop background ──────────────────────────────────────────────
// Applied through hyprpaper's IPC. Not an hl.config option, so it has
// no `hypr` block; services/Wallpaper.qml owns applying it.
{
key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
// Reaches hyprpaper as the "<output>,<path>" argument form, so a
// comma would split it into a different request. Absolute paths
// only, no commas, no newlines.
pattern: "^(|/[^,\\n]+)$",
label: "Wallpaper",
detail: "Shown on every output"
},
// ── Idle, lock, and sleep ───────────────────────────────────────────
// Written into a generated hypridle config; see scripts/panama-idle.
// Zero means never for all three.
{
key: "screenBlankMinutes", type: "int", def: 5, min: 0, max: 120, step: 1,
unit: "min", group: "idle",
label: "Turn the screen off after",
detail: "Blanks the display; nothing is locked yet"
},
{
key: "lockMinutes", type: "int", def: 10, min: 0, max: 240, step: 1,
unit: "min", group: "idle",
label: "Lock the screen after",
detail: "Counted from when the session went idle, not from blanking"
},
{
key: "suspendMinutes", type: "int", def: 0, min: 0, max: 480, step: 5,
unit: "min", group: "idle",
label: "Suspend after",
detail: "This is a desktop, so Panama ships with automatic suspend off"
},
{
key: "lockOnSleep", type: "bool", def: true, group: "idle",
label: "Lock before sleeping",
detail: "Requires your password when the machine wakes"
},
// ── Night light schedule ────────────────────────────────────────────
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
// is normal here and is what the shipped values do: on at 17:00, off at
// 10:00 the following morning.
{
key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
group: "nightLight",
label: "Turns on at",
detail: "Only used when Night Light follows a schedule"
},
{
key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
group: "nightLight",
label: "Turns off at",
detail: "A time earlier than the start simply means the next morning"
},
// ── Colour scheme ───────────────────────────────────────────────────
// Light is Tokyo Night Day, the official light variant, rather than a
// palette invented to merely not be dark. Both share the same hues at
// different lightness, which is what keeps the Prism identity intact
// across the switch.
//
// services/ColorScheme.qml pushes the choice to GTK and to the
// compositor's border colours, because an application toolbar or a
// window border still wearing the other scheme is more jarring than
// either scheme on its own.
{
key: "colorScheme", type: "enum", def: "dark", group: "appearance",
label: "Appearance",
detail: "Light and dark share one identity, not two themes",
options: [
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" }
]
},
// ── Typography ──────────────────────────────────────────────────────
// The single largest thing in this desktop that used to be changeable
// only by editing Theme.qml.
//
// interfaceFont is every piece of text a person reads. iconFont is used
// ONLY to draw glyphs -- workspace pills, the status cluster, search
// icons -- so it must carry Nerd Font glyphs; a plain monospace family
// there replaces every icon in the shell with tofu, which is why the
// picker offers them as a separate, filtered list.
{
key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography",
// A family name, matched by fontconfig. Constrained because it also
// reaches hl.config as a string in hypr/looks.lua.
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
label: "Interface font",
detail: "Used for every piece of text in the shell"
},
{
key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
label: "Icon font",
detail: "Draws the shell's glyphs, so it must be a Nerd Font"
},
{
key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1,
unit: "px", group: "typography",
label: "Interface text size",
detail: "The base size the rest of the shell's type scales from"
},
// ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and
// pushed to the compositor as well where it has its own notion.
{
key: "cursorSize", type: "int", def: 24, min: 16, max: 64, step: 4,
unit: "px", group: "accessibility",
label: "Pointer size",
detail: "Applies to the compositor and to applications"
},
{
key: "textScale", type: "real", def: 1.0, min: 0.75, max: 2.0, step: 0.05,
group: "accessibility",
label: "Text size",
detail: "Scales interface text everywhere; 1.00 is the design size"
},
// ── Weather ─────────────────────────────────────────────────────────
{
key: "temperatureUnit", type: "enum", def: "fahrenheit", group: "weather",
label: "Temperature unit",
detail: "Choose Fahrenheit or Celsius for the weather card",
options: [
{ value: "fahrenheit", label: "Fahrenheit" },
{ value: "celsius", label: "Celsius" }
]
},
{
key: "weatherRefreshMinutes", type: "int", def: 20, min: 5, max: 120, step: 5,
unit: "min", group: "weather",
label: "Weather refresh",
detail: "How often Panama updates the current conditions"
},
// ── Which GPU the vitals readout tracks ─────────────────────────────
// A sysfs path rather than a card number, because the number is neither
// stable across machines nor meaningful. Constrained to the one shape
// that can be read for utilisation; VitalsWidget hides itself when the
// path is unreadable, so a stale value degrades to no readout rather
// than a wrong one.
{
key: "gpuBusyPath", type: "string",
def: "/sys/class/drm/card1/device/gpu_busy_percent",
group: "vitals", internal: true,
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
label: "Graphics device",
detail: "Which GPU the graphics readout in the bar measures"
},
// ── Weather location ────────────────────────────────────────────────
// Coordinates rather than a place name, because that is what Open-Meteo
// takes and it needs no API key. weatherLocation is only the label shown
// in the UI; it is never sent anywhere, so it can say whatever makes the
// reading recognisable.
{
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
group: "weather", internal: true,
label: "Latitude",
detail: "Set by choosing a location"
},
{
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
group: "weather", internal: true,
label: "Longitude",
detail: "Set by choosing a location"
},
{
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
internal: true,
// Display only -- never sent to the weather service.
pattern: "^[^\\n]{1,64}$",
label: "Weather location",
detail: "The place the weather reading is for"
},
// ── Vitals refresh ──────────────────────────────────────────────────
{
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
unit: "ms", group: "vitals",
label: "Vitals refresh",
detail: "How often processor, memory, and graphics usage update"
},
// ── Notifications ───────────────────────────────────────────────────
{
key: "notificationTimeoutMs", type: "int", def: 5000, min: 1000, max: 30000, step: 500,
unit: "ms", group: "notifications",
label: "Notification duration",
detail: "How long ordinary notification banners remain visible"
},
{
key: "notificationTimeoutCriticalMs", type: "int", def: 0, min: 0, max: 60000, step: 1000,
unit: "ms", group: "notifications",
label: "Critical notification duration",
detail: "Zero keeps critical notification banners visible until dismissed"
},
{
key: "notificationHistoryLimit", type: "int", def: 100, min: 10, max: 500, step: 10,
group: "notifications",
label: "Notification history",
detail: "Maximum notifications retained in the notification center"
},
{
key: "maxVisibleToasts", type: "int", def: 4, min: 1, max: 8, step: 1,
group: "notifications",
label: "Visible banners",
detail: "Maximum notification banners shown at once"
},
// ── Capture ─────────────────────────────────────────────────────────
// Directories and encoder arguments are enums rather than free text:
// both are handed to a recorder process, and an arbitrary string there
// is a much larger surface than a settings page needs to expose.
{
key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
label: "Screenshot folder",
detail: "Folder under your home directory for screenshots",
options: [
{ value: "Pictures/Screenshots", label: "Pictures / Screenshots" },
{ value: "Pictures", label: "Pictures" },
{ value: "Desktop", label: "Desktop" }
]
},
{
key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
label: "Recording folder",
detail: "Folder under your home directory for screen recordings",
options: [
{ value: "Videos/Recordings", label: "Videos / Recordings" },
{ value: "Videos", label: "Videos" },
{ value: "Desktop", label: "Desktop" }
]
},
{
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
group: "capture",
label: "Recording encoder",
detail: "Hardware encoding keeps recording off the processor while gaming",
options: [
{ value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
{ value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
{ value: "-c libx264", label: "CPU x264" }
]
},
// ── Dock contents ───────────────────────────────────────────────────
// A "json" value: the ordered list of desktop entry ids pinned to the
// dock. Kept in the shared store so that reordering the dock is covered
// by Restore defaults like everything else, rather than living in its
// own file. The shipped order is the GNOME dash it replaced, with one
// deliberate substitution: Panama Settings takes the first slot rather
// than GNOME Settings. Panama now covers what GNOME Settings did for
// this desktop and delegates the remainder to it by name, so pinning
// the thing it delegates TO put the fallback in front of the real one.
// GNOME Settings stays installed and searchable in the launcher.
{
key: "dockPinned", type: "json", group: "dock",
label: "Pinned applications",
detail: "Applications that stay in the Dock whether or not they are running",
def: [
"panama-settings", "kitty", "org.gnome.Nautilus",
"com.bitwarden.desktop", "org.gnome.Software", "helium",
"org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk",
"io.podman_desktop.PodmanDesktop", "claude-desktop",
"codex-desktop", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam"
]
},
// ── Keyboard shortcut overrides ─────────────────────────────────────
// { "<bind description>": "<chord>" }. Only the chord is stored: the
// action always comes from hypr/keybinds.lua, so an override can move a
// shortcut but can never make one do something else. The Lua validates
// each chord and falls back to the shipped one, so a hand-edited file
// cannot cost you a keymap.
//
// Edited through the Input & Shortcuts page rather than as a row, hence
// internal.
{
key: "keybindOverrides", type: "json", def: ({}), group: "input",
internal: true,
label: "Keyboard shortcut overrides",
detail: "Shortcuts you have moved from their shipped chord"
},
// ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform } }, applied by
// hypr/monitors.lua on top of the shipped values. Colour management and
// bit depth are deliberately not here: those carry a documented
// screencopy tradeoff that a settings page cannot explain at the moment
// you would be changing it.
{
key: "displays", type: "json", def: ({}), group: "display",
internal: true,
label: "Display configuration",
detail: "Resolution, scale, and rotation per connected display"
},
// ── Per-application notification rules ──────────────────────────────
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
//
// Absent means "no rule", which is not the same as a rule that allows
// everything: a new application must be able to notify without needing
// an entry written for it first. services/Notifs.qml treats a missing
// entry as permissive and Do Not Disturb remains an override on top,
// rather than being duplicated per application.
{
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
internal: true,
label: "Application notification rules",
detail: "Per-application notification and lock-screen visibility preferences"
},
// ── Internal ────────────────────────────────────────────────────────
{
key: "lastPage", type: "string", def: "home", group: "internal",
internal: true,
label: "Last settings page",
detail: "Restores the page Settings was left on"
}
]
// key -> entry, built once. Every lookup below goes through this rather than
// scanning `entries`, since get/set are called from bindings.
readonly property var byKey: {
const index = {};
for (const entry of root.entries)
index[entry.key] = entry;
return index;
}
readonly property var userKeys: root.entries.filter(entry => !entry.internal).map(entry => entry.key)
function spec(key: string): var {
return root.byKey[key] ?? null;
}
function has(key: string): bool {
return root.byKey[key] !== undefined;
}
function defaultFor(key: string): var {
const entry = root.byKey[key];
return entry ? entry.def : undefined;
}
function defaults(): var {
const out = {};
for (const entry of root.entries)
out[entry.key] = entry.def;
return out;
}
function inGroup(group: string): var {
return root.entries.filter(entry => entry.group === group && !entry.internal);
}
// Entries the compositor owns, used to build one hl.config{} payload.
function hyprEntries(): var {
return root.entries.filter(entry => entry.hypr !== undefined);
}
// Returns the value coerced into the entry's type and range, or `undefined`
// if it cannot be represented at all. Out-of-range numbers are clamped
// rather than rejected: a stale file with a since-narrowed bound should
// still yield a usable desktop.
function coerce(key: string, value: var): var {
const entry = root.byKey[key];
if (!entry || value === undefined || value === null)
return undefined;
switch (entry.type) {
case "bool":
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return undefined;
case "int":
case "real": {
const numeric = Number(value);
if (!isFinite(numeric)) return undefined;
const rounded = entry.type === "int" ? Math.round(numeric) : numeric;
const lower = entry.min !== undefined ? Math.max(rounded, entry.min) : rounded;
return entry.max !== undefined ? Math.min(lower, entry.max) : lower;
}
case "enum":
return entry.options.some(option => option.value === value) ? value : undefined;
case "json":
// Accepted as-is. Anything JSON.parse produced is representable,
// and per-field meaning belongs to the owning service rather than
// here. A scalar is rejected so a corrupt file falls back to the
// default instead of handing a list-shaped consumer a number.
return (typeof value === "object") ? value : undefined;
case "string": {
const text = typeof value === "string" ? value : String(value);
// A constrained string is rejected rather than sanitised. Several
// of these are serialised into an hl.config payload, and quietly
// stripping characters would turn a typo into a different setting
// instead of an error the user can see.
if (entry.pattern && !new RegExp(entry.pattern).test(text))
return undefined;
return text;
}
}
return undefined;
}
}
+87
View File
@@ -0,0 +1,87 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// User-facing knobs. Everything here is something you might reasonably want to
// change without understanding the rest of the shell.
//
// Theme.qml holds *how it looks*; this holds *what it does*.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import QtQuick
Singleton {
id: root
// ── Clock ───────────────────────────────────────────────────────────────
// Carried over from GNOME: 12-hour, weekday + date shown, seconds on.
readonly property bool use24Hour: DesktopPreferences.get("use24Hour")
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
// ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already
// resolved the location. Uses Open-Meteo, which needs no API key.
readonly property real latitude: DesktopPreferences.get("weatherLatitude")
readonly property real longitude: DesktopPreferences.get("weatherLongitude")
// Open-Meteo returns coordinates but no friendly place name. Keep the
// label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them.
readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
// ── Vitals ──────────────────────────────────────────────────────────────
// The GNOME Vitals extension showed processor usage, memory usage and GPU
// usage, in that order. Same here.
readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu")
// amdgpu exposes utilisation here. Verified present on this machine; the
// widget hides itself if the path is missing rather than showing zeros.
readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath")
// ── Night light ─────────────────────────────────────────────────────────
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ───────────────────────────────────────────────────────
readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and
// the keyboard shortcut should start a useful session in one action.
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
// ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites.
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
// Dash-to-Dock was set to intellihide against all windows: the dock hides
// when any window would overlap it, and comes back on hover.
readonly property bool dockAutohide: DesktopPreferences.get("dockAutohide")
// 0: reveal the instant the pointer reaches the bottom edge. A reveal delay
// is indistinguishable from lag, because the user has already committed to
// the gesture by the time the strip is hit.
readonly property int dockRevealDelayMs: DesktopPreferences.get("dockRevealDelayMs")
// Hiding keeps a delay, so brushing past the bottom edge or crossing the
// gap between two icons doesn't make the dock flicker.
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
// ── Capture ─────────────────────────────────────────────────────────────
readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
readonly property string recordingDir: DesktopPreferences.get("recordingDir")
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming.
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
}
+173
View File
@@ -0,0 +1,173 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Tokyo Night Moon — the single source of truth for every colour, radius,
// duration and font in the shell. Nothing below this file should hardcode a
// colour; if you find yourself writing "#" outside this file, add a token here.
//
// The bar keeps GNOME's 36px information density but sits directly on the
// desktop, edge to edge. The dock retains the former glass vocabulary: bottom,
// 48px icons and dot indicators.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
// QtQuick is required even though nothing visual is declared here: `color` is a
// QtQuick value type, and Qt.rgba() lives in its JS namespace.
import QtQuick
Singleton {
id: root
// ── Colour scheme ───────────────────────────────────────────────────────
// Tokyo Night ships an official light variant (Day), so light mode is that
// rather than a palette invented to merely not be dark. The two share the
// same hues at different lightness, which is what lets the Prism identity
// survive the switch: blue still leads into orchid, it is simply a darker
// blue on a lighter ground.
//
// Every token below is a binding on this, so flipping it repaints the whole
// shell without anything needing to know it happened.
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// ── Palette ─────────────────────────────────────────────────────────────
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
// The pair. `accent` is the primary and carries every state meaning
// (focused, active, on). `accentSecondary` is the orchid from the tmux
// theme — it never appears alone, only as the far end of a gradient. That
// restraint is the whole point: the two colours meeting is the signature,
// so the pink stops being special the moment it's used as a flat fill.
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
readonly property color green: root.dark ? "#c3e88d" : "#587539"
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
// Semantic aliases — prefer these in widgets so intent survives a repaint.
readonly property color ok: green
readonly property color warn: yellow
readonly property color danger: red
readonly property color urgent: orange
// ── Surface alphas ──────────────────────────────────────────────────────
// The dock sits on compositor blur, so its fill is deliberately very
// translucent. Popovers are near-opaque because they carry text that must
// stay legible. The bar itself has no surface or alpha token.
// Light surfaces need more opacity for the same sense of a solid panel: the
// same 0.34 that reads as glass over a dark desktop reads as haze over a
// light one, and text on it stops being legible.
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
// ── Geometry ────────────────────────────────────────────────────────────
readonly property int barHeight: 36
readonly property int barGap: 6 // breathing room below the bar for popovers
readonly property int barSideMargin: 10 // inset for floating popovers
readonly property int dockIconSize: 48
readonly property int dockPadding: 8
readonly property int dockGap: 8
readonly property int dockRadius: 20
readonly property int popoverRadius: 18
readonly property int popoverPadding: 14
readonly property int popoverWidth: 380
readonly property int controlCenterWidth: 430
readonly property int controlCenterTopGap: 2
readonly property int cardRadius: 12
readonly property int pillRadius: 999
readonly property int itemSpacing: 8
readonly property int sectionSpacing: 14
// ── Type ────────────────────────────────────────────────────────────────
// Adwaita Sans for everything the user reads as text. No exceptions: a
// monospaced clock or percentage reads as a terminal readout pasted into a
// UI, which is the opposite of the intent here.
readonly property string fontFamily: DesktopPreferences.get("interfaceFont")
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
// pragmatic alternative to freedesktop symbolic icons, which ship with a
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
readonly property string fontMono: DesktopPreferences.get("iconFont")
// Apply to any text whose digits change in place — clocks, percentages,
// elapsed timers, dimension readouts. Tabular figures all share one
// advance width, so the text stops twitching as the numbers tick without
// needing a monospaced face.
//
// font.family: Theme.fontFamily
// font.features: Theme.tabularFigures
readonly property var tabularFigures: ({
"tnum": 1
})
// The four sizes are a scale, not four independent numbers: they move
// together so the relationship between body, caption, heading and title
// survives a change to the base rather than drifting apart.
readonly property int fontSize: DesktopPreferences.get("interfaceFontSize")
readonly property int fontSizeSmall: Math.max(9, root.fontSize - 2)
readonly property int fontSizeLarge: root.fontSize + 3
readonly property int fontSizeTitle: root.fontSize + 7
// ── Motion ──────────────────────────────────────────────────────────────
// Event-driven only. Nothing in this shell animates while idle — no pulse,
// no shimmer, no spinners. These durations are used for open/close/hover.
readonly property int durFast: 120
readonly property int durNormal: 200
readonly property int durSlow: 320
// The dock revealing is the one animation that answers a live pointer
// movement, so it gets its own (much shorter) duration. Anything slower
// reads as the desktop lagging behind the cursor rather than as motion.
readonly property int durDockReveal: 90
// Matches the "snappy" spring curve defined in hypr/looks.lua.
readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0]
readonly property list<real> easeOvershoot: [0.34, 1.56, 0.64, 1.0]
// ── Helpers ─────────────────────────────────────────────────────────────
// Qt.alpha() does not exist; this is the idiomatic way to re-alpha a token.
function alpha(c: color, a: real): color {
return Qt.rgba(c.r, c.g, c.b, a);
}
// Blend two tokens — used for hover/pressed states so we never invent a
// colour that isn't derived from the palette.
function mix(a: color, b: color, ratio: real): color {
return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio);
}
// ── The prism ───────────────────────────────────────────────────────────
// Where the two accents meet. Used for the hairline along the top edge of
// every glass surface (widgets/PrismEdge.qml), the focused window border,
// the active workspace pill and slider fills.
//
// Where the gradient runs left-to-right the blue leads, because the eye
// reads left first and blue is the colour that carries meaning.
readonly property real prismStart: 0.22
readonly property real prismEnd: 0.78
// Opacity of the hairline itself. Deliberately below 1: at full strength it
// reads as a drawn line rather than as light catching an edge.
readonly property real prismEdgeAlpha: 0.85
}
+6
View File
@@ -0,0 +1,6 @@
module qs.config
singleton DesktopPreferences 1.0 DesktopPreferences.qml
singleton PreferenceSchema 1.0 PreferenceSchema.qml
singleton HomePreferences 1.0 HomePreferences.qml
singleton Settings 1.0 Settings.qml
singleton Theme 1.0 Theme.qml
@@ -0,0 +1,28 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
IpcHandler {
target: "connectivity-test"
function status(): string {
return JSON.stringify({
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
networks: Connectivity.networks.length,
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
adapter: Connectivity.adapter ? true : false,
btDevices: Connectivity.bluetoothDevices.length
});
}
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
function setActive(on: bool): void { Connectivity.active = on; }
}
}
@@ -0,0 +1,75 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "displays-test"
function status(): string {
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
return JSON.stringify({
count: Displays.monitors.length,
name: monitor ? monitor.name : "",
width: monitor ? monitor.width : 0,
height: monitor ? monitor.height : 0,
refresh: monitor ? monitor.refreshRate : 0,
mode: monitor ? monitor.mode : "",
scale: monitor ? monitor.scale : 0,
transform: monitor ? monitor.transform : -1,
modes: monitor ? monitor.modes.length : 0,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
secondsLeft: Displays.secondsLeft,
lastError: Displays.lastError,
overridden: monitor ? Displays.isOverridden(monitor.name) : false
});
}
function applyScale(scale: real): bool {
const monitor = Displays.monitors[0];
if (!monitor) return false;
const mode = monitor.mode;
return Displays.apply(monitor.name, mode, scale, monitor.transform);
}
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
"[email protected]",
"[email protected]"
]);
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
return JSON.stringify({
count: modes.length,
modes: modes.map(mode => mode.mode),
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
});
}
function applyBad(kind: string): bool {
const monitor = Displays.monitors[0];
if (!monitor) return false;
const mode = monitor.mode;
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
if (kind === "dirtyScale") {
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
}
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
return false;
}
function confirmChange(): bool { return Displays.confirm(); }
function revertChange(): void { Displays.revert(); }
function forget(): void {
const monitor = Displays.monitors[0];
if (monitor) Displays.forget(monitor.name);
}
function refresh(): void { Displays.refresh(); }
}
}
@@ -0,0 +1,35 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
// DesktopEntries populates asynchronously. Reading `applications.values`
// here rather than calling byId() blind is the documented way to wait for
// it -- and is exactly the trap noted in modules/settings/README.md.
readonly property var ids: {
const out = {};
for (const entry of DesktopEntries.applications.values)
out[entry.id] = entry;
return out;
}
IpcHandler {
target: "dock-pin-test"
function count(): int { return DesktopEntries.applications.values.length; }
function resolve(): string {
const pinned = DesktopPreferences.get("dockPinned");
const missing = [];
let first = null;
for (const id of pinned) {
const entry = DesktopEntries.byId(id);
if (!entry) missing.push(id);
if (first === null)
first = { id: id, found: !!entry, name: entry ? entry.name : "", icon: entry ? entry.icon : "" };
}
return JSON.stringify({ total: pinned.length, first: first, missing: missing });
}
}
}
@@ -0,0 +1,54 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.quicksettings
ShellRoot {
id: root
property int confirmedValue: 30
property int commitCount: 0
property int lastCommit: -1
HomeBrightnessSlider {
id: slider
width: 200
value: root.confirmedValue
accessibleName: "Desk lamp brightness"
onCommitted: value => {
root.commitCount += 1;
root.lastCommit = value;
}
}
IpcHandler {
target: "home-brightness-slider-test"
function reset(value: int): void {
root.confirmedValue = value;
root.commitCount = 0;
root.lastCommit = -1;
slider.cancelPointerInteraction();
}
function external(value: int): void { root.confirmedValue = value; }
function press(position: int): void { slider.beginPointerInteraction(position); }
function move(position: int): void { slider.movePointerInteraction(position); }
function release(): void { slider.releasePointerInteraction(); }
function cancel(): void { slider.cancelPointerInteraction(); }
function wheel(delta: int): void { slider.commitWheel(delta); }
function status(): string {
return JSON.stringify({
confirmedValue: root.confirmedValue,
previewValue: slider.previewValue,
interactionActive: slider.interactionActive,
commitCount: root.commitCount,
lastCommit: root.lastCommit,
accessibleRoleIsSlider: slider.Accessible.role === Accessible.Slider,
accessibleName: slider.Accessible.name,
accessibleDescription: slider.Accessible.description,
accessibleFocusable: slider.Accessible.focusable
});
}
}
}
@@ -0,0 +1,26 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
IpcHandler {
target: "home-pref-test"
function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
function add(id: string): void { HomePreferences.add(id); }
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); }
function reset(): void { HomePreferences.resetHomeDefaults(); }
function status(): string {
return JSON.stringify({
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites,
saveError: HomePreferences.saveError,
stateDir: Quickshell.stateDir
});
}
}
}
@@ -0,0 +1,58 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "keybinds-test"
function rebind(current: string, next: string): bool {
return Keybinds.rebind(current, next);
}
function resetBind(current: string): bool { return Keybinds.resetBind(current); }
function resetAll(): void { Keybinds.resetAll(); }
function seedResetCollision(shipped: string, current: string, occupantShipped: string): void {
const overrides = {};
overrides[shipped] = current;
overrides[occupantShipped] = shipped;
DesktopPreferences.set("keybindOverrides", overrides);
}
function chordFor(description: string): string {
const found = Keybinds.binds.find(bind => bind.description === description);
return found ? found.luaChord : "";
}
function overrideState(): string {
return JSON.stringify({
overrides: Keybinds.overrides,
count: Object.keys(Keybinds.overrides).length,
lastError: Keybinds.lastError
});
}
function status(): string {
const grouped = Keybinds.grouped();
let groupedCount = 0;
for (const group of grouped)
groupedCount += group.binds.length;
const superT = Keybinds.binds.find(bind => bind.description === "Terminal");
return JSON.stringify({
loaded: Keybinds.loaded,
count: Keybinds.binds.length,
groupedCount: groupedCount,
groups: grouped.map(group => group.name),
sample: superT ? superT.chord : "",
emptyDescriptions: Keybinds.binds.filter(bind => !bind.description).length,
lastError: Keybinds.lastError
});
}
}
}
@@ -0,0 +1,42 @@
// Persistent, bar-native privacy state. Signal Glass handles transitions; this
// remains until the underlying capture activity actually ends.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: PrivacyState.anyActive
horizontalPadding: 8
onActivated: ShellState.toggle("activity")
Text {
anchors.verticalCenter: parent.verticalCenter
text: {
if (PrivacyState.recordingActive)
return "\u{F044A}";
if (PrivacyState.screenSharingActive)
return "\u{F0379}";
if (PrivacyState.cameraActive)
return "\u{F0100}";
return "\u{F036C}";
}
color: PrivacyState.recordingActive ? Theme.danger : Theme.warn
font.family: Theme.fontMono
font.pixelSize: 14
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: PrivacyState.activeKinds.length > 1
text: String(PrivacyState.activeKinds.length)
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
@@ -0,0 +1,218 @@
// Compact explanation and control surface for persistent privacy indicators.
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
PanelWindow {
id: root
visible: ShellState.activityOpen && PrivacyState.anyActive
color: "transparent"
anchors.top: true
anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2
margins.right: Theme.barSideMargin
exclusiveZone: 0
implicitWidth: 350
implicitHeight: surface.implicitHeight
WlrLayershell.namespace: "qs-popover-activity"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
readonly property var activities: {
const result = [];
if (PrivacyState.recordingActive)
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true });
if (PrivacyState.screenSharingActive)
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.cameraActive)
result.push({ kind: "camera", glyph: "\u{F0100}", label: "Camera", detail: PrivacyState.cameraApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.microphoneActive)
result.push({ kind: "microphone", glyph: "\u{F036C}", label: "Microphone", detail: PrivacyState.microphoneApp || "Managed by the application", tone: "warn", stoppable: false });
return result;
}
function elapsed(): string {
const total = Capture.recordingSeconds;
const seconds = String(total % 60).padStart(2, "0");
const minutes = Math.floor(total / 60) % 60;
const hours = Math.floor(total / 3600);
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
}
onVisibleChanged: {
if (root.visible)
openAnimation.restart();
}
Connections {
target: PrivacyState
function onAnyActiveChanged(): void {
if (!PrivacyState.anyActive && ShellState.activityOpen)
ShellState.close();
}
}
HyprlandFocusGrab {
windows: [root]
active: root.visible
onCleared: ShellState.close()
}
Rectangle {
id: surface
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
anchors.left: parent.left
anchors.right: parent.right
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.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
transform: Scale {
id: openScale
origin.x: surface.width
origin.y: 0
xScale: 1
yScale: 1
}
Column {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.popoverPadding
spacing: 8
Text {
width: parent.width
text: "Privacy & activity"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Active access stays visible until it ends."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.08)
}
Repeater {
model: root.activities
Rectangle {
id: activityRow
required property var modelData
width: content.width
height: 50
radius: Theme.cardRadius
border.width: 0
color: Theme.alpha(Theme.fg, 0.045)
Text {
id: activityIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: activityRow.modelData.glyph
color: activityRow.modelData.tone === "danger" ? Theme.danger : Theme.warn
font.family: Theme.fontMono
font.pixelSize: 17
}
Column {
anchors.left: activityIcon.right
anchors.leftMargin: 11
anchors.right: stopButton.visible ? stopButton.left : parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: activityRow.modelData.label
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: activityRow.modelData.detail
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
id: stopButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
visible: activityRow.modelData.stoppable
width: 54
height: 28
radius: Theme.pillRadius
border.width: 0
color: stopMouse.containsMouse ? Theme.alpha(Theme.danger, 0.26) : Theme.alpha(Theme.danger, 0.15)
Text {
anchors.centerIn: parent
text: "Stop"
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: stopMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Capture.stopRecording()
}
}
}
}
}
}
NumberAnimation {
id: openAnimation
target: openScale
property: "yScale"
from: 0.96
to: 1
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
+138
View File
@@ -0,0 +1,138 @@
// The top bar: edge-to-edge system chrome with no surface of its own. Content
// floats directly over the desktop, while individual controls still provide
// hover and press feedback. The entire bar is a hit target so the top-left
// corner remains a dependable workspace-scroll gesture.
//
// Instantiate one per screen:
//
// Variants {
// model: Quickshell.screens
// Bar {}
// }
//
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather centre,
// media + tray + status cluster right.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.modules.clipboard
import qs.modules.focus
PanelWindow {
id: root
// Set by Variants. Left as a plain property (not `required`) so the bar can
// also be instantiated standalone on the default screen while testing.
property var modelData: null
// Raised when the status cluster is clicked; the shell wires this to the
// quick settings panel, which this module does not own.
signal requestQuickSettings
// Bound by the shell when an idle inhibitor is held, so the cluster can
// show the caffeine glyph.
property alias idleInhibited: statusCluster.idleInhibited
screen: root.modelData
anchors {
top: true
left: true
right: true
}
// PanelWindow defaults to an opaque white background.
color: "transparent"
implicitHeight: Theme.barHeight
exclusiveZone: Theme.barHeight
// Stable namespace for compositor inspection and shell diagnostics.
WlrLayershell.namespace: "qs-bar"
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// The full-width region is intentional: unlike a floating island, a menu
// bar owns the screen edge. In particular, Workspaces starts at x=0 and
// spans the full height so scrolling at the literal corner always works.
mask: Region {
item: barContent
}
Item {
id: barContent
anchors.fill: parent
// ── Left ────────────────────────────────────────────────────────────
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.itemSpacing
Workspaces {
anchors.verticalCenter: parent.verticalCenter
screen: root.screen
}
VitalsWidget {
anchors.verticalCenter: parent.verticalCenter
}
}
// ── Centre ──────────────────────────────────────────────────────────
Row {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Clock {
anchors.verticalCenter: parent.verticalCenter
}
WeatherWidget {
anchors.verticalCenter: parent.verticalCenter
}
}
// ── Right ───────────────────────────────────────────────────────────
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.itemSpacing
CalendarIndicator {
anchors.verticalCenter: parent.verticalCenter
}
ActivityIndicator {
anchors.verticalCenter: parent.verticalCenter
}
FocusIndicator {
anchors.verticalCenter: parent.verticalCenter
}
MediaWidget {
anchors.verticalCenter: parent.verticalCenter
}
ClipboardWidget {
anchors.verticalCenter: parent.verticalCenter
}
Tray {
anchors.verticalCenter: parent.verticalCenter
}
StatusCluster {
id: statusCluster
anchors.verticalCenter: parent.verticalCenter
onRequestQuickSettings: root.requestQuickSettings()
}
}
}
}
@@ -0,0 +1,41 @@
// Month-stepping arrow in CalendarPopup's header. Round hover target, sized
// like GNOME's calendar arrows rather than a bare glyph.
import QtQuick
import qs.config
Rectangle {
id: root
signal activated
required property string glyph
width: 24
height: 24
radius: width / 2
border.width: 0
color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
anchors.centerIn: parent
text: root.glyph
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
color: Theme.fgDim
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
}
@@ -0,0 +1,53 @@
// A deliberately quiet appointment reminder. It only enters the bar during
// the narrow window where an upcoming event is immediately useful.
import QtQuick
import QtQuick.Controls
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: CalendarAgenda.capsuleVisible
opacity: visible ? 1 : 0
horizontalPadding: 8
onActivated: ShellState.openDateMenu("agenda")
ToolTip.visible: root.hovered && root.visible
ToolTip.delay: 500
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
Behavior on opacity {
NumberAnimation {
duration: 120
easing.type: Easing.OutCubic
}
}
Behavior on implicitWidth {
NumberAnimation {
duration: 120
easing.type: Easing.OutCubic
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\u{F00ED}"
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 13
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: CalendarAgenda.capsuleText
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
@@ -0,0 +1,222 @@
// The calendar that drops out of the clock — GNOME's date menu, minus the
// notification list (that lives in its own panel). Month grid, today marked
// with the accent, arrows to page through months, current weather at the foot.
import Quickshell
import QtQuick
import qs.config
import qs.services
import qs.widgets
Popover {
id: root
// Popover's container is a plain Item, which does not derive an implicit
// size from its children, so the window has to be sized from the body.
implicitWidth: body.implicitWidth + contentPadding * 2
implicitHeight: body.implicitHeight + contentPadding * 2
readonly property int cellSize: 34
readonly property int cellHeight: 30
// Hours precision is enough: the only thing that has to change on its own
// is which cell counts as "today", and that only moves at midnight.
SystemClock {
id: clock
precision: SystemClock.Hours
}
readonly property int todayYear: clock.date.getFullYear()
readonly property int todayMonth: clock.date.getMonth()
readonly property int todayDay: clock.date.getDate()
// The month currently on screen. Reset to today every time the popover
// opens, so it never comes back showing wherever you paged off to.
property int viewYear: root.todayYear
property int viewMonth: root.todayMonth
onVisibleChanged: if (visible)
root.showToday()
function showToday(): void {
root.viewYear = root.todayYear;
root.viewMonth = root.todayMonth;
}
function stepMonth(delta: int): void {
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
root.viewYear = d.getFullYear();
root.viewMonth = d.getMonth();
}
// Six weeks of cells, so the grid height never changes as you page through
// months. Days from the neighbouring months fill the edges, dimmed.
readonly property var cells: {
const first = new Date(root.viewYear, root.viewMonth, 1);
const offset = first.getDay(); // 0 = Sunday, matching the header row
const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate();
const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate();
const out = [];
for (let i = 0; i < 42; i++) {
const n = i - offset + 1;
if (n < 1)
out.push({
day: inPrevMonth + n,
current: false
});
else if (n > inThisMonth)
out.push({
day: n - inThisMonth,
current: false
});
else
out.push({
day: n,
current: true
});
}
return out;
}
Column {
id: body
spacing: Theme.itemSpacing
// ── Month header ────────────────────────────────────────────────────
Item {
width: root.cellSize * 7
height: 28
CalendarArrow {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
glyph: "\u{F0141}" // md-chevron_left
onActivated: root.stepMonth(-1)
}
Text {
anchors.centerIn: parent
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
color: Theme.fg
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.showToday()
}
}
CalendarArrow {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
glyph: "\u{F0142}" // md-chevron_right
onActivated: root.stepMonth(1)
}
}
// ── Weekday header ──────────────────────────────────────────────────
Row {
Repeater {
model: ["S", "M", "T", "W", "T", "F", "S"]
delegate: Text {
required property string modelData
width: root.cellSize
height: 20
text: modelData
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
color: Theme.fgMuted
}
}
}
// ── Day grid ────────────────────────────────────────────────────────
Grid {
columns: 7
Repeater {
model: root.cells
delegate: Item {
id: cell
required property var modelData
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
width: root.cellSize
height: root.cellHeight
Rectangle {
anchors.centerIn: parent
width: root.cellHeight - 2
height: root.cellHeight - 2
radius: width / 2
border.width: 0
visible: cell.isToday
color: Theme.accent
}
Text {
anchors.centerIn: parent
text: cell.modelData.day
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
color: {
if (cell.isToday)
return Theme.bgDark;
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
}
}
}
}
}
// ── Weather ─────────────────────────────────────────────────────────
Rectangle {
width: root.cellSize * 7
height: 1
color: Theme.alpha(Theme.fg, 0.1)
visible: Weather.available
}
Row {
spacing: Theme.itemSpacing
visible: Weather.available
Text {
anchors.verticalCenter: parent.verticalCenter
text: Weather.icon
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeTitle
color: Theme.accentAlt
}
Column {
Text {
text: Math.round(Weather.temperature) + Weather.unitSuffix
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
}
Text {
text: Weather.description
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
color: Theme.fgDim
}
}
}
}
}
@@ -0,0 +1,40 @@
// The centre clock, kept in the same place and the same format GNOME had it:
// weekday, date, 12-hour time with seconds. Clicking it opens the calendar,
// exactly like GNOME's date menu.
import Quickshell
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
// Seconds are only ticked when they are actually shown — a per-second wake
// for a clock that displays minutes would be pure waste.
SystemClock {
id: clock
precision: Settings.showSeconds ? SystemClock.Seconds : SystemClock.Minutes
}
readonly property string format: {
const date = Settings.showWeekday ? "ddd MMM d " : "MMM d ";
const time = Settings.use24Hour ? (Settings.showSeconds ? "HH:mm:ss" : "HH:mm") : (Settings.showSeconds ? "h:mm:ss AP" : "h:mm AP");
return date + time;
}
onActivated: ShellState.toggleDateMenu("agenda")
Text {
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, root.format)
font.family: Theme.fontFamily
// Seconds tick once a second in the centre of the bar; without tabular
// figures the whole clock shifts sideways on every digit change.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
color: Theme.fg
}
}
@@ -0,0 +1,69 @@
// Now-playing readout. Click toggles play/pause, scroll skips tracks.
//
// "Nothing playing" means no MPRIS player is exposing a track at all — a
// paused player still shows, because otherwise there would be nothing left on
// the bar to press play on.
import QtQuick
import Quickshell.Services.Mpris
import qs.config
import qs.widgets
Pill {
id: root
// Prefer whatever is actually playing; fall back to the first player that
// has a track loaded so a paused session stays reachable. The model's
// insertion behavior is covered by the live-player verification.
readonly property MprisPlayer player: {
const players = Mpris.players ? Mpris.players.values : [];
return players.find(p => p.isPlaying) ?? players.find(p => p.trackTitle) ?? null;
}
readonly property string label: {
if (!root.player)
return "";
const title = root.player.trackTitle ?? "";
const artist = root.player.trackArtist ?? "";
return artist ? artist + " — " + title : title;
}
visible: root.player !== null
horizontalPadding: 8
onActivated: if (root.player?.canTogglePlaying)
root.player.togglePlaying()
// Scroll up = previous, down = next — the same direction as the workspace
// switcher, so the whole bar scrolls consistently.
onScrolled: delta => {
if (!root.player)
return;
if (delta > 0 && root.player.canGoPrevious)
root.player.previous();
else if (delta < 0 && root.player.canGoNext)
root.player.next();
}
Text {
anchors.verticalCenter: parent.verticalCenter
// Shows the action the click will perform, not the current state.
text: root.player?.isPlaying ? "\u{F04C}" : "\u{F04B}" // fa-pause / fa-play
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
color: Theme.accent
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.label
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
// Titles are unbounded; the bar is not. Elide rather than let one
// podcast episode push the tray off the edge.
elide: Text.ElideRight
width: Math.min(implicitWidth, 200)
}
}
@@ -0,0 +1,116 @@
// GNOME's system menu button: a small run of status glyphs — network, volume,
// bluetooth, caffeine — that opens the quick settings panel when clicked.
//
// This widget only *reports* state. The quick settings panel itself lives
// elsewhere; clicking here just raises `requestQuickSettings`.
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Networking
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
signal requestQuickSettings
// Set by the shell when an idle inhibitor is held. Lives outside this
// widget because the inhibitor itself is owned by the quick settings side.
property bool idleInhibited: false
onActivated: root.requestQuickSettings()
// ── Audio ───────────────────────────────────────────────────────────────
// Without a tracker, volume and muted silently read as zero/false.
PwObjectTracker {
objects: [Pipewire.defaultAudioSink]
}
// defaultAudioSink is transiently null while pipewire settles.
readonly property real volume: Pipewire.defaultAudioSink?.audio?.volume ?? 0
readonly property bool muted: Pipewire.defaultAudioSink?.audio?.muted ?? false
// ── Network ─────────────────────────────────────────────────────────────
readonly property var devices: Networking.devices ? Networking.devices.values : []
readonly property var wiredDevice: root.devices.find(d => d.type === DeviceType.Wired && d.connected) ?? null
readonly property var wifiDevice: root.devices.find(d => d.type === DeviceType.Wifi) ?? null
readonly property var wifiNetwork: {
const networks = root.wifiDevice?.networks ? root.wifiDevice.networks.values : [];
return networks.find(n => n.connected) ?? null;
}
// ── Bluetooth ───────────────────────────────────────────────────────────
readonly property bool bluetoothOn: Bluetooth.defaultAdapter?.enabled ?? false
readonly property bool bluetoothConnected: {
const devices = Bluetooth.devices ? Bluetooth.devices.values : [];
return devices.some(d => d.connected);
}
StatusGlyph {
glyph: {
if (root.wiredDevice)
return "\u{F0200}"; // md-ethernet
if (!root.wifiNetwork)
return "\u{F092D}"; // md-wifi_strength_off
// NetworkManager reports strength 0-100; some backends normalise to
// 0-1, so accept either rather than showing one bar forever.
const raw = root.wifiNetwork.signalStrength ?? 0;
const strength = raw <= 1 ? raw * 100 : raw;
if (strength >= 75)
return "\u{F0928}"; // md-wifi_strength_4
if (strength >= 50)
return "\u{F0925}"; // md-wifi_strength_3
if (strength >= 25)
return "\u{F0922}"; // md-wifi_strength_2
return "\u{F091F}"; // md-wifi_strength_1
}
color: {
if (root.wiredDevice)
return Theme.fg;
if (!root.wifiNetwork)
return Theme.fgMuted;
return Networking.connectivity === NetworkConnectivity.Full ? Theme.fg : Theme.warn;
}
}
StatusGlyph {
glyph: {
if (root.muted || root.volume <= 0)
return "\u{F075F}"; // md-volume_mute
if (root.volume < 0.34)
return "\u{F057F}"; // md-volume_low
if (root.volume < 0.67)
return "\u{F0580}"; // md-volume_medium
return "\u{F057E}"; // md-volume_high
}
color: root.muted ? Theme.fgMuted : Theme.fg
}
StatusGlyph {
// Bluetooth off is the resting state on this machine, so the glyph is
// simply absent rather than shown crossed out.
visible: root.bluetoothOn
glyph: root.bluetoothConnected ? "\u{F00B1}" : "\u{F00AF}" // md-bluetooth_connect / md-bluetooth
color: root.bluetoothConnected ? Theme.accent : Theme.fg
}
StatusGlyph {
visible: root.idleInhibited
glyph: "\u{F0176}" // md-coffee
color: Theme.warn
}
StatusGlyph {
visible: KdeConnect.phoneReachable
glyph: "\u{F03F2}" // md-cellphone-link
color: Theme.cyan
}
}
@@ -0,0 +1,26 @@
// A single status icon in StatusCluster. Fixed width so the cluster doesn't
// shuffle sideways every time a glyph swaps for a wider one.
import QtQuick
import qs.config
Text {
id: root
required property string glyph
anchors.verticalCenter: parent?.verticalCenter ?? undefined
text: root.glyph
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeLarge
horizontalAlignment: Text.AlignHCenter
width: 18
color: Theme.fg
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
}
@@ -0,0 +1,84 @@
// System tray (StatusNotifierItem / AppIndicator).
//
// GNOME removed this and never replaced it; Nextcloud, RustDesk and Slack all
// still expect it, so it is a first-class part of this bar. Left click
// activates, right click opens the item's own menu — rendered in QML by
// TrayMenu so it matches the rest of the shell instead of dropping a GTK menu
// on top of it.
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import QtQuick
import qs.config
Row {
id: root
spacing: 2
Repeater {
model: SystemTray.items
delegate: Item {
id: entry
required property SystemTrayItem modelData
implicitWidth: 26
implicitHeight: Theme.barHeight - 8
Rectangle {
anchors.fill: parent
radius: Theme.pillRadius
border.width: 0
color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
}
IconImage {
anchors.centerIn: parent
implicitSize: 16
asynchronous: true
mipmap: true
// `icon` is already a resolved URL — running it through
// Quickshell.iconPath() would break it.
source: entry.modelData.icon
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: event => {
// onlyMenu items have no activate action at all, so a left
// click has to fall through to the menu or nothing happens.
if (event.button === Qt.RightButton || entry.modelData.onlyMenu) {
if (entry.modelData.hasMenu)
menu.visible = !menu.visible;
} else if (event.button === Qt.MiddleButton) {
entry.modelData.secondaryActivate();
} else {
entry.modelData.activate();
}
}
onWheel: event => entry.modelData.scroll(event.angleDelta.y, false)
}
TrayMenu {
id: menu
anchorItem: entry
trayItem: entry.modelData
}
}
}
}
@@ -0,0 +1,101 @@
// A tray item's DBus menu, drawn natively so it inherits the shell's theme.
//
// Submenus drill down in place rather than flying out sideways — a popover with
// a back row, the way GNOME's menus behave, and far less fiddly than chasing a
// cascade of popup windows with the mouse.
import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
import qs.config
import qs.widgets
Popover {
id: root
required property SystemTrayItem trayItem
// Popover's container is a plain Item and does not size itself from its
// children, so the window dimensions come from the column's implicit size.
// Rows take their *actual* width from the window in the other direction —
// the two chains are independent, so there is no binding loop.
implicitWidth: Math.max(body.implicitWidth + contentPadding * 2, 200)
implicitHeight: body.implicitHeight + contentPadding * 2
// Stack of submenu handles; empty means we are at the top level.
property var menuStack: []
onVisibleChanged: if (!visible)
root.menuStack = []
QsMenuOpener {
id: opener
menu: root.menuStack.length > 0 ? root.menuStack[root.menuStack.length - 1] : root.trayItem.menu
}
Column {
id: body
width: parent.width
spacing: 2
// Back row, only while inside a submenu.
TrayMenuRow {
width: parent.width
visible: root.menuStack.length > 0
label: "\u{F0141} Back" // md-chevron_left
onActivated: root.menuStack = root.menuStack.slice(0, -1)
}
Repeater {
model: opener.children
delegate: Loader {
id: entryLoader
required property var modelData
width: body.width
sourceComponent: entryLoader.modelData.isSeparator ? separator : row
Component {
id: separator
Item {
implicitHeight: 7
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.1)
}
}
}
Component {
id: row
TrayMenuRow {
// QsMenuEntry calls it `text`, not `label`.
label: entryLoader.modelData.text
icon: entryLoader.modelData.icon
rowEnabled: entryLoader.modelData.enabled
buttonType: entryLoader.modelData.buttonType
checkState: entryLoader.modelData.checkState
submenu: entryLoader.modelData.hasChildren
onActivated: {
if (entryLoader.modelData.hasChildren) {
root.menuStack = root.menuStack.concat([entryLoader.modelData]);
return;
}
entryLoader.modelData.triggered();
root.visible = false;
}
}
}
}
}
}
}
@@ -0,0 +1,85 @@
// One row of a TrayMenu: optional icon, label, and either a check/radio mark
// or a submenu chevron on the right.
import Quickshell.Widgets
import QtQuick
import qs.config
Rectangle {
id: root
signal activated
property string label: ""
property string icon: ""
// Named `rowEnabled` rather than `enabled`: overriding Item.enabled would
// also disable the MouseArea, and we still want the row to swallow clicks
// rather than let them fall through to whatever is behind the popover.
property bool rowEnabled: true
property int buttonType: 0 // QsMenuButtonType: 0 None, 1 CheckBox, 2 RadioButton
property int checkState: Qt.Unchecked
property bool submenu: false
implicitWidth: rowContent.implicitWidth + 20
implicitHeight: 30
radius: Theme.cardRadius
border.width: 0
color: mouse.containsMouse && root.rowEnabled ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Row {
id: rowContent
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 8
IconImage {
anchors.verticalCenter: parent.verticalCenter
implicitSize: 16
asynchronous: true
visible: root.icon !== ""
source: root.icon
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.label
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: root.rowEnabled ? Theme.fg : Theme.fgMuted
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
color: Theme.accent
text: {
if (root.submenu)
return "\u{F0142}"; // md-chevron_right
if (root.buttonType !== 0 && root.checkState === Qt.Checked)
return "\u{F012C}"; // md-check
return "";
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: root.rowEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.rowEnabled)
root.activated()
}
}
@@ -0,0 +1,53 @@
// One glyph + percentage pair inside VitalsWidget. Split out so the three
// fields cannot drift apart in spacing, width or colour thresholds.
import QtQuick
import qs.config
Row {
id: root
required property string glyph
required property real value
property int warnAt: 70
property int dangerAt: 90
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.glyph
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
color: Theme.fgDim
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Math.round(root.value) + "%"
// Tabular figures and a fixed width so the row doesn't jitter as digits
// change — a moving bar is far more distracting than a wide one. Sans,
// not mono: this is UI text, not terminal output.
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
width: 30
color: {
if (root.value >= root.dangerAt)
return Theme.danger;
if (root.value >= root.warnAt)
return Theme.warn;
return Theme.fg;
}
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
}
}
@@ -0,0 +1,43 @@
// CPU / RAM / GPU readout, in that order — the exact set and order the GNOME
// Vitals extension was configured with (_processor_usage_, _memory_usage_,
// _gpu#1_usage_).
//
// Values are colour-coded rather than graphed: at bar size a number you can
// glance at beats a sparkline you have to squint at.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
interactive: false
Row {
spacing: Theme.itemSpacing
VitalsField {
glyph: "\u{F4BC}" // oct-cpu
value: Vitals.cpu
visible: Settings.showCpu
}
VitalsField {
glyph: "\u{F035B}" // md-memory
value: Vitals.memory
visible: Settings.showMemory
// Memory sitting high is normal (page cache, browsers); only start
// complaining much later than for CPU.
warnAt: 80
dangerAt: 92
}
VitalsField {
glyph: "\u{F08AE}" // md-expansion_card
value: Vitals.gpu
visible: Settings.showGpu && Vitals.gpuAvailable
}
}
}
@@ -0,0 +1,31 @@
// Temperature and condition glyph, sitting next to the clock the way GNOME's
// weather sat inside the date menu button. Hidden entirely until a fetch has
// succeeded — an empty slot reads better than a placeholder.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
interactive: false
visible: Weather.available
Text {
anchors.verticalCenter: parent.verticalCenter
text: Weather.icon
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeLarge
color: Theme.accentAlt
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Math.round(Weather.temperature) + Weather.unitSuffix
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
}
}
@@ -0,0 +1,161 @@
// Workspace indicator, in the shape GNOME's workspace switcher had: a row of
// dots, the current one drawn as a wide accent pill. Hyprland workspaces are
// dynamic here, so the row grows and shrinks as workspaces come and go.
//
// Scrolling anywhere over the row switches workspace — a habit carried over
// from the GNOME top panel and explicitly worth keeping.
import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.config
Item {
id: root
// The QsScreen this bar instance lives on. When set, only that monitor's
// workspaces are shown — otherwise a second monitor would duplicate them.
property var screen: null
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
// Special workspaces (negative ids) are scratchpads, not places you switch
// to by index — GNOME had no equivalent, so they stay out of the row.
readonly property var workspaces: {
const all = Hyprland.workspaces ? Hyprland.workspaces.values : [];
return all.filter(ws => ws && ws.id > 0 && (!root.monitor || !ws.monitor || ws.monitor === root.monitor)).sort((a, b) => a.id - b.id);
}
// The delegate currently focused. The accent pill tracks its geometry, which
// is what makes the pill appear to slide from one workspace to the next.
property Item focusedItem: null
readonly property int dotSize: 8
readonly property int slotWidth: 16
readonly property int focusedSlotWidth: 28
implicitWidth: row.implicitWidth + 12
// Full bar height makes (0, 0) part of the wheel target. The dots stay
// optically centred by the row and active-pill geometry below.
implicitHeight: Theme.barHeight
// Scroll over the padding either side of the row, not just over a dot.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: event => root.cycle(event.angleDelta.y)
}
Rectangle {
id: activePill
// Behind the dots: the focused delegate hides its own dot, so nothing
// is ever drawn on top of the pill.
z: -1
visible: root.focusedItem !== null
height: root.dotSize
radius: Theme.pillRadius
border.width: 0 // QTBUG-137166: rounded rects can lose their corners
// The prism, as a fill rather than a hairline. This is the one element
// in the bar that is always coloured, so it is where the pair belongs.
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0.0
color: Theme.accent
}
GradientStop {
position: 1.0
color: Theme.accentSecondary
}
}
y: (root.height - height) / 2
x: row.x + (root.focusedItem ? root.focusedItem.x : 0)
width: root.focusedItem ? root.focusedItem.width : 0
Behavior on x {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
Behavior on width {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
}
Row {
id: row
anchors.centerIn: parent
spacing: 4
Repeater {
model: root.workspaces
delegate: Item {
id: slot
required property var modelData
readonly property bool isFocused: slot.modelData.focused
readonly property bool isOccupied: slot.modelData.toplevels ? slot.modelData.toplevels.values.length > 0 : false
implicitWidth: slot.isFocused ? root.focusedSlotWidth : root.slotWidth
implicitHeight: root.height
Behavior on implicitWidth {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
onIsFocusedChanged: if (slot.isFocused)
root.focusedItem = slot
Component.onCompleted: if (slot.isFocused)
root.focusedItem = slot
Rectangle {
anchors.centerIn: parent
width: root.dotSize
height: root.dotSize
radius: width / 2
border.width: 0
visible: !slot.isFocused
color: {
if (slot.modelData.urgent)
return Theme.urgent;
return slot.isOccupied ? Theme.fg : Theme.alpha(Theme.fg, 0.3);
}
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: slot.modelData.activate()
onWheel: event => root.cycle(event.angleDelta.y)
}
}
}
}
// Scroll up goes to the previous workspace, down to the next — the same
// direction GNOME used. Relative focus rather than by index so it keeps
// working with dynamic workspaces.
function cycle(angleDeltaY: int): void {
Hyprland.dispatch(angleDeltaY > 0 ? 'hl.dsp.focus({ workspace = "-1" })' : 'hl.dsp.focus({ workspace = "+1" })');
}
}
@@ -0,0 +1,225 @@
// The floating control panel at the bottom of the capture overlay.
//
// Layout is a deliberate copy of GNOME 42's screenshot UI: the area segmented
// control on top, and below it the shutter dead-centre with "Show Pointer" on
// the left and the screenshot/screencast switch on the right.
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
signal captureRequested
// Set by the overlay so the shutter can grey out when Window mode has
// nothing pickable (no Hyprland, or an empty workspace).
property bool captureEnabled: true
implicitWidth: 760
implicitHeight: col.implicitHeight + 2 * 20
radius: 28
border.width: 0
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
Column {
id: col
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 20
}
spacing: 18
// ── Area mode ───────────────────────────────────────────────────────
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: modes.implicitWidth + 8
implicitHeight: modes.implicitHeight + 8
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(Theme.fg, 0.07)
Row {
id: modes
anchors.centerIn: parent
spacing: 2
ModeButton {
glyph: "󰍹"
label: "Screen"
active: Capture.mode === "screen"
onClicked: Capture.mode = "screen"
}
ModeButton {
glyph: "󰖯"
label: "Window"
active: Capture.mode === "window"
onClicked: Capture.mode = "window"
}
ModeButton {
glyph: "󰩭"
label: "Selection"
active: Capture.mode === "selection"
onClicked: Capture.mode = "selection"
}
}
}
// ── Pointer toggle · shutter · capture type ─────────────────────────
Item {
width: col.width
height: 68
// "Show Pointer". wf-recorder has no cursor switch in this build,
// so it is disabled while the screencast type is selected instead
// of silently lying about what it does.
Rectangle {
id: pointerToggle
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
}
implicitWidth: pointerRow.implicitWidth + 26
implicitHeight: 40
radius: Theme.pillRadius
border.width: 0
opacity: Capture.recordMode || Capture.intelligenceMode ? 0.4 : 1.0
color: pointerMouse.containsMouse && !Capture.recordMode && !Capture.intelligenceMode ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.07)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Row {
id: pointerRow
anchors.centerIn: parent
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: Capture.showPointer ? "󰄬" : "󰄱"
color: Capture.showPointer ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeLarge
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Show Pointer"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
MouseArea {
id: pointerMouse
anchors.fill: parent
hoverEnabled: true
enabled: !Capture.recordMode && !Capture.intelligenceMode
cursorShape: Qt.PointingHandCursor
onClicked: Capture.showPointer = !Capture.showPointer
}
}
// ── Shutter ─────────────────────────────────────────────────────
Rectangle {
id: shutter
anchors.centerIn: parent
implicitWidth: 64
implicitHeight: 64
radius: width / 2
border.width: 0
opacity: root.captureEnabled ? 1.0 : 0.45
color: shutterMouse.containsMouse && root.captureEnabled ? Theme.alpha(Theme.fg, 0.3) : Theme.alpha(Theme.fg, 0.18)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Rectangle {
anchors.centerIn: parent
width: shutterMouse.pressed ? 40 : 46
height: width
radius: width / 2
border.width: 0
color: Capture.recordMode ? Theme.danger : (Capture.intelligenceMode ? Theme.accent : Theme.fg)
Behavior on width {
NumberAnimation {
duration: Theme.durFast
}
}
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
anchors.centerIn: parent
visible: Capture.intelligenceMode
text: "󰗊"
color: Theme.bgDark
font.family: Theme.fontMono
font.pixelSize: 21
}
}
MouseArea {
id: shutterMouse
anchors.fill: parent
hoverEnabled: true
enabled: root.captureEnabled
cursorShape: Qt.PointingHandCursor
onClicked: root.captureRequested()
}
}
// ── Screenshot / screencast ─────────────────────────────────────
Rectangle {
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
implicitWidth: types.implicitWidth + 8
implicitHeight: types.implicitHeight + 8
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(Theme.fg, 0.07)
Row {
id: types
anchors.centerIn: parent
spacing: 2
ModeButton {
glyph: "󰄀"
label: "Shot"
active: !Capture.recordMode && !Capture.intelligenceMode
onClicked: Capture.selectScreenshot()
}
ModeButton {
glyph: "󰕧"
label: "Record"
active: Capture.recordMode
onClicked: Capture.selectRecording()
}
ModeButton {
glyph: "󰗊"
label: "Read"
active: Capture.intelligenceMode
onClicked: Capture.selectIntelligence()
}
}
}
}
}
}
@@ -0,0 +1,178 @@
// The GNOME 42 screenshot UI.
//
// Print freezes the screen, this overlay paints that frozen frame full-bleed
// and puts the picker on top of it. Because the frame is a still, nothing under
// the overlay animates while the user aims, and the overlay can never end up in
// its own screenshot.
//
// The grab happens in services/Capture.qml *before* ShellState flips to
// "capture", so by the time this window maps the backdrop is already on disk.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
PanelWindow {
id: win
visible: ShellState.captureOpen
color: "transparent"
anchors {
top: true
bottom: true
left: true
right: true
}
// Fullscreen overlays must not reserve space, or every window on the
// workspace gets resized as the picker opens and closes.
exclusiveZone: 0
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
onVisibleChanged: {
if (!win.visible)
return;
// Hyprland reports window geometry in layout-global coordinates; the
// picker works in window-local ones. On a single monitor this is 0,0.
Capture.originX = win.screen ? win.screen.x : 0;
Capture.originY = win.screen ? win.screen.y : 0;
keys.forceActiveFocus();
}
// Fire whatever the current mode has selected.
function capture(): void {
if (Capture.mode === "screen") {
Capture.commit(null);
return;
}
if (Capture.mode === "window") {
const r = picker.selectedRect;
if (r)
Capture.commit(r);
return;
}
if (!selection.hasSelection)
return;
const s = selection.selection;
Capture.commit(Qt.rect(s.x + Capture.originX, s.y + Capture.originY, s.width, s.height));
}
function cycleMode(step: int): void {
const order = ["screen", "window", "selection"];
const i = order.indexOf(Capture.mode);
Capture.mode = order[(i + step + order.length) % order.length];
}
FocusScope {
id: keys
anchors.fill: parent
focus: true
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Escape:
Capture.close();
break;
case Qt.Key_Return:
case Qt.Key_Enter:
case Qt.Key_Space:
win.capture();
break;
case Qt.Key_Left:
win.cycleMode(-1);
break;
case Qt.Key_Right:
win.cycleMode(1);
break;
case Qt.Key_1:
Capture.mode = "screen";
break;
case Qt.Key_2:
Capture.mode = "window";
break;
case Qt.Key_3:
Capture.mode = "selection";
break;
case Qt.Key_P:
Capture.showPointer = !Capture.showPointer;
break;
default:
return;
}
event.accepted = true;
}
// Painted under the frozen frame so a failed grab (no wlr-screencopy)
// still gives the picker something opaque to sit on.
Rectangle {
anchors.fill: parent
color: Theme.bgDark
border.width: 0
}
// The frozen screen. grim -o pins the grab to this output, so filling
// the window is an exact 1:1 mapping; the source is 4500x3000 device
// pixels drawn into 3000x2000 logical ones, i.e. full sharpness.
Image {
anchors.fill: parent
source: Capture.freezeUrl
fillMode: Image.Stretch
// Unique filename per grab plus cache:false — otherwise QML would
// happily repaint a previous session's frame.
cache: false
// Synchronous so the overlay never flashes black on the way in; a
// raw PPM decode is a parse, not a decompress.
asynchronous: false
visible: status === Image.Ready
}
// ── Screen mode ─────────────────────────────────────────────────────
// Nothing to pick, so just outline the output the way GNOME does.
Frame {
anchors.fill: parent
visible: Capture.mode === "screen"
strokeWidth: 3
strokeColor: Theme.accent
}
// ── Window mode ─────────────────────────────────────────────────────
WindowPicker {
id: picker
anchors.fill: parent
visible: Capture.mode === "window"
originX: Capture.originX
originY: Capture.originY
onPicked: win.capture()
}
// ── Selection mode ──────────────────────────────────────────────────
SelectionLayer {
id: selection
anchors.fill: parent
visible: Capture.mode === "selection"
onCommitted: win.capture()
}
CaptureBar {
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: 56
}
captureEnabled: {
if (Capture.mode === "window")
return picker.selectedRect !== null;
if (Capture.mode === "selection")
return selection.hasSelection;
return true;
}
onCaptureRequested: win.capture()
}
}
}
@@ -0,0 +1,49 @@
// An outline drawn as four opaque strips rather than a Rectangle border.
//
// A rounded Rectangle with a translucent fill and a border renders holes at the
// corners on this Qt build (QTBUG-137166), and every outline in the capture
// overlay sits on top of a photo where that would be very visible. Four plain
// strips have no corners to get wrong.
import QtQuick
import qs.config
Item {
id: root
property color strokeColor: Theme.accent
property int strokeWidth: 2
// Optional wash over the enclosed area, drawn under the strips.
property color fillColor: "transparent"
Rectangle {
anchors.fill: parent
color: root.fillColor
border.width: 0
}
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top }
height: root.strokeWidth
color: root.strokeColor
border.width: 0
}
Rectangle {
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
height: root.strokeWidth
color: root.strokeColor
border.width: 0
}
Rectangle {
anchors { top: parent.top; bottom: parent.bottom; left: parent.left }
width: root.strokeWidth
color: root.strokeColor
border.width: 0
}
Rectangle {
anchors { top: parent.top; bottom: parent.bottom; right: parent.right }
width: root.strokeWidth
color: root.strokeColor
border.width: 0
}
}
@@ -0,0 +1,408 @@
// Focused result sheet for local OCR and code recognition. It is intentionally
// a transient overlay: the user selects pixels, acts on the result, and leaves
// without creating another application window or workspace tile.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
PanelWindow {
id: win
visible: ScreenIntelligence.visible
color: "transparent"
exclusiveZone: 0
anchors {
top: true
bottom: true
left: true
right: true
}
WlrLayershell.namespace: "qs-screen-intelligence"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
onVisibleChanged: {
if (visible)
keyboardScope.forceActiveFocus();
else
copied = false;
}
property bool copied: false
Timer {
id: copiedTimer
interval: 1500
onTriggered: win.copied = false
}
FocusScope {
id: keyboardScope
anchors.fill: parent
focus: true
Keys.onEscapePressed: event => {
ScreenIntelligence.close();
event.accepted = true;
}
Rectangle {
anchors.fill: parent
color: Theme.alpha(Theme.bgDark, 0.46)
border.width: 0
MouseArea {
anchors.fill: parent
onClicked: ScreenIntelligence.close()
}
}
Rectangle {
id: sheet
anchors.centerIn: parent
width: Math.min(parent.width - 80, 840)
height: Math.min(parent.height - 120, ScreenIntelligence.phase === "ready" ? 680 : 390)
radius: 24
color: Theme.alpha(Theme.bgPopover, 0.965)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
clip: true
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: sheet.radius
}
MouseArea {
anchors.fill: parent
onClicked: mouse => mouse.accepted = true
}
Column {
id: heading
anchors.left: parent.left
anchors.right: closeButton.left
anchors.top: parent.top
anchors.leftMargin: 28
anchors.topMargin: 25
anchors.rightMargin: 18
spacing: 4
Text {
text: "Screen Intelligence"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
text: {
if (ScreenIntelligence.phase === "capturing")
return "Capturing the selected pixels";
if (ScreenIntelligence.phase === "reading")
return "Reading locally on this computer";
if (ScreenIntelligence.phase === "error")
return "Recognition needs attention";
return "Local text and code recognition";
}
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Rectangle {
id: closeButton
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 20
anchors.rightMargin: 20
width: 34
height: 34
radius: 11
color: closeMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Text {
anchors.centerIn: parent
text: "×"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 20
}
MouseArea {
id: closeMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: ScreenIntelligence.close()
}
}
Item {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: heading.bottom
anchors.bottom: actions.top
anchors.leftMargin: 28
anchors.rightMargin: 28
anchors.topMargin: 24
anchors.bottomMargin: 18
Column {
anchors.centerIn: parent
width: Math.min(parent.width, 520)
spacing: 13
visible: ScreenIntelligence.busy
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "󰗊"
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 40
}
Text {
width: parent.width
text: ScreenIntelligence.phase === "capturing" ? "Capturing…" : "Reading text and codes…"
color: Theme.fg
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Pixels stay on this computer. No network request is being made."
color: Theme.fgDim
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Column {
anchors.centerIn: parent
width: Math.min(parent.width, 560)
spacing: 14
visible: ScreenIntelligence.phase === "error"
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "󰅚"
color: Theme.warn
font.family: Theme.fontMono
font.pixelSize: 38
}
Text {
width: parent.width
text: ScreenIntelligence.error
color: Theme.fg
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: commandText.implicitWidth + 28
height: 38
radius: 10
visible: !ScreenIntelligence.ocrReady
color: Theme.alpha(Theme.fg, 0.07)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
Text {
id: commandText
anchors.centerIn: parent
text: "sudo dnf install -y tesseract zbar"
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
}
}
}
Column {
anchors.fill: parent
spacing: 12
visible: ScreenIntelligence.phase === "ready"
Rectangle {
width: parent.width
height: ScreenIntelligence.codeValue === "" ? 0 : 62
visible: ScreenIntelligence.codeValue !== ""
radius: 13
color: Theme.alpha(Theme.accent, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.2)
Text {
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: "󰐲"
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 22
}
Column {
anchors.left: parent.left
anchors.leftMargin: 52
anchors.right: codeAction.left
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text { text: ScreenIntelligence.codeType || "Detected code"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSizeSmall; font.weight: Font.DemiBold }
Text { width: parent.width; text: ScreenIntelligence.codeValue; color: Theme.fgDim; elide: Text.ElideMiddle; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
}
ResultAction {
id: codeAction
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
label: ScreenIntelligence.primaryUrl !== "" ? "Open" : "Copy"
onClicked: {
if (ScreenIntelligence.primaryUrl !== "")
ScreenIntelligence.openDetected();
else
ScreenIntelligence.copyCode();
}
}
}
Rectangle {
width: parent.width
height: parent.height - (ScreenIntelligence.codeValue === "" ? 0 : 74)
radius: 14
color: Theme.alpha(Theme.bgDark, 0.72)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.075)
Text {
anchors.centerIn: parent
width: parent.width - 60
visible: ScreenIntelligence.text === "" && ScreenIntelligence.codeValue === ""
text: "No readable text or code was found. Try a tighter selection with larger, higher-contrast text."
color: Theme.fgDim
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
}
Flickable {
anchors.fill: parent
anchors.margins: 17
visible: ScreenIntelligence.text !== ""
clip: true
contentWidth: width
contentHeight: resultText.implicitHeight
boundsBehavior: Flickable.StopAtBounds
TextEdit {
id: resultText
width: parent.width
text: ScreenIntelligence.text
readOnly: true
selectByMouse: true
wrapMode: TextEdit.Wrap
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
}
}
}
}
}
Row {
id: actions
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: 28
anchors.bottomMargin: 24
spacing: 8
visible: ScreenIntelligence.phase === "ready" && (ScreenIntelligence.text !== "" || ScreenIntelligence.codeValue !== "")
ResultAction {
label: "Open link"
visible: ScreenIntelligence.primaryUrl !== "" && ScreenIntelligence.codeValue === ""
onClicked: ScreenIntelligence.openDetected()
}
ResultAction {
label: "Translate"
enabled: ScreenIntelligence.text !== ""
onClicked: ScreenIntelligence.translate()
}
ResultAction {
label: "Search"
enabled: ScreenIntelligence.text !== ""
onClicked: ScreenIntelligence.search()
}
ResultAction {
label: win.copied ? "Copied" : "Copy text"
primary: true
enabled: ScreenIntelligence.text !== ""
onClicked: {
ScreenIntelligence.copyText();
win.copied = true;
copiedTimer.restart();
}
}
}
}
}
component ResultAction: Rectangle {
id: action
property string label: ""
property bool primary: false
property bool enabled: true
signal clicked
implicitWidth: actionLabel.implicitWidth + 26
implicitHeight: 34
radius: 10
opacity: enabled ? 1 : 0.4
color: primary
? (actionMouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.1) : Theme.accent)
: (actionMouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075))
border.width: primary ? 0 : 1
border.color: Theme.alpha(Theme.fg, 0.08)
Text {
id: actionLabel
anchors.centerIn: parent
text: action.label
color: action.primary ? Theme.bgDark : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: actionMouse
anchors.fill: parent
enabled: action.enabled
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: action.clicked()
}
}
}
@@ -0,0 +1,72 @@
// One segment of the Screen / Window / Selection control.
//
// Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolour
// them, so they'd be invisible on this dark overlay.
import QtQuick
import qs.config
Rectangle {
id: root
property string glyph: ""
property string label: ""
property bool active: false
signal clicked
implicitWidth: content.implicitWidth + 30
implicitHeight: 40
radius: Theme.pillRadius
border.width: 0
color: {
if (root.active)
return mouse.containsMouse ? Qt.lighter(Theme.accent, 1.1) : Theme.accent;
if (mouse.pressed)
return Theme.alpha(Theme.fg, Theme.activeAlpha);
if (mouse.containsMouse)
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
return "transparent";
}
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
readonly property color contentColor: root.active ? Theme.bgDark : Theme.fg
Row {
id: content
anchors.centerIn: parent
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.glyph
color: root.contentColor
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeLarge
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.label
color: root.contentColor
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
@@ -0,0 +1,109 @@
// The "you are being recorded" pill.
//
// GNOME puts a red dot and a timer in the top bar while a screencast runs and
// clicking it stops the recording. The bar is another module's window, so this
// is its own tiny layer surface parked just below it.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
PanelWindow {
id: win
visible: Capture.recording
color: "transparent"
// Top only: with neither left nor right anchored, layer-shell centres the
// surface horizontally.
anchors.top: true
margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap
implicitWidth: pill.implicitWidth
implicitHeight: pill.implicitHeight
exclusiveZone: 0
WlrLayershell.namespace: "qs-popover-recording"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
function elapsed(): string {
const t = Capture.recordingSeconds;
const s = ("0" + (t % 60)).slice(-2);
const m = Math.floor(t / 60) % 60;
const h = Math.floor(t / 3600);
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s;
}
Rectangle {
id: pill
implicitWidth: row.implicitWidth + 12
implicitHeight: 34
radius: Theme.pillRadius
border.width: 0
color: Theme.redDeep
Row {
id: row
anchors.centerIn: parent
spacing: 8
// Static dot, not a blinking one: nothing in this shell repaints
// while idle.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 10
height: 10
radius: 5
border.width: 0
color: Theme.fg
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: win.elapsed()
color: Theme.fg
// Tabular figures: the timer must not shuffle as it counts.
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Rectangle {
id: stop
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 26
radius: width / 2
border.width: 0
color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
anchors.centerIn: parent
text: "󰓛"
color: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
}
MouseArea {
id: stopMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// SIGINT, so wf-recorder finalises the container.
onClicked: Capture.stopRecording()
}
}
}
}
}
@@ -0,0 +1,278 @@
// Selection mode: a GNOME-style rubber band over the frozen screen.
//
// Behaviours copied from GNOME 42: the overlay opens with a sensible centred
// selection already in place (so the shutter is immediately useful), dragging
// inside moves it, dragging a corner resizes from the opposite corner, and
// dragging anywhere else starts a fresh band.
//
// A single MouseArea owns all of it. Nested MouseAreas for the handles would
// fight the move/create area for the press, and the hit priorities are much
// easier to reason about in one place.
import QtQuick
import qs.config
Item {
id: root
// Anything smaller than this on release is treated as a stray click and the
// previous selection is restored.
readonly property real minSize: 12
// Pointer distance from a corner that counts as grabbing its handle.
readonly property real handleGrab: 24
readonly property real handleSize: 14
property real selX: 0
property real selY: 0
property real selW: 0
property real selH: 0
readonly property bool hasSelection: root.selW >= root.minSize && root.selH >= root.minSize
readonly property rect selection: Qt.rect(root.selX, root.selY, root.selW, root.selH)
// Double-clicking inside the band captures, like confirming a crop.
signal committed
// "none" | "new" | "move" | "nw" | "ne" | "sw" | "se"
property string _grab: "none"
property real _anchorX: 0
property real _anchorY: 0
property real _pressX: 0
property real _pressY: 0
property real _origX: 0
property real _origY: 0
property rect _prev: Qt.rect(0, 0, 0, 0)
onWidthChanged: root._resetIfEmpty()
onHeightChanged: root._resetIfEmpty()
Component.onCompleted: root._resetIfEmpty()
function _resetIfEmpty(): void {
if (root.selW === 0 && root.selH === 0)
root.resetSelection();
}
// Centred, roughly the middle half of the screen.
function resetSelection(): void {
if (root.width <= 0 || root.height <= 0)
return;
root.selW = Math.round(root.width * 0.55);
root.selH = Math.round(root.height * 0.55);
root.selX = Math.round((root.width - root.selW) / 2);
root.selY = Math.round((root.height - root.selH) / 2);
}
function _clampX(v: real): real {
return Math.max(0, Math.min(root.width, v));
}
function _clampY(v: real): real {
return Math.max(0, Math.min(root.height, v));
}
function _near(a: real, b: real): bool {
return Math.abs(a - b) <= root.handleGrab;
}
function _hitTest(px: real, py: real): string {
const x2 = root.selX + root.selW;
const y2 = root.selY + root.selH;
if (root.hasSelection) {
if (root._near(px, root.selX) && root._near(py, root.selY))
return "nw";
if (root._near(px, x2) && root._near(py, root.selY))
return "ne";
if (root._near(px, root.selX) && root._near(py, y2))
return "sw";
if (root._near(px, x2) && root._near(py, y2))
return "se";
if (px > root.selX && px < x2 && py > root.selY && py < y2)
return "move";
}
return "new";
}
function _cursorFor(m: string): int {
switch (m) {
case "nw":
case "se":
return Qt.SizeFDiagCursor;
case "ne":
case "sw":
return Qt.SizeBDiagCursor;
case "move":
return Qt.SizeAllCursor;
default:
return Qt.CrossCursor;
}
}
// Rebuild the band from a fixed anchor corner and the live pointer.
function _dragTo(px: real, py: real): void {
const cx = root._clampX(px);
const cy = root._clampY(py);
root.selX = Math.min(root._anchorX, cx);
root.selY = Math.min(root._anchorY, cy);
root.selW = Math.abs(cx - root._anchorX);
root.selH = Math.abs(cy - root._anchorY);
}
Spotlight {
anchors.fill: parent
holeVisible: root.hasSelection
hole: root.selection
}
Frame {
x: root.selX
y: root.selY
width: root.selW
height: root.selH
visible: root.hasSelection
strokeWidth: 2
strokeColor: Theme.accent
}
// ── Corner handles ──────────────────────────────────────────────────────
Repeater {
model: [
{
gx: 0,
gy: 0
},
{
gx: 1,
gy: 0
},
{
gx: 0,
gy: 1
},
{
gx: 1,
gy: 1
}
]
Rectangle {
required property var modelData
visible: root.hasSelection
width: root.handleSize
height: root.handleSize
radius: width / 2
border.width: 0
color: Theme.accent
x: root.selX + modelData.gx * root.selW - width / 2
y: root.selY + modelData.gy * root.selH - height / 2
// A dark core keeps the handle readable on top of a light photo.
Rectangle {
anchors.centerIn: parent
width: parent.width - 6
height: width
radius: width / 2
border.width: 0
color: Theme.bgDark
}
}
}
// ── Live size readout ───────────────────────────────────────────────────
Rectangle {
id: readout
visible: root.hasSelection
implicitWidth: readoutText.implicitWidth + 24
implicitHeight: 34
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
x: Math.max(0, Math.min(root.width - width, root.selX + (root.selW - width) / 2))
// Inside the band when there is room, otherwise tucked just above it.
y: root.selH > 110 ? root.selY + (root.selH - height) / 2 : Math.max(0, root.selY - height - 10)
Text {
id: readoutText
anchors.centerIn: parent
text: Math.round(root.selW) + " × " + Math.round(root.selH)
color: Theme.fg
// Tabular figures so the readout doesn't twitch while dragging.
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
cursorShape: root._cursorFor(root._grab !== "none" ? root._grab : root._hitTest(area.mouseX, area.mouseY))
onPressed: event => {
root._prev = root.selection;
root._grab = root._hitTest(event.x, event.y);
root._pressX = event.x;
root._pressY = event.y;
root._origX = root.selX;
root._origY = root.selY;
const x2 = root.selX + root.selW;
const y2 = root.selY + root.selH;
switch (root._grab) {
case "nw":
root._anchorX = x2;
root._anchorY = y2;
break;
case "ne":
root._anchorX = root.selX;
root._anchorY = y2;
break;
case "sw":
root._anchorX = x2;
root._anchorY = root.selY;
break;
case "se":
root._anchorX = root.selX;
root._anchorY = root.selY;
break;
case "new":
root._anchorX = root._clampX(event.x);
root._anchorY = root._clampY(event.y);
root.selX = root._anchorX;
root.selY = root._anchorY;
root.selW = 0;
root.selH = 0;
break;
}
}
onPositionChanged: event => {
if (root._grab === "none")
return;
if (root._grab === "move") {
root.selX = Math.max(0, Math.min(root.width - root.selW, root._origX + (event.x - root._pressX)));
root.selY = Math.max(0, Math.min(root.height - root.selH, root._origY + (event.y - root._pressY)));
} else {
root._dragTo(event.x, event.y);
}
}
onReleased: {
// A plain click outside the band would otherwise wipe the selection
// down to nothing; treat it as "no change" instead.
if (root._grab !== "move" && !root.hasSelection) {
root.selX = root._prev.x;
root.selY = root._prev.y;
root.selW = root._prev.width;
root.selH = root._prev.height;
}
root._grab = "none";
}
onDoubleClicked: event => {
if (root.hasSelection && event.x > root.selX && event.x < root.selX + root.selW && event.y > root.selY && event.y < root.selY + root.selH)
root.committed();
}
}
}
@@ -0,0 +1,72 @@
// Dims everything except one rectangle, GNOME-style.
//
// Four dimming rectangles laid around the hole, because QML has no way to
// subtract a shape from another without an OpacityMask shader — and a shader
// here would mean a full-screen 4500x3000 offscreen texture on every pointer
// move.
import QtQuick
import qs.config
Item {
id: root
// The region left undimmed, in this item's coordinates.
property rect hole: Qt.rect(0, 0, 0, 0)
// When false the whole item dims uniformly (nothing selected yet).
property bool holeVisible: false
property color dimColor: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
readonly property real _l: Math.max(0, Math.min(root.width, root.hole.x))
readonly property real _t: Math.max(0, Math.min(root.height, root.hole.y))
readonly property real _r: Math.max(root._l, Math.min(root.width, root.hole.x + root.hole.width))
readonly property real _b: Math.max(root._t, Math.min(root.height, root.hole.y + root.hole.height))
Rectangle {
anchors.fill: parent
visible: !root.holeVisible
color: root.dimColor
border.width: 0
}
// Above the hole
Rectangle {
visible: root.holeVisible
x: 0
y: 0
width: root.width
height: root._t
color: root.dimColor
border.width: 0
}
// Below
Rectangle {
visible: root.holeVisible
x: 0
y: root._b
width: root.width
height: Math.max(0, root.height - root._b)
color: root.dimColor
border.width: 0
}
// Left of the hole, between top and bottom bands
Rectangle {
visible: root.holeVisible
x: 0
y: root._t
width: root._l
height: Math.max(0, root._b - root._t)
color: root.dimColor
border.width: 0
}
// Right
Rectangle {
visible: root.holeVisible
x: root._r
y: root._t
width: Math.max(0, root.width - root._r)
height: Math.max(0, root._b - root._t)
color: root.dimColor
border.width: 0
}
}
@@ -0,0 +1,140 @@
// Window mode: outline every window on the focused workspace, highlight the one
// under the pointer and dim the rest, click to capture it.
//
// Geometry comes from `hyprctl -j clients` via services/Capture.qml, because
// Quickshell's HyprlandToplevel exposes no position or size.
import QtQuick
import qs.config
import qs.services
Item {
id: root
// Layout-global origin of the output this overlay covers.
property real originX: 0
property real originY: 0
signal picked
readonly property var windows: Capture.windows
// Front-to-back hit test: Capture.windows is already sorted by
// focusHistoryID, so the first hit is the topmost window.
readonly property int hoveredIndex: {
if (!area.containsMouse)
return -1;
const mx = area.mouseX;
const my = area.mouseY;
for (let i = 0; i < root.windows.length; i++) {
const w = root.windows[i];
const x = w.x - root.originX;
const y = w.y - root.originY;
if (mx >= x && mx <= x + w.w && my >= y && my <= y + w.h)
return i;
}
return -1;
}
// Once a window has been hovered it stays highlighted after the pointer
// moves down onto the control bar, so the shutter button always has
// something to shoot. Index 0 is the most recently focused window, which is
// what GNOME preselects.
property int stickyIndex: 0
onHoveredIndexChanged: {
if (root.hoveredIndex >= 0)
root.stickyIndex = root.hoveredIndex;
}
onWindowsChanged: root.stickyIndex = 0
readonly property int activeIndex: root.hoveredIndex >= 0 ? root.hoveredIndex : root.stickyIndex
readonly property var hovered: (root.activeIndex >= 0 && root.activeIndex < root.windows.length) ? root.windows[root.activeIndex] : null
// Layout-global rect of the hovered window, or null. The overlay hands this
// straight to Capture.commit().
readonly property var selectedRect: root.hovered ? Qt.rect(root.hovered.x, root.hovered.y, root.hovered.w, root.hovered.h) : null
Spotlight {
anchors.fill: parent
holeVisible: root.hovered !== null
hole: root.hovered ? Qt.rect(root.hovered.x - root.originX, root.hovered.y - root.originY, root.hovered.w, root.hovered.h) : Qt.rect(0, 0, 0, 0)
}
// Faint outline on every candidate so the user can see what is pickable
// before moving the pointer, the way GNOME's window shots do.
Repeater {
model: root.windows
Frame {
required property var modelData
required property int index
x: modelData.x - root.originX
y: modelData.y - root.originY
width: modelData.w
height: modelData.h
visible: index !== root.activeIndex
strokeWidth: 1
strokeColor: Theme.alpha(Theme.fg, 0.35)
}
}
Frame {
visible: root.hovered !== null
x: root.hovered ? root.hovered.x - root.originX : 0
y: root.hovered ? root.hovered.y - root.originY : 0
width: root.hovered ? root.hovered.w : 0
height: root.hovered ? root.hovered.h : 0
strokeWidth: 3
strokeColor: Theme.accent
fillColor: Theme.alpha(Theme.accent, 0.08)
}
// Title chip on the highlighted window — stacked windows are otherwise very
// hard to tell apart once the rest of the screen is dimmed.
Rectangle {
id: chip
visible: root.hovered !== null
x: (root.hovered ? root.hovered.x - root.originX : 0) + 12
y: (root.hovered ? root.hovered.y - root.originY : 0) + 12
implicitWidth: Math.min(chipText.implicitWidth + 24, root.hovered ? root.hovered.w - 24 : 0)
implicitHeight: 32
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
Text {
id: chipText
anchors.centerIn: parent
width: chip.width - 24
text: root.hovered ? (root.hovered.title || root.hovered.appId) : ""
color: Theme.fg
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
// Shown when hyprctl gave us nothing — a blank dimmed screen with no
// explanation would just look broken.
Text {
anchors.centerIn: parent
visible: root.windows.length === 0
text: "No windows to capture on this workspace"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
cursorShape: root.hovered ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.hovered)
root.picked();
}
}
}
@@ -0,0 +1,171 @@
// One row of clipboard history: what was copied, where it came from, and how
// long ago. Clicking it puts the entry back on the clipboard.
//
// Rows are cards on an already-glass surface, so they get no prism edge — the
// hairline marks a pane of glass, not every item sitting on one.
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
required property var entry
signal activated
implicitHeight: Math.max(52, body.implicitHeight + 18)
radius: Theme.cardRadius
// border.width: 0 works around QTBUG-137166, where transparent rounded
// rectangles can render with holes at the corners.
border.width: 0
color: {
if (mouse.pressed)
return Theme.alpha(Theme.accent, Theme.activeAlpha);
if (mouse.containsMouse)
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
return "transparent";
}
// Fast in, relaxed out — the row should feel like it answers the pointer
// rather than fading after it.
Behavior on color {
ColorAnimation {
duration: mouse.containsMouse ? Theme.durFast : Theme.durNormal
easing.type: Easing.OutQuad
}
}
// Nerd Font glyph for the kind of thing that was copied. Text is the common
// case and gets the quietest icon, so the odd link or image stands out.
readonly property string kindGlyph: {
switch (root.entry.kind) {
case Clipboard.kindLink:
return "\u{F0C1}"; // nf-fa-link
case Clipboard.kindImage:
return "\u{F03E}"; // nf-fa-picture_o
case Clipboard.kindFile:
return "\u{F016}"; // nf-fa-file_o
default:
return "\u{F15C}"; // nf-fa-file_text_o
}
}
// Where it came from. Vicinae leaves `source` empty for clients it cannot
// attribute (anything pasted by a script, for one), in which case a link's
// host is more informative than nothing.
readonly property string origin: root.entry.source || root.entry.host || ""
Text {
id: glyph
anchors.left: parent.left
anchors.leftMargin: 12
anchors.top: parent.top
anchors.topMargin: 11
text: root.entry.encrypted ? "\u{F023}" : root.kindGlyph
// fontMono is the Nerd Font, used here only to draw an icon.
font.family: Theme.fontMono
font.pixelSize: 14
color: root.entry.pinned ? Theme.accent : Theme.fgMuted
}
Column {
id: body
anchors.left: glyph.right
anchors.leftMargin: 10
anchors.right: timeLabel.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.entry.encrypted ? "Concealed by Vicinae" : root.entry.preview
color: root.entry.encrypted ? Theme.fgMuted : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.italic: root.entry.encrypted
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
}
Row {
spacing: 6
visible: chip.visible || originLabel.visible
// Images and file drags have no text worth reading, so the type is
// spelled out rather than left to the glyph alone.
Rectangle {
id: chip
visible: !root.entry.textual
width: chipLabel.implicitWidth + 12
height: chipLabel.implicitHeight + 4
radius: 4
border.width: 0
color: Theme.alpha(Theme.fg, 0.10)
Text {
id: chipLabel
anchors.centerIn: parent
text: root.entry.typeLabel
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
}
Text {
id: originLabel
anchors.verticalCenter: chip.verticalCenter
visible: root.origin !== ""
text: root.origin
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
Row {
id: timeLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 11
spacing: 5
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.entry.pinned
text: "\u{F0403}" // nf-md-pin
font.family: Theme.fontMono
font.pixelSize: 11
color: Theme.accent
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Clipboard.relativeTime(root.entry.ts)
color: Theme.fgMuted
font.family: Theme.fontFamily
// The list is full of these stacked in a column; tabular figures
// keep them from stepping sideways row to row.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
}
@@ -0,0 +1,230 @@
// Clipboard history, backed by Vicinae's existing database. Opening the panel
// performs one read-only query; nothing polls or repaints while it is closed.
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import qs.config
import qs.services
import qs.widgets
import qs.modules.quicksettings
PanelWindow {
id: root
visible: ShellState.clipboardOpen
color: "transparent"
anchors {
top: true
right: true
}
margins {
top: Theme.barHeight + Theme.barGap * 2
right: Theme.barSideMargin
}
exclusiveZone: 0
implicitWidth: 430
implicitHeight: 550
WlrLayershell.namespace: "qs-popover-clipboard"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
property string filter: ""
readonly property var filteredEntries: Clipboard.entries.filter(entry => {
const needle = root.filter.trim().toLowerCase();
if (!needle)
return true;
return [entry.preview, entry.source, entry.host, entry.typeLabel]
.join(" ").toLowerCase().includes(needle);
})
function close(): void {
ShellState.close();
}
function activateFirst(): void {
if (root.filteredEntries.length === 0)
return;
Clipboard.copyEntry(root.filteredEntries[0].id);
root.close();
}
onVisibleChanged: {
if (root.visible) {
root.filter = "";
search.clear();
Clipboard.refresh();
openAnim.restart();
Qt.callLater(search.grab);
}
}
HyprlandFocusGrab {
windows: [root]
active: root.visible
onCleared: root.close()
}
Rectangle {
id: surface
anchors.fill: parent
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
left: parent.left
right: parent.right
topMargin: 1
}
inset: parent.radius
}
transform: Scale {
id: openScale
origin.x: surface.width
origin.y: 0
xScale: 1
yScale: 1
}
Column {
anchors.fill: parent
anchors.margins: Theme.popoverPadding
spacing: Theme.sectionSpacing
Item {
width: parent.width
height: 30
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 9
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 17
icon: "edit-paste-symbolic"
iconFallback: "edit-copy-symbolic"
tint: Theme.accent
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Clipboard"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: Clipboard.entries.length === 1
? "1 item"
: Clipboard.entries.length + " items"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
SearchField {
id: search
width: parent.width
placeholder: "Search clipboard history"
onTextChanged: root.filter = text
onAccepted: root.activateFirst()
onDismissed: root.close()
}
Rectangle {
width: parent.width
height: 1
border.width: 0
color: Theme.alpha(Theme.fg, 0.1)
}
Item {
width: parent.width
height: parent.height - y
Text {
anchors.centerIn: parent
visible: Clipboard.loading
text: "Loading clipboard history…"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.centerIn: parent
width: parent.width - 40
visible: !Clipboard.loading && !Clipboard.available
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
text: "Clipboard history is unavailable. Start Vicinae to restore it."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.centerIn: parent
visible: !Clipboard.loading && Clipboard.available
&& root.filteredEntries.length === 0
text: root.filter ? "No matching clipboard items" : "Nothing copied yet"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
ScrollColumn {
anchors.fill: parent
maxHeight: parent.height
spacing: 4
visible: !Clipboard.loading && Clipboard.available
&& root.filteredEntries.length > 0
Repeater {
model: root.filteredEntries
ClipboardEntry {
required property var modelData
width: parent.width
entry: modelData
onActivated: {
Clipboard.copyEntry(modelData.id);
root.close();
}
}
}
}
}
}
}
NumberAnimation {
id: openAnim
target: openScale
property: "yScale"
from: 0.96
to: 1
duration: Theme.durFast
easing.type: Easing.OutCubic
}
}
@@ -0,0 +1,24 @@
// The bar entry for clipboard history. The icon is intentionally familiar and
// quiet: the history itself is a utility, not another permanent status readout.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
implicitWidth: 30
implicitHeight: 28
onActivated: ShellState.toggle("clipboard")
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 16
icon: "edit-paste-symbolic"
iconFallback: "edit-copy-symbolic"
tint: ShellState.clipboardOpen ? Theme.accent : Theme.fg
}
}
@@ -0,0 +1,86 @@
// The filter box at the top of the clipboard popover. Nothing is submitted:
// the text is read live and narrows the list that is already loaded, which is
// how GNOME's own search entries behave.
import QtQuick
import qs.config
Rectangle {
id: root
property alias text: input.text
property string placeholder: "Search"
signal accepted
signal dismissed
implicitHeight: 32
radius: Theme.pillRadius
// border.width is set below rather than left at 0, so the focus ring has
// something to change. See QTBUG-137166 for why 0 matters elsewhere.
color: Theme.alpha(Theme.fg, 0.07)
border.width: 1
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
Behavior on border.color {
ColorAnimation {
duration: Theme.durFast
}
}
function clear(): void {
input.text = "";
}
function grab(): void {
input.forceActiveFocus();
}
Text {
id: glyph
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "\u{F002}" // nf-fa-search — fontMono is the Nerd Font, icon only
font.family: Theme.fontMono
font.pixelSize: 12
color: input.activeFocus ? Theme.accent : Theme.fgMuted
}
TextInput {
id: input
anchors.left: glyph.right
anchors.leftMargin: 9
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
onAccepted: root.accepted()
// Escape clears a non-empty filter first and only closes the popover on
// a second press — losing a typed query to a stray Escape is worse than
// one extra keystroke.
Keys.onEscapePressed: {
if (input.text !== "")
input.text = "";
else
root.dismissed();
}
Text {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
visible: input.text === ""
text: root.placeholder
color: Theme.fgMuted
font: input.font
}
}
}
@@ -0,0 +1,5 @@
module qs.modules.clipboard
ClipboardEntry 1.0 ClipboardEntry.qml
ClipboardPanel 1.0 ClipboardPanel.qml
ClipboardWidget 1.0 ClipboardWidget.qml
SearchField 1.0 SearchField.qml
@@ -0,0 +1,128 @@
import QtQuick
import qs.config
import qs.services
Item {
id: root
required property var eventData
property bool past: false
signal activated
signal joinRequested
readonly property var sourceData: CalendarAgenda.sourceForId(eventData.sourceId)
readonly property color eventColor: sourceData?.color ?? Theme.accent
readonly property string timeText: eventData.allDay
? "All day"
: Qt.formatTime(new Date(Number(eventData.start) * 1000), Settings.use24Hour ? "HH:mm" : "h:mm AP")
readonly property string detailText: {
const source = root.sourceData?.name ?? "Calendar";
const location = String(root.eventData.location ?? "").trim();
return location === "" ? source : source + " · " + location;
}
implicitHeight: 58
opacity: past ? 0.52 : 1
Rectangle {
anchors.fill: parent
radius: 9
border.width: 0
color: rowMouse.containsMouse ? Theme.alpha(Theme.fg, 0.07) : "transparent"
Behavior on color { ColorAnimation { duration: Theme.durFast } }
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
Text {
id: timeLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 56
text: root.timeText
color: Theme.fgDim
horizontalAlignment: Text.AlignRight
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
id: sourceRail
anchors.left: timeLabel.right
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 3
height: 34
radius: 2
border.width: 0
color: root.eventColor
}
Column {
anchors.left: sourceRail.right
anchors.leftMargin: 10
anchors.right: joinPill.visible ? joinPill.left : parent.right
anchors.rightMargin: joinPill.visible ? 10 : 4
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.eventData.summary || "Untitled event"
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.detailText
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
id: joinPill
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
visible: String(root.eventData.joinUrl ?? "").startsWith("https://")
width: joinLabel.implicitWidth + 18
height: 26
radius: Theme.pillRadius
border.width: 0
color: joinMouse.containsMouse ? Theme.alpha(Theme.accent, 0.28) : Theme.alpha(Theme.accent, 0.14)
Text {
id: joinLabel
anchors.centerIn: parent
text: "Join"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: joinMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.joinRequested()
}
}
}
@@ -0,0 +1,292 @@
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
id: root
readonly property bool isToday: CalendarAgenda.selectedDateKey === CalendarAgenda.dateKey(clock.date)
readonly property var upNext: CalendarAgenda.nextEventForSelectedDate
readonly property var listedEvents: CalendarAgenda.selectedEvents.filter(event => !root.upNext || event.id !== root.upNext.id)
readonly property string selectedLabel: root.isToday
? "Today"
: Qt.formatDate(CalendarAgenda.selectedDate, "dddd, MMMM d")
implicitHeight: content.implicitHeight
SystemClock {
id: clock
precision: SystemClock.Minutes
}
Column {
id: content
width: parent.width
spacing: 12
Column {
width: parent.width
spacing: 3
visible: CalendarAgenda.phase === "loading"
Text {
text: "CALENDAR"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 1.1
}
Text {
text: "Loading calendars…"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
}
Column {
width: parent.width
spacing: 10
visible: CalendarAgenda.phase === "unavailable"
Text {
text: "Calendar data is unavailable"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
wrapMode: Text.WordWrap
text: "Notifications, weather, and the month grid are still available. GNOME Calendar may have more detail."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
ActionPill {
text: "Open Calendar"
onClicked: CalendarAgenda.openCalendar(CalendarAgenda.selectedDate)
}
}
Rectangle {
id: nextCard
width: parent.width
height: visible ? 88 : 0
visible: CalendarAgenda.available && root.isToday && root.upNext !== null
radius: Theme.cardRadius
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.18)
color: Theme.alpha(Theme.accent, 0.105)
clip: true
Rectangle {
anchors.right: parent.right
anchors.rightMargin: -24
anchors.bottom: parent.bottom
anchors.bottomMargin: -34
width: 110
height: 110
radius: 55
border.width: 0
color: Theme.alpha(Theme.accentSecondary, 0.08)
}
Column {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.right: nextJoin.visible ? nextJoin.left : parent.right
anchors.rightMargin: nextJoin.visible ? 10 : 14
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Text {
text: "UP NEXT · " + root.relativeStart(root.upNext)
color: Theme.accent
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
font.letterSpacing: 0.8
}
Text {
width: parent.width
text: root.upNext?.summary ?? ""
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.eventDetail(root.upNext)
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
ActionPill {
id: nextJoin
anchors.right: parent.right
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 12
visible: String(root.upNext?.joinUrl ?? "").startsWith("https://")
text: "Join"
accent: true
onClicked: CalendarAgenda.join(root.upNext)
}
MouseArea {
anchors.fill: parent
z: -1
cursorShape: Qt.PointingHandCursor
onClicked: CalendarAgenda.openEvent(root.upNext)
}
}
Item {
width: parent.width
height: 34
visible: CalendarAgenda.available
Column {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: root.selectedLabel.toUpperCase()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.9
}
Text {
text: "Your schedule"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
}
Text {
anchors.right: parent.right
anchors.bottom: parent.bottom
text: CalendarAgenda.selectedEvents.length + (CalendarAgenda.selectedEvents.length === 1 ? " event" : " events")
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
Item {
width: parent.width
height: visible ? 92 : 0
visible: CalendarAgenda.available && CalendarAgenda.selectedEvents.length === 0
Column {
anchors.centerIn: parent
spacing: 8
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Nothing scheduled"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
ActionPill {
anchors.horizontalCenter: parent.horizontalCenter
text: "Open Calendar"
onClicked: CalendarAgenda.openCalendar(CalendarAgenda.selectedDate)
}
}
}
ScrollColumn {
width: parent.width
maxHeight: 286
spacing: 0
visible: CalendarAgenda.available && root.listedEvents.length > 0
Repeater {
model: root.listedEvents
AgendaEvent {
required property var modelData
width: parent.width
eventData: modelData
past: Number(modelData.end) <= CalendarAgenda.nowEpoch
onActivated: CalendarAgenda.openEvent(modelData)
onJoinRequested: CalendarAgenda.join(modelData)
}
}
}
}
function relativeStart(event: var): string {
if (!event)
return "";
const seconds = Number(event.start) - CalendarAgenda.nowEpoch;
if (seconds <= 0)
return "NOW";
if (seconds < 3600)
return "IN " + Math.ceil(seconds / 60) + " MINUTES";
return Qt.formatTime(new Date(Number(event.start) * 1000), Settings.use24Hour ? "HH:mm" : "h:mm AP");
}
function eventDetail(event: var): string {
if (!event)
return "";
const source = CalendarAgenda.sourceForId(event.sourceId)?.name ?? "Calendar";
const location = String(event.location ?? "").trim();
return location === "" ? source : source + " · " + location;
}
component ActionPill: Rectangle {
id: action
required property string text
property bool accent: false
signal clicked
width: actionLabel.implicitWidth + 20
height: 28
radius: Theme.pillRadius
border.width: 0
color: actionMouse.containsMouse
? Theme.alpha(action.accent ? Theme.accent : Theme.fg, 0.24)
: Theme.alpha(action.accent ? Theme.accent : Theme.fg, action.accent ? 0.14 : 0.07)
Text {
id: actionLabel
anchors.centerIn: parent
text: action.text
color: action.accent ? Theme.accent : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: actionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: action.clicked()
}
}
}
@@ -0,0 +1,281 @@
// The month grid from GNOME's date menu.
//
// Six week rows always, so the grid height never changes as you page through
// months and the panel below it never jumps. Days belonging to the neighbouring
// months fill the edges, dimmed. Today wears the accent.
//
// Days become real navigation targets now that Evolution Data Server backs the
// panel. Up to three source-coloured markers show whether a date has events.
import Quickshell
import QtQuick
import qs.config
import qs.services
Item {
id: root
signal dateActivated(date value)
signal visibleMonthChanged(int year, int month)
implicitHeight: body.implicitHeight
// Cells divide the available width evenly; the height is fixed so the grid
// stays roughly square regardless of how wide the panel gets.
readonly property real cellWidth: root.width / 7
readonly property int cellHeight: 32
// Hours precision is enough: the only thing that has to change on its own
// is which cell counts as "today", and that only moves at midnight.
SystemClock {
id: clock
precision: SystemClock.Hours
}
readonly property int todayYear: clock.date.getFullYear()
readonly property int todayMonth: clock.date.getMonth()
readonly property int todayDay: clock.date.getDate()
// The month currently on screen. DateMenu calls showToday() every time the
// panel opens, so it never comes back showing wherever you paged off to.
property int viewYear: root.todayYear
property int viewMonth: root.todayMonth
property date selectedDate: CalendarAgenda.selectedDate
readonly property bool onCurrentMonth: root.viewYear === root.todayYear && root.viewMonth === root.todayMonth
function showToday(): void {
root.viewYear = root.todayYear;
root.viewMonth = root.todayMonth;
}
function stepMonth(delta: int): void {
// Built through Date rather than by hand so December -> January rolls
// the year over for us.
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
root.viewYear = d.getFullYear();
root.viewMonth = d.getMonth();
}
function activateCell(model: var): void {
if (!model.current) {
root.viewYear = model.date.getFullYear();
root.viewMonth = model.date.getMonth();
}
root.dateActivated(model.date);
}
onViewYearChanged: root.visibleMonthChanged(root.viewYear, root.viewMonth)
onViewMonthChanged: root.visibleMonthChanged(root.viewYear, root.viewMonth)
readonly property var cells: {
const first = new Date(root.viewYear, root.viewMonth, 1);
const offset = first.getDay(); // 0 = Sunday, matching the header row
const out = [];
for (let i = 0; i < 42; i++) {
const date = new Date(root.viewYear, root.viewMonth, i - offset + 1);
out.push({
date: date,
day: date.getDate(),
current: date.getMonth() === root.viewMonth && date.getFullYear() === root.viewYear
});
}
return out;
}
Column {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 6
// ── Month header ────────────────────────────────────────────────────
// Both arrows sit on the right so the left slot is free for the "Today"
// affordance, which only exists while you are looking at another month.
Item {
width: parent.width
height: 30
Rectangle {
id: todayPill
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: todayLabel.implicitWidth + 18
height: 24
radius: Theme.pillRadius
border.width: 0
visible: !root.onCurrentMonth
color: todayMouse.containsMouse ? Theme.alpha(Theme.accent, 0.30) : Theme.alpha(Theme.accent, 0.16)
Behavior on color {
ColorAnimation {
duration: todayMouse.containsMouse ? Theme.durFast : Theme.durNormal
}
}
Text {
id: todayLabel
anchors.centerIn: parent
text: "Today"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: todayMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.showToday()
}
}
// Centred on the row rather than between the pill and the arrows,
// so it does not shift sideways when the pill appears.
Text {
anchors.centerIn: parent
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
font.family: Theme.fontFamily
// The year ticks in place when you page across a boundary.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
color: Theme.fg
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 2
MonthArrow {
glyph: "\u{F0141}" // md-chevron_left
onActivated: root.stepMonth(-1)
}
MonthArrow {
glyph: "\u{F0142}" // md-chevron_right
onActivated: root.stepMonth(1)
}
}
}
// ── Weekday header ──────────────────────────────────────────────────
Row {
width: parent.width
Repeater {
model: ["S", "M", "T", "W", "T", "F", "S"]
delegate: Text {
required property string modelData
width: root.cellWidth
height: 22
text: modelData
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.fgMuted
}
}
}
// ── Day grid ────────────────────────────────────────────────────────
Grid {
width: parent.width
columns: 7
Repeater {
model: root.cells
delegate: Item {
id: cell
required property var modelData
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.onCurrentMonth
readonly property bool isSelected: CalendarAgenda.dateKey(cell.modelData.date) === CalendarAgenda.dateKey(root.selectedDate)
readonly property var markers: CalendarAgenda.markersForDate(cell.modelData.date).slice(0, 3)
width: root.cellWidth
height: root.cellHeight
Rectangle {
anchors.centerIn: parent
width: root.cellHeight - 4
height: root.cellHeight - 4
radius: width / 2
border.width: 0
visible: cell.isToday || cell.isSelected || cellMouse.containsMouse
color: {
if (cell.isToday)
return Theme.accent;
if (cell.isSelected)
return Theme.alpha(Theme.accent, 0.20);
return Theme.alpha(Theme.fg, 0.08);
}
Behavior on color {
ColorAnimation { duration: Theme.durFast }
}
}
Text {
anchors.centerIn: parent
text: cell.modelData.day
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
color: {
if (cell.isToday)
return Theme.bgDark;
if (cell.isSelected)
return Theme.accent;
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
}
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
spacing: 2
Repeater {
model: cell.markers
Rectangle {
required property var modelData
width: 3
height: 3
radius: 2
border.width: 0
color: cell.isToday ? Theme.bgDark : modelData.color
}
}
}
MouseArea {
id: cellMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activateCell(cell.modelData)
}
}
}
}
}
Component.onCompleted: root.visibleMonthChanged(root.viewYear, root.viewMonth)
}
@@ -0,0 +1,122 @@
// GNOME's date/time menu: calendar, ongoing activity and messages in one panel,
// dropping from the top centre because that is where GNOME put it and the
// muscle memory is the whole point.
//
// A layer-shell surface rather than a PopupWindow because the inline reply
// fields have to be typeable, and because the clock it hangs from is owned by a
// different module.
//
// Layer-shell centres a surface on whichever axis it is not anchored to, so
// anchoring only `top` is what puts it in the middle.
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import qs.config
import qs.services
import qs.widgets
PanelWindow {
id: root
readonly property bool ongoingContentFits: panel.ongoingContentFits
// "notifications" remains the overlay key so existing keybinds keep their
// contract; the surface itself now has Agenda, Ongoing and Notifications.
visible: ShellState.notificationsOpen
color: "transparent"
anchors.top: true
margins.top: Theme.barHeight + Theme.barGap * 2
exclusiveZone: 0
implicitWidth: 760
implicitHeight: panel.implicitHeight
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
WlrLayershell.namespace: "qs-popover-datemenu"
WlrLayershell.layer: WlrLayer.Overlay
// OnDemand rather than Exclusive: the panel must be able to take typing for
// inline replies, but must not steal it while merely visible.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
onVisibleChanged: {
if (root.visible) {
CalendarAgenda.refresh();
if (ShellState.dateMenuPage === "notifications")
Notifs.markAllRead();
panel.prepare(ShellState.dateMenuPage);
openAnim.restart();
} else {
panel.reset();
}
}
Connections {
target: ShellState
function onDateMenuPageChanged(): void {
if (root.visible && ShellState.dateMenuPage === "notifications")
Notifs.markAllRead();
}
}
// Closes the panel when a click lands anywhere else on the desktop.
HyprlandFocusGrab {
windows: [root]
active: root.visible
onCleared: ShellState.close()
}
Rectangle {
id: surface
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
// border so the two don't fight for the same row of pixels.
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
// Grows out of the top edge it is anchored to, rather than out of its
// own centre — the panel should look like it came from the bar.
transform: Scale {
id: openScale
origin.x: surface.width / 2
origin.y: 0
xScale: 1
yScale: 1
}
// Escape reaches here by propagating up from whatever holds focus
// (usually a reply field), so it works in every sub-state.
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: ShellState.close()
DateMenuPanel {
id: panel
anchors.fill: parent
}
}
}
NumberAnimation {
id: openAnim
target: openScale
property: "yScale"
from: 0.94
to: 1.0
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
@@ -0,0 +1,319 @@
// The approved A · Daybook: month context on the left, the selected day's
// schedule, persistent activity or grouped notification history on the right.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.widgets
Item {
id: root
implicitWidth: 760
implicitHeight: frame.implicitHeight + Theme.popoverPadding * 2
function prepare(page: string): void {
calendar.showToday();
CalendarAgenda.selectDate(clock.date);
CalendarAgenda.setVisibleMonth(clock.date.getFullYear(), clock.date.getMonth());
if (page === "notifications")
Notifs.markAllRead();
}
function reset(): void {
notifications.reset();
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
Column {
id: frame
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.popoverPadding
spacing: 0
Item {
id: header
width: parent.width
height: 42
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.right: dateMenuTabs.left
anchors.rightMargin: Theme.itemSpacing
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Row {
id: dateMenuTabs
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
HeaderPill {
text: "Agenda"
active: ShellState.dateMenuPage === "agenda"
onClicked: ShellState.dateMenuPage = "agenda"
}
HeaderPill {
text: "Ongoing"
active: ShellState.dateMenuPage === "ongoing"
badge: Ongoing.count
onClicked: ShellState.dateMenuPage = "ongoing"
}
HeaderPill {
text: "Notifications"
active: ShellState.dateMenuPage === "notifications"
badge: Notifs.history.length
onClicked: ShellState.dateMenuPage = "notifications"
}
HeaderPill {
visible: ShellState.dateMenuPage === "agenda"
text: "Calendar ↗"
onClicked: CalendarAgenda.openCalendar(CalendarAgenda.selectedDate)
}
}
}
Rectangle {
width: parent.width
height: 1
border.width: 0
color: Theme.alpha(Theme.fg, 0.10)
}
Row {
id: body
width: parent.width
height: Math.min(620, Math.max(438, leftContent.implicitHeight + 28, rightContentHeight + 28))
Item {
id: leftColumn
width: 310
height: body.height
Column {
id: leftContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
spacing: 12
CalendarGrid {
id: calendar
width: parent.width
selectedDate: CalendarAgenda.selectedDate
onDateActivated: date => CalendarAgenda.selectDate(date)
onVisibleMonthChanged: (year, month) => CalendarAgenda.setVisibleMonth(year, month)
}
Rectangle {
width: parent.width
height: 1
border.width: 0
visible: media.visible || Weather.available
color: Theme.alpha(Theme.fg, 0.08)
}
MediaCard {
id: media
width: parent.width
}
WeatherCard {
width: parent.width
visible: Weather.available
}
}
}
Rectangle {
width: 1
height: body.height
border.width: 0
color: Theme.alpha(Theme.fg, 0.10)
}
Item {
id: rightColumn
width: body.width - leftColumn.width - 1
height: body.height
readonly property real availableWidth: width - 28
AgendaPane {
id: agenda
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
visible: ShellState.dateMenuPage === "agenda"
}
OngoingPane {
id: ongoingPane
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
maxHeight: body.height - 28
visible: ShellState.dateMenuPage === "ongoing"
}
Column {
id: notificationPane
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
spacing: 10
visible: ShellState.dateMenuPage === "notifications"
Item {
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.right: notificationActions.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Row {
id: notificationActions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
HeaderPill {
text: "Do Not Disturb"
active: Notifs.doNotDisturb
icon: "notifications-disabled-symbolic"
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
HeaderPill {
visible: Notifs.hasNotifications
text: "Clear all"
onClicked: Notifs.dismissAll()
}
}
}
NotificationList {
id: notifications
width: parent.width
maxHeight: body.height - 76
}
}
}
}
}
readonly property real rightContentHeight: {
if (ShellState.dateMenuPage === "notifications")
return notificationPane.implicitHeight;
if (ShellState.dateMenuPage === "ongoing")
return ongoingPane.implicitHeight;
return agenda.implicitHeight;
}
readonly property bool ongoingContentFits: ongoingPane.implicitHeight <= body.height - 28
component HeaderPill: Rectangle {
id: pill
required property string text
property string icon: ""
property bool active: false
property int badge: -1
signal clicked
width: pillRow.implicitWidth + 20
height: 28
radius: Theme.pillRadius
border.width: 0
color: {
if (pill.active)
return pillMouse.containsMouse ? Theme.alpha(Theme.accent, 0.38) : Theme.alpha(Theme.accent, 0.24);
return pillMouse.containsMouse ? Theme.alpha(Theme.fg, 0.15) : Theme.alpha(Theme.fg, 0.07);
}
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Row {
id: pillRow
anchors.centerIn: parent
spacing: 6
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
visible: pill.icon !== ""
size: visible ? 14 : 0
icon: pill.icon
tint: pill.active ? Theme.accent : Theme.fgDim
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: pill.text
color: pill.active ? Theme.accent : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: pill.badge > 0
width: visible ? badgeText.implicitWidth + 9 : 0
height: 17
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(pill.active ? Theme.accent : Theme.fg, 0.12)
Text {
id: badgeText
anchors.centerIn: parent
text: String(pill.badge)
color: pill.active ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: 9
font.weight: Font.DemiBold
}
}
}
MouseArea {
id: pillMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: pill.clicked()
}
}
}
@@ -0,0 +1,152 @@
// Compact media controls in the date menu: artwork, useful metadata, and the
// three controls people expect. It disappears completely without a track.
import QtQuick
import Quickshell.Services.Mpris
import qs.config
import qs.widgets
Rectangle {
id: root
readonly property bool compact: width < 360
readonly property MprisPlayer player: {
const players = Mpris.players ? Mpris.players.values : [];
return players.find(p => p.isPlaying) ?? players.find(p => p.trackTitle) ?? null;
}
visible: root.player !== null
implicitHeight: visible ? (compact ? 64 : 76) : 0
radius: Theme.cardRadius
border.width: 0
color: Theme.alpha(Theme.fg, 0.06)
clip: true
Rectangle {
id: artwork
anchors.left: parent.left
anchors.leftMargin: root.compact ? 8 : 10
anchors.verticalCenter: parent.verticalCenter
width: root.compact ? 46 : 56
height: width
radius: root.compact ? 9 : 10
border.width: 0
color: Theme.alpha(Theme.accentAlt, 0.16)
clip: true
Image {
anchors.fill: parent
source: root.player?.trackArtUrl ?? ""
asynchronous: true
cache: true
fillMode: Image.PreserveAspectCrop
visible: status === Image.Ready
}
ThemedIcon {
anchors.centerIn: parent
size: 24
icon: "audio-x-generic-symbolic"
iconFallback: "multimedia-player-symbolic"
tint: Theme.accentAlt
visible: !root.player?.trackArtUrl
}
}
Column {
anchors.left: artwork.right
anchors.leftMargin: root.compact ? 9 : 12
anchors.right: controls.left
anchors.rightMargin: root.compact ? 6 : 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.player?.trackTitle || "Unknown track"
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.player?.trackArtist || root.player?.identity || "Media"
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Row {
id: controls
anchors.right: parent.right
anchors.rightMargin: root.compact ? 7 : 10
anchors.verticalCenter: parent.verticalCenter
spacing: 2
MediaButton {
icon: "media-skip-backward-symbolic"
enabled: root.player?.canGoPrevious ?? false
onClicked: root.player?.previous()
}
MediaButton {
icon: root.player?.isPlaying ? "media-playback-pause-symbolic" : "media-playback-start-symbolic"
enabled: root.player?.canTogglePlaying ?? false
emphasized: true
onClicked: root.player?.togglePlaying()
}
MediaButton {
icon: "media-skip-forward-symbolic"
enabled: root.player?.canGoNext ?? false
onClicked: root.player?.next()
}
}
component MediaButton: Rectangle {
id: button
required property string icon
property bool emphasized: false
signal clicked
width: root.compact ? (emphasized ? 30 : 27) : (emphasized ? 34 : 30)
height: width
radius: width / 2
border.width: 0
opacity: enabled ? 1 : 0.35
color: {
if (mouse.pressed)
return Theme.alpha(emphasized ? Theme.accent : Theme.fg, 0.34);
if (mouse.containsMouse)
return Theme.alpha(emphasized ? Theme.accent : Theme.fg, 0.22);
return emphasized ? Theme.alpha(Theme.accent, 0.14) : "transparent";
}
Behavior on color {
ColorAnimation { duration: Theme.durFast }
}
ThemedIcon {
anchors.centerIn: parent
size: 15
icon: button.icon
tint: button.emphasized ? Theme.accent : Theme.fg
}
MouseArea {
id: mouse
anchors.fill: parent
enabled: button.enabled
hoverEnabled: true
cursorShape: button.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: button.clicked()
}
}
}
@@ -0,0 +1,45 @@
// Month-stepping arrow in CalendarGrid's header. A round hover target sized
// like GNOME's calendar arrows rather than a bare glyph, so it reads as a
// button before the pointer is anywhere near it.
import QtQuick
import qs.config
Rectangle {
id: root
signal activated
required property string glyph
implicitWidth: 26
implicitHeight: 26
radius: width / 2
border.width: 0
color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
// Fast in, relaxed out — the arrow answers the pointer, so arriving should
// feel immediate and leaving should not snap.
Behavior on color {
ColorAnimation {
duration: mouse.containsMouse ? Theme.durFast : Theme.durNormal
}
}
Text {
anchors.centerIn: parent
text: root.glyph
// fontMono is the Nerd Font: used here to draw an icon, never text.
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
color: mouse.containsMouse ? Theme.fg : Theme.fgDim
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
}
@@ -0,0 +1,172 @@
// One app's worth of notifications in the date menu, GNOME-style: a header
// carrying the app's identity and a clear button, over a collapsible stack of
// cards.
//
// The header is the disclosure control in its entirety — a 4px chevron is a
// poor click target, and GNOME lets you hit the whole row.
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.config
import qs.services
import qs.modules.notifications
import qs.modules.quicksettings
import qs.widgets
Column {
id: root
// One entry from Notifs.groups: { app, icon, desktopEntry, items }.
required property var group
property bool expanded: true
signal toggled
signal cleared
spacing: 4
// The app icon the notifications themselves declared, falling back to the
// desktop entry — same resolution order NotificationCard uses.
readonly property string iconSource: {
if (root.group.icon)
return Quickshell.iconPath(root.group.icon, true);
const entry = DesktopEntries.byId(root.group.desktopEntry);
return entry ? Quickshell.iconPath(entry.icon, true) : "";
}
Rectangle {
id: header
width: parent.width
height: 28
radius: Theme.cardRadius - 4
border.width: 0
color: headerMouse.containsMouse ? Theme.alpha(Theme.fg, 0.07) : "transparent"
Behavior on color {
ColorAnimation {
duration: headerMouse.containsMouse ? Theme.durFast : Theme.durNormal
}
}
MouseArea {
id: headerMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggled()
}
Row {
anchors.left: parent.left
anchors.leftMargin: 6
anchors.right: clearButton.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
spacing: 7
IconImage {
anchors.verticalCenter: parent.verticalCenter
implicitSize: 15
asynchronous: true
source: root.iconSource
visible: source !== ""
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.group.app
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
// A bare count next to a collapsed group is the only way to know
// how much is hidden, so it is always shown once there is more
// than one.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: countLabel.implicitWidth + 12
height: 16
radius: Theme.pillRadius
border.width: 0
visible: root.group.items.length > 1
color: Theme.alpha(Theme.fg, 0.12)
Text {
id: countLabel
anchors.centerIn: parent
text: root.group.items.length
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
IconButton {
id: clearButton
anchors.right: chevron.left
anchors.rightMargin: 2
anchors.verticalCenter: parent.verticalCenter
size: 22
iconSize: 12
tint: Theme.fgMuted
icon: "edit-clear-all-symbolic"
iconFallback: "window-close-symbolic"
onClicked: root.cleared()
}
ThemedIcon {
id: chevron
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
size: 12
icon: "pan-down-symbolic"
iconFallback: "go-down-symbolic"
tint: Theme.fgMuted
// Pointing down when open, right when closed — the standard
// disclosure direction, animated only on the click that caused it.
rotation: root.expanded ? 0 : -90
Behavior on rotation {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
}
}
// Section animates its own height and hides itself at zero, which also
// collapses the spacing the parent Column would otherwise leave behind.
Section {
width: parent.width
expanded: root.expanded
Column {
anchors.left: parent.left
anchors.right: parent.right
spacing: 4
Repeater {
model: root.group.items
NotificationCard {
required property var modelData
width: parent.width
compact: true
notification: modelData
onDismissed: Notifs.dismiss(modelData)
}
}
}
}
}
@@ -0,0 +1,73 @@
// The message tray inside the date menu: everything that has arrived, newest
// first, grouped by app, scrolling once it outgrows `maxHeight`.
import QtQuick
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
id: root
property int maxHeight: 420
implicitHeight: Notifs.hasNotifications ? list.implicitHeight : empty.implicitHeight
// Which app groups are collapsed, keyed by app name. Kept here rather than
// on the group itself because Notifs.groups is rebuilt from scratch on
// every change to history, which destroys and recreates the delegates.
property var collapsedApps: ({})
function toggleApp(app: string): void {
// Reassigned rather than mutated: a property holding a JS object only
// re-evaluates bindings when the reference itself changes.
const next = Object.assign({}, root.collapsedApps);
if (next[app])
delete next[app];
else
next[app] = true;
root.collapsedApps = next;
}
// Called when the panel closes, so it always reopens fully expanded.
function reset(): void {
root.collapsedApps = ({});
}
Item {
id: empty
anchors.fill: parent
implicitHeight: 132
visible: !Notifs.hasNotifications
Text {
anchors.centerIn: parent
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
ScrollColumn {
id: list
anchors.fill: parent
maxHeight: root.maxHeight
spacing: Theme.itemSpacing
visible: Notifs.hasNotifications
Repeater {
model: Notifs.groups
NotificationGroup {
required property var modelData
width: list.width
group: modelData
expanded: !root.collapsedApps[modelData.app]
onToggled: root.toggleApp(modelData.app)
onCleared: Notifs.dismissApp(modelData.app)
}
}
}
}
@@ -0,0 +1,58 @@
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
signal activated
visible: Notifs.hasNotifications
implicitHeight: visible ? 62 : 0
radius: Theme.cardRadius
border.width: 0
color: summaryMouse.containsMouse ? Theme.alpha(Theme.fg, 0.10) : Theme.alpha(Theme.fg, 0.055)
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Column {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
text: Notifs.history.length + (Notifs.history.length === 1 ? " notification" : " notifications")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
text: "Grouped quietly until you open them"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "View →"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: summaryMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
}
@@ -0,0 +1,263 @@
// Persistent work and privacy activity inside the Daybook.
import QtQuick
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
id: root
property real maxHeight: 500
implicitHeight: content.implicitHeight
Column {
id: content
width: parent.width
spacing: 12
Item {
width: parent.width
height: 38
Column {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: "PERSISTENT ACTIVITY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.9
}
Text {
text: "Ongoing"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
}
Text {
anchors.right: parent.right
anchors.bottom: parent.bottom
text: Ongoing.count === 0 ? "Nothing active" : Ongoing.count + (Ongoing.count === 1 ? " activity" : " activities")
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
Item {
width: parent.width
height: visible ? 190 : 0
visible: Ongoing.count === 0
Column {
anchors.centerIn: parent
spacing: 8
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "\u{F04A5}"
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: 24
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Nothing ongoing"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Focus, Caffeine, recording and privacy activity appear here."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
ScrollColumn {
width: parent.width
// Header + footer + the two 12px Column gaps consume 116px.
// Keeping that outside the scroll budget prevents the pane from
// growing beyond the Daybook when every producer is active.
maxHeight: Math.max(0, root.maxHeight - 116)
spacing: 8
visible: Ongoing.count > 0
Repeater {
model: Ongoing.activities
Rectangle {
id: activityRow
required property var modelData
width: parent.width
height: 62
radius: Theme.cardRadius
border.width: 0
color: rowMouse.containsMouse
? Theme.alpha(Theme.fg, 0.10)
: Theme.alpha(Theme.fg, 0.055)
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Text {
id: activityIcon
anchors.left: parent.left
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
text: activityRow.modelData.glyph
color: root.toneColor(activityRow.modelData.tone)
font.family: Theme.fontMono
font.pixelSize: 17
}
Column {
anchors.left: activityIcon.right
anchors.leftMargin: 11
anchors.right: activityAction.visible ? activityAction.left : activityState.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: activityRow.modelData.label
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: activityRow.modelData.detail
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
id: activityState
anchors.right: activityAction.visible ? activityAction.left : parent.right
anchors.rightMargin: activityAction.visible ? 7 : 12
anchors.verticalCenter: parent.verticalCenter
width: stateLabel.implicitWidth + 16
height: 25
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(root.toneColor(activityRow.modelData.tone), 0.12)
Text {
id: stateLabel
anchors.centerIn: parent
text: activityRow.modelData.state
color: root.toneColor(activityRow.modelData.tone)
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
Rectangle {
id: activityAction
z: 2
anchors.right: parent.right
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
visible: activityRow.modelData.action !== ""
width: visible ? actionLabel.implicitWidth + 18 : 0
height: 28
radius: Theme.pillRadius
border.width: 0
color: actionMouse.containsMouse
? Theme.alpha(activityRow.modelData.tone === "danger" ? Theme.danger : Theme.fg, 0.20)
: Theme.alpha(activityRow.modelData.tone === "danger" ? Theme.danger : Theme.fg, 0.08)
Text {
id: actionLabel
anchors.centerIn: parent
text: activityRow.modelData.action
color: activityRow.modelData.tone === "danger" ? Theme.danger : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: actionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Ongoing.invoke(activityRow.modelData.kind)
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
z: 1
enabled: activityRow.modelData.kind !== "caffeine"
hoverEnabled: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: Ongoing.activate(activityRow.modelData.kind)
}
}
}
}
Rectangle {
width: parent.width
height: 54
radius: Theme.cardRadius
border.width: 0
visible: Ongoing.count > 0
color: Theme.alpha(Theme.fg, 0.035)
Column {
anchors.left: parent.left
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
text: "Visible until it ends"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
text: "Completed screenshots, devices and other brief events stay in Signal Glass."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
function toneColor(tone: string): color {
if (tone === "danger")
return Theme.danger;
if (tone === "warn")
return Theme.warn;
if (tone === "ok")
return Theme.ok;
return Theme.accent;
}
}
@@ -0,0 +1,65 @@
// Current conditions, at the foot of the date menu — GNOME's weather section.
//
// The whole card disappears when services/Weather.qml has nothing: a weather
// widget that cannot reach the network must go quiet, not show an error. The
// caller collapses the separator above it off the same `Weather.available`.
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
implicitHeight: 62
radius: Theme.cardRadius
// Cards inside an already-glass panel do not get a prism edge — the edge
// marks a pane of glass, not every item sitting on one.
border.width: 0
color: Theme.alpha(Theme.fg, 0.06)
Text {
id: glyph
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: Weather.icon
// fontMono is the Nerd Font, used here purely to draw an icon.
font.family: Theme.fontMono
font.pixelSize: 26
color: Theme.accentAlt
}
Column {
anchors.left: glyph.right
anchors.leftMargin: 14
anchors.right: parent.right
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: Math.round(Weather.temperature) + Weather.unitSuffix
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
// The temperature changes in place on every refresh.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
// Description is empty for weather codes we don't have a label for,
// in which case the row is just the location.
text: Weather.description === "" ? Settings.weatherLocation : Weather.description + " · " + Settings.weatherLocation
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
@@ -0,0 +1,13 @@
module qs.modules.datemenu
AgendaEvent 1.0 AgendaEvent.qml
AgendaPane 1.0 AgendaPane.qml
CalendarGrid 1.0 CalendarGrid.qml
DateMenu 1.0 DateMenu.qml
DateMenuPanel 1.0 DateMenuPanel.qml
MediaCard 1.0 MediaCard.qml
MonthArrow 1.0 MonthArrow.qml
NotificationGroup 1.0 NotificationGroup.qml
NotificationList 1.0 NotificationList.qml
NotificationSummary 1.0 NotificationSummary.qml
OngoingPane 1.0 OngoingPane.qml
WeatherCard 1.0 WeatherCard.qml
+152
View File
@@ -0,0 +1,152 @@
// The dock — a replacement for Dash-to-Dock configured as: bottom edge, 48px
// icons, intellihide against all windows, dot running-indicators, show-apps
// button at the leading edge.
//
// Intellihide means the dock reserves no space at all (exclusiveZone 0) and
// floats over windows, appearing when the pointer reaches the bottom edge or
// when nothing is in the way.
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.services
PanelWindow {
id: root
// Set by Variants when instantiated per-screen. Plain rather than
// `required` so the dock can also be created standalone while testing;
// Variants injects into the existing property either way.
property var modelData: null
screen: root.modelData
anchors {
bottom: true
left: true
right: true
}
color: "transparent"
// A dock that reserved space would not be intellihiding.
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
WlrLayershell.namespace: "qs-dock"
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// ── Geometry ────────────────────────────────────────────────────────────
// Height = room for the tooltip above the bar + the bar + the gap under it.
readonly property int revealStripHeight: 3
readonly property int bottomMargin: Theme.barGap
readonly property int tooltipSpace: 34
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
// ── Intellihide ─────────────────────────────────────────────────────────
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
// be computed. "Is anything on this workspace at all" is the robust proxy,
// and it is what Dash-to-Dock's all-windows intellihide felt like in
// practice: an empty workspace keeps the dock out.
readonly property bool workspaceOccupied: {
const ws = Hyprland.focusedWorkspace;
return !!ws && ws.toplevels.values.length > 0;
}
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered
property bool revealed: true
onWantRevealedChanged: {
if (wantRevealed) {
hideTimer.stop();
revealTimer.restart();
} else {
revealTimer.stop();
hideTimer.restart();
}
}
Timer {
id: revealTimer
interval: Settings.dockRevealDelayMs
onTriggered: root.revealed = true
}
Timer {
id: hideTimer
interval: Settings.dockHideDelayMs
onTriggered: root.revealed = false
}
// Other modules (the bar, the capture overlay) read this.
onRevealedChanged: ShellState.dockRevealed = revealed
// wantRevealed's first evaluation emits no change signal when it lands on
// false (the default), so the initial state has to be taken explicitly —
// otherwise a shell started on a busy workspace would leave the dock up.
Component.onCompleted: {
revealed = wantRevealed;
ShellState.dockRevealed = revealed;
}
// ── Input region ────────────────────────────────────────────────────────
// Revealed: the bar plus everything below it, so crossing the gap under the
// dock does not count as leaving. Hidden: a sliver along the screen edge,
// which is the only thing that can still trigger the dock — every other
// click passes straight through to the window underneath.
mask: Region {
item: maskItem
}
Item {
id: surface
anchors.fill: parent
// Reports the pointer anywhere inside the input region. A HoverHandler
// rather than a MouseArea because it keeps reporting while the icons'
// own MouseAreas are hovered.
HoverHandler {
id: pointer
}
Item {
id: maskItem
x: root.revealed ? body.x : 0
y: root.revealed ? body.y : surface.height - root.revealStripHeight
width: root.revealed ? body.width : surface.width
height: root.revealed ? surface.height - body.y : root.revealStripHeight
}
DockBody {
id: body
anchors.horizontalCenter: parent.horizontalCenter
// Slides off the bottom edge when hidden.
y: root.revealed ? root.tooltipSpace : surface.height
opacity: root.revealed ? 1 : 0
// Asymmetric on purpose. Revealing is a response to something the
// user just did, so it has to feel immediate — any delay there
// reads as lag. Hiding is not a response to anything, so it can
// take its time and stay calm in peripheral vision.
Behavior on y {
NumberAnimation {
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
}
}
Behavior on opacity {
NumberAnimation {
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
easing.type: Easing.OutCubic
}
}
}
}
}
@@ -0,0 +1,203 @@
// The visible dock bar: the rounded translucent pill, its items, and the
// tooltip that floats above it.
//
// Kept separate from Dock.qml so it can be dropped straight into a
// FloatingWindow for testing — PanelWindow cannot map without layer shell.
import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.widgets
Rectangle {
id: root
// ── Model ───────────────────────────────────────────────────────────────
// One pass over the live toplevel list produces the whole dock: pinned
// apps first in Settings order, then anything else that is running.
// Entries are plain JS objects: { entry, windows, appId }.
readonly property var items: {
// DesktopEntries is scanned asynchronously at startup, and byId() is a
// plain method call that creates no binding dependency. Reading the
// model here is what makes the dock fill in once the scan lands.
if (DesktopEntries.applications.values.length === 0)
return [];
const groups = {}; // appId -> [HyprlandToplevel]
const order = []; // appIds in first-seen order
const toplevels = Hyprland.toplevels.values;
for (let i = 0; i < toplevels.length; i++) {
const t = toplevels[i];
// `wayland` is null until Hyprland reports the address, and it is
// the only live source of an app id — there is no `class` property.
const appId = t.wayland ? t.wayland.appId : "";
if (!appId)
continue;
if (!groups[appId]) {
groups[appId] = [];
order.push(appId);
}
groups[appId].push(t);
}
const out = [];
const claimed = {};
const pinned = Settings.dockPinned;
for (let i = 0; i < pinned.length; i++) {
const entry = DesktopEntries.byId(pinned[i]);
// A pin that no longer resolves is dropped rather than drawn as a
// broken icon.
if (!entry)
continue;
let windows = [];
for (let j = 0; j < order.length; j++) {
if (!claimed[order[j]] && root.matches(order[j], entry, pinned[i])) {
claimed[order[j]] = true;
windows = windows.concat(groups[order[j]]);
}
}
out.push({
entry: entry,
windows: windows,
appId: pinned[i]
});
}
for (let i = 0; i < order.length; i++) {
const appId = order[i];
if (claimed[appId])
continue;
out.push({
entry: DesktopEntries.heuristicLookup(appId),
windows: groups[appId],
appId: appId
});
}
return out;
}
// Wayland app ids and desktop entry ids agree often but not always
// (`org.gnome.Nautilus` vs `nautilus`, `Steam` vs `steam`), so compare the
// usual candidates case-insensitively and fall back to the last
// reverse-DNS segment.
function matches(appId: string, entry: var, pinId: string): bool {
const a = appId.toLowerCase();
if (a === pinId.toLowerCase())
return true;
if (entry.id && a === entry.id.toLowerCase())
return true;
if (entry.startupClass && a === entry.startupClass.toLowerCase())
return true;
const tail = a.split(".").pop();
return tail === pinId.toLowerCase().split(".").pop();
}
// ── Appearance ──────────────────────────────────────────────────────────
radius: Theme.dockRadius
color: Theme.alpha(Theme.bgPanel, Theme.dockAlpha)
border.width: 0 // QTBUG-137166: rounded translucent rects lose their corners
// The prism edge — see widgets/PrismEdge.qml.
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
// The item the tooltip is currently describing, or null.
property Item hoveredItem: null
Row {
id: row
anchors.centerIn: parent
spacing: Theme.dockGap
ShowAppsButton {
id: showApps
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = showApps
onExited: if (root.hoveredItem === showApps)
root.hoveredItem = null
}
// Separator between the launcher and the apps, as in GNOME's dash.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: Theme.dockIconSize * 0.7
border.width: 0
color: Theme.alpha(Theme.fg, 0.14)
}
Repeater {
model: root.items
DockItem {
id: dockItem
required property var modelData
app: modelData
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null
}
}
}
// ── Tooltip ─────────────────────────────────────────────────────────────
// Styled like widgets/Popover.qml. Deliberately outside the dock's bounds
// (negative y): it renders fine there, and the dock's input mask does not
// cover it, so it can never swallow a click.
Rectangle {
id: tooltip
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
visible: opacity > 0
opacity: root.hoveredItem && tipLabel.text ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Theme.durFast
}
}
// Centred on the hovered item, clamped inside the dock. Reads the item's
// x directly (rather than mapToItem) so the binding re-evaluates when
// the row reflows.
x: {
if (!root.hoveredItem)
return 0;
const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
}
y: -height - 8
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
height: tipLabel.implicitHeight + 8
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
Text {
id: tipLabel
anchors.centerIn: parent
text: tooltip.text
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
@@ -0,0 +1,168 @@
// One dock icon: the app, its running-window dots, hover scale and click
// behaviour. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
// underneath, grow-on-hover, click to focus / cycle / launch.
import Quickshell
import Quickshell.Hyprland
import Quickshell.Widgets
import QtQuick
import qs.config
Item {
id: root
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string }
// Built by DockBody so this file stays presentational.
required property var app
readonly property DesktopEntry entry: app && app.entry ? app.entry : null
readonly property var windows: app && app.windows ? app.windows : []
readonly property string label: entry ? entry.name : (app && app.appId ? app.appId : "")
readonly property bool running: windows.length > 0
readonly property bool hovered: mouse.containsMouse
// Emitted so DockBody can drive the single shared tooltip.
signal entered
signal exited
// The icon may grow past the cell on hover; the cell itself stays a fixed
// size so the row doesn't reflow.
implicitWidth: Theme.dockIconSize
implicitHeight: Theme.dockIconSize + dots.height + 4
// Desktop entries usually carry a freedesktop icon *name*, but some ship an
// absolute path. iconPath() only understands names, so branch on it. The
// `true` argument makes a missing icon return "" instead of a placeholder
// that renders as a black square in some themes.
readonly property string iconSource: {
const name = entry && entry.icon ? entry.icon : (app && app.appId ? app.appId : "");
if (!name)
return "";
if (name.startsWith("/"))
return "file://" + name;
return Quickshell.iconPath(name, true);
}
Item {
id: iconSlot
width: Theme.dockIconSize
height: Theme.dockIconSize
anchors.horizontalCenter: parent.horizontalCenter
y: 0
// Grow upward out of the dock, the way GNOME's dash does, so the icon
// never overlaps its own running dots.
transformOrigin: Item.Bottom
scale: root.hovered ? 1.15 : 1.0
Behavior on scale {
NumberAnimation {
duration: Theme.durFast
easing.type: Easing.OutBack
easing.overshoot: 1.6
}
}
IconImage {
anchors.fill: parent
visible: root.iconSource !== ""
source: root.iconSource
asynchronous: true
mipmap: true
}
// Last resort for an app with no resolvable icon: an initial, which is
// still recognisable, unlike the theme's broken-icon placeholder.
Rectangle {
anchors.fill: parent
visible: root.iconSource === ""
radius: Theme.cardRadius
border.width: 0
color: Theme.alpha(Theme.fg, Theme.activeAlpha)
Text {
anchors.centerIn: parent
text: root.label ? root.label.charAt(0).toUpperCase() : "?"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Math.round(Theme.dockIconSize * 0.5)
}
}
}
// Running indicators. Dash-to-Dock showed one dot per window up to four.
Row {
id: dots
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
spacing: 3
height: 4
Repeater {
model: Math.min(root.windows.length, 4)
Rectangle {
width: 4
height: 4
radius: 2
border.width: 0 // QTBUG-137166: transparent rounded rects punch corners
color: Theme.accent
}
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onEntered: root.entered()
onExited: root.exited()
onClicked: mev => {
// Middle click always starts a new instance, as in GNOME.
if (mev.button === Qt.MiddleButton) {
root.launch();
return;
}
if (root.running)
root.focusNext();
else
root.launch();
}
}
function launch(): void {
if (root.entry)
root.entry.execute();
}
// Clicking a running app cycles through its windows, matching GNOME's dash.
function focusNext(): void {
const wins = root.windows;
if (wins.length === 0)
return;
let next = wins[0];
for (let i = 0; i < wins.length; i++) {
if (wins[i].activated) {
next = wins[(i + 1) % wins.length];
break;
}
}
if (!next)
return;
// Quickshell reports the address without the 0x prefix Hyprland's
// window selector expects; normalise so either form works.
if (next.address) {
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
} else if (next.wayland) {
// No address yet (the toplevel is still being reported) — the
// wlr handle can still raise it.
next.wayland.activate();
}
}
}
@@ -0,0 +1,79 @@
// The leading "show applications" button. GNOME's dash had
// show-apps-at-top: true, which on a bottom dock means the leading edge.
//
// The glyph is drawn rather than pulled from the icon theme: GNOME's app-grid
// icon is literally a 3x3 dot grid, and drawing it removes any dependency on
// which icon theme happens to be installed.
import Quickshell
import QtQuick
import qs.config
Item {
id: root
signal entered
signal exited
readonly property bool hovered: mouse.containsMouse
readonly property string label: qsTr("Show Applications")
implicitWidth: Theme.dockIconSize
implicitHeight: Theme.dockIconSize
Rectangle {
id: bg
anchors.centerIn: parent
width: Theme.dockIconSize
height: Theme.dockIconSize
radius: width / 2
border.width: 0 // QTBUG-137166
color: root.hovered ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
transformOrigin: Item.Center
scale: root.hovered ? 1.15 : 1.0
Behavior on scale {
NumberAnimation {
duration: Theme.durFast
easing.type: Easing.OutBack
easing.overshoot: 1.6
}
}
Grid {
anchors.centerIn: parent
columns: 3
spacing: Math.round(Theme.dockIconSize * 0.11)
Repeater {
model: 9
Rectangle {
readonly property int dot: Math.round(Theme.dockIconSize * 0.135)
width: dot
height: dot
radius: dot / 2
border.width: 0
color: Theme.fg
}
}
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
onEntered: root.entered()
onExited: root.exited()
// vicinae is the launcher this shell uses in place of GNOME's app grid.
onClicked: Quickshell.execDetached(["vicinae", "toggle"])
}
}
@@ -0,0 +1,65 @@
// One action in the focus capsule. Kept local to the module so the capsule's
// low, tailored geometry does not leak into generic shell buttons.
import QtQuick
import qs.config
import qs.widgets
Rectangle {
id: root
property string icon: ""
property string iconFallback: "dialog-information-symbolic"
property string label: ""
property bool destructive: false
signal activated
implicitHeight: 34
radius: Theme.cardRadius - 2
border.width: 0
readonly property color actionColor: root.destructive ? Theme.danger : Theme.fg
color: {
if (mouse.pressed)
return Theme.alpha(root.actionColor, 0.24);
if (mouse.containsMouse)
return Theme.alpha(root.actionColor, root.destructive ? 0.16 : Theme.hoverAlpha);
return Theme.alpha(Theme.fg, 0.07);
}
Behavior on color {
ColorAnimation { duration: Theme.durFast }
}
Row {
anchors.centerIn: parent
spacing: 7
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 15
icon: root.icon
iconFallback: root.iconFallback
tint: root.actionColor
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.label
color: root.actionColor
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
}
@@ -0,0 +1,34 @@
// The session never disappears completely. Dismissing the larger glass signal
// leaves this quiet bar-native indicator, which reopens it in one click.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: FocusSession.active
horizontalPadding: 8
onActivated: FocusSession.reveal()
onSecondaryActivated: FocusSession.pauseOrResume()
Text {
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.paused ? "\u{F03E4}" : "\u{F051F}" // pause / timer-outline
color: FocusSession.paused ? Theme.warn : Theme.accent
font.family: Theme.fontMono
font.pixelSize: 14
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.remainingText
color: FocusSession.paused ? Theme.fgDim : Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
}
@@ -0,0 +1,3 @@
module qs.modules.focus
FocusAction 1.0 FocusAction.qml
FocusIndicator 1.0 FocusIndicator.qml
@@ -0,0 +1,306 @@
// One notification, rendered GNOME-style: app icon, app name, summary, body,
// optional image, action buttons and an inline reply box.
//
// Shared by the toasts and the notification centre so a notification looks the
// same wherever you meet it.
//
// IconButton/ThemedIcon come from the quicksettings module — they belong in
// widgets/, but that directory is owned elsewhere.
import QtQuick
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
import qs.config
import qs.services
import qs.modules.quicksettings
import qs.widgets
Rectangle {
id: root
required property var notification
// The centre renders a denser card than a toast banner.
property bool compact: false
signal dismissed
readonly property alias hovered: hover.containsMouse
// Raised when the reply field takes focus: the toast window has to ask the
// compositor for keyboard focus before anything can be typed.
signal replyFocusChanged(bool focused)
// The image sits outside `layout`, so the card has to be tall enough for
// whichever of the two is bigger.
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
radius: Theme.cardRadius
border.width: 0
color: hover.containsMouse ? Theme.alpha(Theme.fg, 0.1) : Theme.alpha(Theme.fg, 0.06)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
// Critical notifications get a red edge rather than a colour wash, so the
// text contrast never changes.
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 6
width: 3
radius: 1.5
border.width: 0
color: Theme.urgent
visible: root.notification.urgency === NotificationUrgency.Critical
}
readonly property var defaultAction: {
for (const action of root.notification.actions) {
if (action.identifier === "default")
return action;
}
return null;
}
// "default" is the card click, so it never becomes a button.
readonly property bool hasButtons: {
for (const action of root.notification.actions) {
if (action.identifier !== "default")
return true;
}
return false;
}
// Everything except the buttons: clicking the body runs the notification's
// default action, which is what GNOME does.
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.defaultAction)
root.defaultAction.invoke();
}
}
IconImage {
id: appIcon
anchors.left: parent.left
anchors.leftMargin: 14
anchors.top: parent.top
anchors.topMargin: 13
implicitSize: 22
asynchronous: true
source: {
if (root.notification.appIcon !== "")
return Quickshell.iconPath(root.notification.appIcon, true);
const entry = DesktopEntries.byId(root.notification.desktopEntry);
return entry ? Quickshell.iconPath(entry.icon, true) : "";
}
visible: source !== ""
}
IconButton {
id: closeButton
anchors.right: parent.right
anchors.rightMargin: 6
anchors.top: parent.top
anchors.topMargin: 6
size: 24
iconSize: 12
tint: Theme.fgDim
icon: "window-close-symbolic"
onClicked: root.dismissed()
}
Column {
id: layout
anchors.left: appIcon.visible ? appIcon.right : parent.left
anchors.leftMargin: appIcon.visible ? 10 : 14
anchors.right: image.visible ? image.left : closeButton.left
anchors.rightMargin: 8
anchors.top: parent.top
anchors.topMargin: 12
spacing: 3
Text {
width: parent.width
text: {
const app = root.notification.appName || "Notification";
const time = Notifs.timeText(root.notification.id);
return time === "" ? app : app + " · " + time;
}
color: Theme.fgMuted
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: text !== ""
text: root.notification.summary
color: Theme.fg
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.Wrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
visible: text !== ""
text: root.notification.body
color: Theme.fgDim
// The server advertises bodyMarkupSupported, so bodies arrive with
// the freedesktop subset of HTML in them.
textFormat: Text.StyledText
linkColor: Theme.accent
elide: Text.ElideRight
wrapMode: Text.Wrap
maximumLineCount: root.compact ? 6 : 3
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Item {
width: 1
height: 4
visible: actionRow.visible || replyBox.visible
}
Flow {
id: actionRow
width: parent.width
spacing: 6
visible: root.hasButtons
Repeater {
model: root.notification.actions
Rectangle {
id: actionButton
required property var modelData
visible: actionButton.modelData.identifier !== "default"
width: visible ? actionLabel.implicitWidth + 22 : 0
height: visible ? 28 : 0
radius: Theme.pillRadius
border.width: 0
color: actionMouse.containsMouse ? Theme.alpha(Theme.accent, 0.28) : Theme.alpha(Theme.fg, 0.1)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
id: actionLabel
anchors.centerIn: parent
text: actionButton.modelData.text
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: actionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: actionButton.modelData.invoke()
}
}
}
}
Rectangle {
id: replyBox
width: parent.width
height: 36
visible: root.notification.hasInlineReply
radius: Theme.cardRadius - 2
border.width: 1
border.color: replyInput.activeFocus ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.12)
color: Theme.alpha(Theme.fg, 0.08)
TextInput {
id: replyInput
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: replySend.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
onActiveFocusChanged: root.replyFocusChanged(replyInput.activeFocus)
onAccepted: {
if (replyInput.text.length === 0)
return;
root.notification.sendInlineReply(replyInput.text);
replyInput.text = "";
root.dismissed();
}
Text {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
visible: replyInput.text === ""
text: root.notification.inlineReplyPlaceholder || "Reply"
color: Theme.fgMuted
font: replyInput.font
}
}
IconButton {
id: replySend
anchors.right: parent.right
anchors.rightMargin: 3
anchors.verticalCenter: parent.verticalCenter
size: 28
iconSize: 14
icon: "go-next-symbolic"
onClicked: replyInput.accepted()
}
}
}
// Notification images (album art, avatars, screenshots) sit on the right.
ClippingRectangle {
id: image
anchors.right: parent.right
anchors.rightMargin: 14
anchors.top: closeButton.bottom
anchors.topMargin: 4
implicitWidth: 52
implicitHeight: 52
radius: 8
border.width: 0
color: "transparent"
visible: root.notification.image !== ""
Image {
anchors.fill: parent
source: root.notification.image
fillMode: Image.PreserveAspectCrop
asynchronous: true
sourceSize.width: 104
sourceSize.height: 104
}
}
}
@@ -0,0 +1,219 @@
// GNOME's message tray: everything that has arrived, grouped by app.
//
// Drops from the top centre because that is where GNOME's tray lives and the
// muscle memory is the whole point. Layer-shell centres a surface on whichever
// axis it is not anchored to, so anchoring only `top` does it.
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import Quickshell.Widgets
import qs.config
import qs.services
import qs.modules.quicksettings
import qs.widgets
PanelWindow {
id: root
visible: ShellState.notificationsOpen
color: "transparent"
anchors.top: true
margins.top: Theme.barHeight + Theme.barGap * 2
exclusiveZone: 0
implicitWidth: 440
implicitHeight: surface.implicitHeight
WlrLayershell.namespace: "qs-popover-notifications"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
onVisibleChanged: {
if (root.visible)
Notifs.markAllRead();
}
HyprlandFocusGrab {
windows: [root]
active: root.visible
onCleared: ShellState.close()
}
Rectangle {
id: surface
anchors.fill: parent
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
// border so the two don't fight for the same row of pixels.
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: ShellState.close()
Column {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.popoverPadding
spacing: Theme.itemSpacing
// ── Header ──────────────────────────────────────────────────
Item {
width: parent.width
height: 32
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
IconButton {
size: 30
iconSize: 16
icon: "notifications-disabled-symbolic"
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
Rectangle {
width: clearLabel.implicitWidth + 20
height: 28
radius: Theme.pillRadius
border.width: 0
visible: Notifs.hasNotifications
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
id: clearLabel
anchors.centerIn: parent
text: "Clear all"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: clearMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Notifs.dismissAll()
}
}
}
}
// ── Empty state ─────────────────────────────────────────────
Item {
width: parent.width
height: 120
visible: !Notifs.hasNotifications
Text {
anchors.centerIn: parent
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
// ── Grouped history ─────────────────────────────────────────
ScrollColumn {
width: parent.width
maxHeight: 620
spacing: Theme.itemSpacing
visible: Notifs.hasNotifications
Repeater {
model: Notifs.groups
Column {
id: group
required property var modelData
width: parent.width
spacing: 4
Item {
width: parent.width
height: 26
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: group.modelData.app
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
IconButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
size: 24
iconSize: 13
tint: Theme.fgDim
icon: "edit-clear-all-symbolic"
iconFallback: "window-close-symbolic"
onClicked: Notifs.dismissApp(group.modelData.app)
}
}
Repeater {
model: group.modelData.items
NotificationCard {
required property var modelData
width: group.width
compact: true
notification: modelData
onDismissed: Notifs.dismiss(modelData)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,64 @@
// A single banner: a notification card that slides in and times itself out.
import QtQuick
import Quickshell.Services.Notifications
import qs.config
import qs.services
Item {
id: root
required property var notification
// Raised while the inline reply field has focus — Toasts.qml turns that
// into a keyboard focus request on the layer surface.
signal replyFocusChanged(bool focused)
implicitHeight: card.implicitHeight
// Critical notifications stay until dismissed (the setting is 0). An app
// asking for 0 means "never expire" per the freedesktop spec; -1 means
// "server decides", which is our default.
readonly property int timeoutMs: {
if (root.notification.urgency === NotificationUrgency.Critical)
return Settings.notificationTimeoutCriticalMs;
if (root.notification.expireTimeout === 0)
return 0;
if (root.notification.expireTimeout > 0)
return Math.round(root.notification.expireTimeout * 1000);
return Settings.notificationTimeoutMs;
}
HoverHandler {
id: hover
}
Timer {
// Hovering holds the banner open; the countdown restarts on leave.
running: root.timeoutMs > 0 && !hover.hovered
interval: root.timeoutMs
onTriggered: Notifs.dropPopup(root.notification)
}
NotificationCard {
id: card
width: parent.width
notification: root.notification
onDismissed: Notifs.dropPopup(root.notification)
onReplyFocusChanged: focused => root.replyFocusChanged(focused)
// Slide in from the right edge. Runs once, on creation.
NumberAnimation on x {
from: 40
to: 0
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
NumberAnimation on opacity {
from: 0
to: 1
duration: Theme.durNormal
}
}
}
@@ -0,0 +1,59 @@
// The banner stack: newest at the top, tucked under the bar on the right.
//
// The window is exactly as wide and tall as the stack, so everything outside
// it stays clickable; the mask trims it further to the cards themselves.
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.config
import qs.services
PanelWindow {
id: root
visible: !Notifs.doNotDisturb && Notifs.popups.length > 0
color: "transparent"
anchors.top: true
anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2
margins.right: Theme.barSideMargin
exclusiveZone: 0
implicitWidth: 400
implicitHeight: Math.max(1, stack.implicitHeight)
WlrLayershell.namespace: "qs-notifications"
WlrLayershell.layer: WlrLayer.Overlay
// Normally inert so banners never steal a keystroke. An open inline reply
// is the one case where the surface has to be typeable.
WlrLayershell.keyboardFocus: root.replyFocused ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
property bool replyFocused: false
mask: Region {
item: stack
}
Column {
id: stack
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
spacing: Theme.itemSpacing
Repeater {
model: Notifs.popups.slice(0, Settings.maxVisibleToasts)
Toast {
required property var modelData
width: stack.width
notification: modelData
onReplyFocusChanged: focused => root.replyFocused = focused
}
}
}
}
+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
}
}
}

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