Compare commits

...

71 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki 78971fd9d8 hyprdrive: route notification D-Bus activation to beacon, not dunst
The dunst package ships org.knopwob.dunst.service with
Name=org.freedesktop.Notifications, so D-Bus auto-activates dunst the
moment any app posts a notification, beating beacon to the name (beacon
then exits on name-lost). Shadow it with a user-level activation file
that Execs beacon-start.sh; ~/.local/share/dbus-1/services takes
precedence over /usr/share. Documented the install-time deploy in
updater.conf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:59:10 +02:00
Amir Alexander Abdelbaki e5d58622fe Revert "feat(freeipa-ansible): gateway-wide Basic Auth in front of the ansipa portal"
This reverts commit 4730f89e82.
2026-07-22 16:24:31 +02:00
Amir Alexander Abdelbaki 4730f89e82 feat(freeipa-ansible): gateway-wide Basic Auth in front of the ansipa portal
Adds a deliberate extra sign-in layer covering the entire nginx gateway
(portal page + every proxied web UI) via a shared htpasswd credential
generated on the FreeIPA container and published to nginx through a new
portal-auth volume. FreeIPA, CheckMK and Keycloak still each enforce their
own login behind it — two prompts beats one attacker who only had to beat
one lock. Healthcheck updated to treat 401 as healthy accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 16:08:10 +02:00
Amir Alexander Abdelbaki 4200557757 feat(freeipa-ansible): git-based signed policy distribution for ANSIPA
Replaces the unsigned SMB-policystore auto-sync (enforcer blindly sourced
whatever .sh files sat on the share, no integrity check) with a dedicated
git-over-SSH server plus GPG commit signing: nodes pull on a timer, verify
every commit's signature and fast-forward history before an unprivileged
puller account hands off to root via one exact scoped sudo command, and a
fetch failure is a safe no-op that keeps the last-known-good policy running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 15:56:19 +02:00
Amir Alexander Abdelbaki 2a1113018d chore: mark sway.sh install module executable
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:52:32 +02:00
Amir Alexander Abdelbaki ab6e330bed style(greetd): restyle ReGreet login as Cosmonaut Shell
Rework regreet.css into holographic-glass login cards — magenta scanlines,
violet top-glow, thin glowing magenta borders, glowing monospace text — to
match hyprdrive's Cosmonaut Shell, with fixed cq_magenta/cq_cyan colours kept
outside apply-theme's palette map. Switch the greeter font to Agave Nerd Font
Mono and the greeting to "-< CREW AUTHENTICATION >-" (regreet.toml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:52:32 +02:00
Amir Alexander Abdelbaki 057a5a44d8 fix(station-bar): make SNI watcher D-Bus calls async (self-deadlock)
The bar hosts the StatusNotifierWatcher in-process (sni_watcher.py), so the
*_sync proxy creation and RegisterStatusNotifierHost call blocked the main
loop waiting on a reply only that same loop could produce — a self-deadlock
freezing the whole bar (clock, holo intro) until the 25s D-Bus timeout. Switch
to new_for_bus + call (async, fire-and-forget).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:52:32 +02:00
Amir Alexander Abdelbaki 4da704cb5d feat(hyprdrive): default wallpaper -> ESA/Hubble "Rings of Relativity"
Swap the module's default background.jpg for ESA/Hubble Picture of the Week
potw2050a, "Rings of Relativity" (GAL-CLUS-022058s, one of the most complete
Einstein rings observed). Update CREDITS.md and the hyprdrive.sh install-module
comments to match. Credit: ESA/Hubble & NASA, S. Jha; Ack. L. Shatz — CC BY 4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:49:04 +02:00
Amir Alexander Abdelbaki f040cac729 feat(hypr): float dialog-like windows at cursor, minimum size
Add a `dialog-float` window rule to both hyprdrive and hyprlua: float any
window whose title looks like a dialog (Rename/Save/Open/Opening/Confirm/
Delete/Move/Copy/Replace/Properties/Preferences — covers file-manager rename
dialogs and Firefox "Opening <file>" download prompts), centred on the cursor
at its natural (minimum) size. Catches dialogs that carry no xdg/X11 dialog
hint and would otherwise tile; Hyprland still auto-floats the ones that do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:49:04 +02:00
Amir Alexander Abdelbaki 6ef9f31dee feat(hyprdrive): tune beacon notification open/close animation
- beacon: snappier in-app dissolve (OUTRO_DURATION 0.4 -> 0.2).
- hyprland.lua: split the global `layers` animation into layersIn/layersOut
  so open and close differ. layersIn keeps the springy myBezier slide-in;
  layersOut slides the surface back off-screen with a new easeInQuint-ish
  `slideOutLate` curve — it creeps slowly then whips away in the final
  quarter, instead of a uniform fade.
- windowrules.lua: drop `no_anim` from the beacon layerrule (keep blur) so
  notification cards ride those layer animations again (they slid in/out via
  the global anim before, since no per-namespace no_anim was deployed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:37:22 +02:00
Amir Alexander Abdelbaki b7eba3f967 feat(hyprdrive): switch notifications from dunst to beacon
beacon (the Cosmonaut Shell notif daemon) already renders its holo
fade-in-from-noise intro (materialise out of static), but dunst held the
org.freedesktop.Notifications bus so beacon never started. Make beacon the
deployed and active notification daemon:

- updater.conf: manage `config beacon`; drop `config dunst` (now `ignore`d,
  superseded). Also carries the pre-existing `config ghostty` line that was
  already sitting uncommitted in this file.
- beacon: add a SIGUSR1 close-all handler (window.close_all) so the old
  `dunstctl close-all` keybind has an equivalent.
- binds.lua: Super+Ctrl+C now runs `pkill -USR1 -f '[b]eacon/main.py'`
  (bracket idiom avoids pkill self-matching its own wrapper).
- screenrec.sh: `dunstctl close` -> freedesktop CloseNotification via gdbus.
- timer-run: resolve the session bus via the beacon process, not dunst.

hyprlua keeps its own independent updater.conf + dunst and is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:04:13 +02:00
Amir Alexander Abdelbaki f94a825c82 feat(hyprdrive): dark holo scanlines for kitty + caffeine widget in station-bar
kitty: regenerate scanlines.png — the old tile was a washed-out near-white
overlay that milked out the terminal. Replace it with a dark holographic tile
(gentle violet #5018DD tint + crisp violet scanlines every 4px + sparse
magenta/crimson grain) on a transparent base, so the window's own opacity/blur
still show the wallpaper through it. Seamlessly tileable; matches the suite's
HologramOverlay palette.

station-bar: add a caffeine badge to the right zone, a faithful port of
hyprlua's 4-state eww caffeine widget. Click toggles the shared idle-inhibit
lock via the same scripts/caffeine.sh; the glyph tracks manual inhibit (coffee)
/ webcam presence (eye) / idle (sleep) and glows magenta while presence holds
the lock, reading the same lock/flag files the status scripts do. Nerd Font
codepoints verified against Agave's cmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:38:51 +02:00
Amir Alexander Abdelbaki fa78fc9a66 Add md-to-pdf.sh: convert Markdown to PDF via python-markdown + weasyprint
Companion to docs/md-to-html.sh, for cases needing a standalone PDF
rather than a themed HTML page.
2026-07-21 13:32:10 +02:00
Amir Alexander Abdelbaki c1e54f4f75 feat(freeipa-ansible): add CheckMK monitoring for Proxmox VE + PBS
Proxmox VE hypervisors and Proxmox Backup Server aren't FreeIPA clients,
so they sit outside the dev_mon_base host-group flow. Add standalone
CheckMK local checks (cluster/storage/guests/backup for PVE; datastore
usage/last-backup/task-failures for PBS) plus a small installer that
detects the role, installs the agent, and registers the host under
/ansipa/infra — mirroring the existing dev_mon.sh pattern without
requiring IPA enrollment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:31:03 +02:00
Amir Alexander Abdelbaki dce26b2dcf feat(terminals): deploy CyberQueer palette across kitty/alacritty everywhere
hyprland's kitty theme was still the stock template (only 5 colors set,
ANSI colors commented out); brought it in line with hyprdrive/hyprlua's
full neon 16-color palette and dropped the now-redundant inline cursor
overrides in kitty.conf. Alacritty had no color scheme at all in any DE,
so added matching [colors] blocks to hyprdrive/hyprlua/hyprland so it
renders identically to kitty/ghostty. Registered alacritty.toml and
ghostty/config in apply-theme.sh so future colors.conf edits propagate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 10:01:35 +02:00
Amir Alexander Abdelbaki ace372780d feat(hyprdrive): holographic glow for kitty + new Ghostty CyberQueer config
kitty: add a seamlessly-tiled scanlines+noise+ambient-bloom PNG as
background_image, giving kitty a static approximation of the suite's
HologramOverlay treatment since it has no per-frame draw hook or shader
support to do real bloom.

ghostty: port the CyberQueer theme (palette, cursor/selection colors)
to a new Ghostty config, with a real per-pixel custom-shader instead of
a static image — color-preserving bloom, procedural scanlines/grain, an
occasional analog glitch/roll burst (tear + vertical roll + chromatic
aberration), and a one-time hologram boot intro that resolves out of
colored static via a downward sweep. Window decorations and the tab bar
are disabled to match kitty's borderless look under Hyprland.

Not wired up anywhere yet — no keybind or $TERMINAL changes point at
Ghostty. This is just the config, ready to test.
2026-07-20 09:46:14 +02:00
Amir Alexander Abdelbaki 2d7336c593 feat(hyprlua/kitty): finish the CyberQueer kitty theme
Same fix as hyprdrive's kitty (6c07c10): the 16 ANSI colors were still
stock template defaults clashing with the violet/crimson palette used
everywhere else in the suite. Fill them in from colors.conf plus a
few new neon tints in the same hue family, set background to the
real #1A1A1A instead of pure black, add matching border/bell/mark
colors, and drop the stale cursor-color pair in kitty.conf that the
theme include was silently overriding anyway.
2026-07-20 08:56:52 +02:00
Amir Alexander Abdelbaki 6c07c10370 feat(hyprdrive/kitty): finish the CyberQueer kitty theme
The 16 ANSI colors were still stock template defaults (plain green/
blue/cyan) clashing with the violet/crimson palette used everywhere
else in the suite. Fill them in from colors.conf plus a few new neon
tints in the same hue family, set background to the real #1A1A1A
instead of pure black, and add matching border/bell/mark colors. Also
drop a stale cursor-color pair in kitty.conf that the theme include
was silently overriding anyway.
2026-07-20 08:52:31 +02:00
Amir Alexander Abdelbaki 9d59565469 revert(hyprlua): remove Cosmonaut Shell components, restore pre-Friday state
orbit-menu and horizon-dock (and their eww-bar launcher buttons, autostart
hooks, and O-bind wrapping) were built inside hyprlua before hyprdrive
existed as a separate DE. hyprdrive is now the home for the full Cosmonaut
Shell suite, so hyprlua drops back to eww bars + vicinae launcher + the
astal-menu pop-open menu, unchanged, matching last Thursday's state.
hyprdrive itself is untouched.
2026-07-20 08:37:12 +02:00
Amir Alexander Abdelbaki 74fc9aefaf feat(hyprdrive/beacon): draw the divider as a Cairo magenta sine path
Replaces the ∿-glyph label divider with a Gtk.DrawingArea that strokes a sampled
sine wave (soft-glow + bright-core passes), colour per urgency (magenta / accent),
phase-advanced each tick so it travels like a live signal. Background stays
transparent — a class-specific rule overrides the CyberQueer theme's universal
`background-color`, so no dark pill shows behind it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:36:04 +02:00
Amir Alexander Abdelbaki 838f954cad feat(hyprdrive/beacon): holographic notification daemon replacing dunst
beacon is a GTK4 layer-shell notification daemon in the Cosmonaut Shell idiom.
It owns org.freedesktop.Notifications and renders each notification as a
holographic card: scanline/sweep/noise overlay (shared lib/hologram.py) clipped
to a rounded violet holo-glass card, emitted-magenta text, glow-violet frame
(accent + squiggle for critical), materialise-out-of-static intro / dissolve
outro, top-centre stack.

Implements the interactive spec subset: body markup, urgency, replaces_id,
per-urgency timeouts, action pills, icons (name/path/raw), click-to-dismiss, and
the NotificationClosed / ActionInvoked signals.

- scripts/beacon-start.sh: LD_PRELOAD launcher
- autostart.lua: dunst -> beacon
- windowrules.lua: blur layer-rule for the `beacon` namespace (no_anim; it has
  its own hologram intro)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:22:01 +02:00
Amir Alexander Abdelbaki 178c5e3e91 feat(hyprdrive/dunst): holographic notification theme + radio-squiggle divider
Themes dunst in the astro-menu idiom: emitted-magenta text, violet holo-glass
backgrounds (transparent for the compositor blur), glow-violet/accent frames,
Agave Nerd Font Mono, and a "radio squiggle" sine-wave divider between title and
body. (Superseded by the beacon daemon in a following commit, but kept as themed
fallback config.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:21:28 +02:00
Amir Alexander Abdelbaki 837bc2823a fix(hyprdrive/station-bar): wall-clock safety net so the holo intro can't stall
The materialise intro was driven only by the frame-clock tick, which stops
advancing when the compositor withholds frame callbacks (e.g. the bar restarted
into an idle compositor) — freezing it at t=0 with content stuck at opacity 0,
i.e. permanent static. Arm a GLib.timeout alongside start_intro() that
force-resolves the intro regardless, so content always appears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:21:28 +02:00
Amir Alexander Abdelbaki e1126e9613 feat(hyprdrive/station-bar): render tray context menus from dbusmenu
Right-click now fetches the item's com.canonical.dbusmenu layout and renders it
in a GTK popover anchored to the icon, instead of calling the SNI ContextMenu
method. Fixes menus opening at screen (0,0) for apps that implement it (Discord)
and gives a menu at all for dbusmenu-only apps (nm-applet, blueman). Supports
sections, submenus, checkmarks, icons and Event-on-activate; themed to match the
suite (.station-tray-menu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:21:28 +02:00
Amir Alexander Abdelbaki 50b80cd5ce fix(hyprdrive/orbit-menu): launch Tools/Scripts via hl.dsp.exec_cmd
`hyprctl dispatch` evaluates its argument as Lua here (hyprlua), so the old
native `dispatch exec <cmd>` form parsed as a Lua syntax error and was silently
dropped — every Tools/Scripts/Management entry failed to launch. Route through
hl.dsp.exec_cmd(...) instead (matching binds.lua), keeping the [tag ...] rule
prefix support.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:21:28 +02:00
Amir Alexander Abdelbaki a534952c1c fix(hyprdrive/station-bar): host a StatusNotifierWatcher so the tray fills
hyprdrive has no DE component owning org.kde.StatusNotifierWatcher (waybar/eww
used to), so tray apps had nowhere to register and every SNI host — including the
bar's own tray.py — saw an empty tray.

- sni_watcher.py: a minimal StatusNotifierWatcher — owns the name, tracks
  registered items/hosts, drops items on NameOwnerChanged, and emits the
  registration signals + PropertiesChanged so hosts stay live.
- main.py: start it in do_startup, before the bars.
- tray.py: watch the watcher name (bus_watch_name) instead of connecting once, so
  the host attaches when our watcher appears and recovers if any watcher restarts.

Verified: the watcher owns the bus name, IsStatusNotifierHostRegistered is true,
and running tray apps (udiskie, nm-applet, blueman, Steam) register + render in
the bar the moment it comes up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:44:17 +02:00
Amir Alexander Abdelbaki 8dfd505ce4 feat(hyprdrive): materialize animations, dmenu mode, ship-systems astro rework
Second daily-driving pass over the Cosmonaut Shell suite.

Animations (all four surfaces):
- Removed every slide-in reveal; each surface now only "materialises out of
  static" (compositor no_anim layerrule + in-app hologram intro). Longer, noisier,
  gradual fade-in: the content opacity ramps 0->1 (fade_widget) instead of a solid
  haze block popping on. Added a quick reverse "dissolve into static" outro on
  close. horizon-dock/orbit-menu use a slower 2.4s intro. Fixed a warm-reopen bug
  where a stale frame-clock baseline skipped the intro entirely.

horizon-dock:
- Center planet: bigger, sinks ~1/4 below the screen edge, translucent holographic
  body with a wide bloom halo, specular sheen, rim glow and a breathing pulse.
- Ornamental satellite glyphs: two rings framing the windows orbit, each with a
  random 3-7 satellites whose motion source is randomised (toward-cursor / mouseX
  / mouseY), so the quiet edges feel alive.
- Super+D now opens horizon-dock (astro-menu moved to Super+Shift+D).

orbit-menu:
- Classic dmenu mode: `--dmenu` reads newline items from stdin, shows them as ring
  nodes, prints the choice to stdout (exit 1 on cancel). Standalone NON_UNIQUE app
  so it never routes to the resident daemon; scripts/orbit-dmenu.sh wraps it with
  the gtk4-layer-shell preload.

astro-menu (sci-fi rework):
- Replaced the map module with "Ship Systems": a Gtk.Grid system monitor backed by
  backend/sysmon.sh. CPU->Thrusters, GPU->Hyperdrive, RAM->Life Support Systems,
  Storage->Cargo Hold (one aligned row per mounted drive with capacity), each a
  bracketed -<████░░░░>- usage meter + -( 54°C )- temperature readout.
- Renamed Bluetooth -> System Radio, Network -> Laser Antenna Uplink.
- Weather gains a "Planetary Environment Report" headline.

station-bar:
- The battery readout uses a fuel-tank glyph (the ship runs on fuel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:17:48 +02:00
Amir Alexander Abdelbaki f48c70273a fix(hyprdrive): holographic Cosmonaut Shell fixes + per-monitor station bar
Daily-driving pass over the Cosmonaut Shell suite: transparency, holographic
effects, and layout/geometry fixes across every component built for the DE.

Global holographic layer (astro-menu/horizon-dock/station-bar lib/hologram.py):
- edge-fade mask (A8 DEST_OUT) so scanlines fade out at panel borders
- magenta particle field; "materialize from static" noise-mask intro animation
- optional clip_func so the mask follows non-rectangular / animated content
- universal `* { background-color: transparent }` at USER+1 to defeat the
  CyberQueer theme painting opaque bg on plain containers; blur layer_rules

astro-menu: magenta text (alt red) for readability; thinner, glowier borders;
rounded map-texture corners; fixed transparency stacking + square corners.

orbit-menu: scanline mask now follows the zoom transition into a submenu.

horizon-dock: reworked into an orbit-menu node ~1/3 on-screen (<=2/3 width) with
horizon perspective (arched orbit rows), a non-functional center clock-sphere,
orbit glyph decorations, graceful edge fade-out, and centered/filled orbits;
tray removed; giant-surface clip fixed.

station-bar: redesigned as an extra Horizon Dock orbit — bare glowing color-coded
glyphs (no pills), width-adaptive, one bar per monitor, Super+Z toggles the
focused monitor's bar. Clickable HUD trapezoid around the title opens Astro Menu.
Workspaces are now per-monitor: each bar shows only its own monitor's workspaces
and highlights that monitor's active workspace. Populated workspaces show the
station glyph, empty ones the bare spaceship + number; the focused one is framed
by a `-< >-` reticle with the rocket prepended when there's a station to dock at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:34:40 +02:00
Amir Alexander Abdelbaki 90375a5733 feat(hyprdrive): add hyprdrive DE with full Cosmonaut Shell suite
New leaner Hyprland DE (setup/modules/Desktop-Environments/hyprdrive.sh),
a sibling of hyprlua that drops the older EWW/nwg-shell/wofi/walker/
ulauncher stack in favor of a purpose-built, GTK4 layer-shell component
suite sharing one CyberQueer hologram visual language:

- orbit-menu    radial power/utility menu (focal-point zoom nav, holographic
                overlay, cosmetic satellites)
- horizon-dock  hover-scrollable orbital app dock (open windows/favorites/
                all apps orbits, SNI tray satellite)
- astro-menu    astal-menu re-themed as a hologram info display (Location/
                Weather/Bluetooth/Network quads, taskbar, app drawer)
- station-bar   the EWW top bar's replacement ("Voidstation Status Bar"):
                a thin curved layer-shell bar tracking Hyprland workspaces/
                active window natively over .socket2.sock, no polling

Every component now shares one consistent hologram treatment: translucent
violet panel/node fills (replacing solid gray), a config-toggleable
scanline+sweep+noise overlay (lib/hologram.py, ported to horizon-dock and
station-bar to match orbit-menu/astro-menu), and glow-on-hover throughout.

Also adds horizon-dock to hyprlua (built there first, before hyprdrive
existed) and wires dependency checks, config-updater entries, and
apply-theme.sh color sync for every new component across both DEs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nu8JkQ95hPAT8vADeGDAmL
2026-07-17 15:28:07 +02:00
Amir Alexander Abdelbaki 4fbffee5a0 feat(hyprlua/orbit-menu): sci-fi zoom navigation + holographic overlay + opening intro
Replaces the plain Gtk.Stack crossfade with a focal-point zoom (Gtk.Fixed +
Gsk.Transform): the clicked node's screen position becomes a pivot the parent
page zooms into while the child page slides in from that exact spot and grows
to become the new center — like a moon becoming the planet you now orbit.
Going back reverses the same motion around the same remembered point
(_stack_origins tracks it per depth).

Adds a holographic overlay (config.json "hologram" toggle, default on):
violet scanlines + a looping sweep band + magenta/red noise specs with real
per-particle lifetimes (fade in/out, not single-frame flicker), all composited
through a radial mask so it reads as one soft-edged glow over the menu rather
than tinting the whole square canvas.

Adds an opening-only intro (never plays between nodes): each button starts
invisible and fades in on a staggered cascade — center first, then ring nodes
in order — with a bright noise burst flashing at each node's position the
instant it starts appearing, like it's condensing out of the static.

Also fixes a transparency regression: the new hologram DrawingArea sits above
everything via Gtk.Overlay, and the cyberqueer theme's aggressive `*` selector
was painting it an opaque background, hiding the whole menu — forced
transparent via CSS, same fix pattern as the earlier orbit-node label bug.
2026-07-17 11:29:51 +02:00
Amir Alexander Abdelbaki 779e365cb8 feat(hyprlua/orbit-menu): cosmetic mouse-reactive satellites around ring nodes
Adds a decorative layer to the radial menu: small nerd-font glyphs orbit the
ring nodes (and occasionally the center) at a fixed radial "height" per
satellite, only their angle changes — easing toward the cursor plus a slight
idle wobble. Faint dashed/dotted/solid orbit-path circles trace each one.

Per page build (not per frame, so it never flickers):
- total count is 2 + random(1, (3 % node-amount) + 2), clamped to node
  capacity, spread across at least 3 distinct nodes when there are that many
- a node can get up to 2 satellites; since each has its own fixed height they
  never overlap, and the gap between a doubled pair is widened further
- a doubled pair splits its motion — one tracks only the cursor's X, the
  other only Y, each with its own random phase offset so multiple doubled
  nodes on one page don't move in lockstep
- every satellite gets independent horizontal/vertical response multipliers,
  so it can read as more reactive to one direction than the other
- some satellites get tangential bracket flourishes (-<, -[, -(, -{, -|)
  rotated to the orbit's local tangent

Toggleable via ~/.local/state/orbit-menu/config.json ({"satellites": false})
since config-updater wipes ~/.config/orbit-menu on every sync.
2026-07-17 10:45:24 +02:00
Amir Alexander Abdelbaki 2fa3414e31 feat(hyprlua): add orbit-menu radial power/utility menu
New GTK4 layer-shell radial menu (desktopenvs/hyprlua/orbit-menu/), following
astal-menu's single-instance D-Bus daemon conventions. Super+Shift+O opens
straight into a Power ring (sleep/reboot/poweroff/soft-reboot via hyprshutdown);
Super+Ctrl+O opens the full 5-category root (Power, Tools, Scripts sourced live
from ~/Documents/Scripts, Management, and a live open-Windows list with
go-to/bring-here/close/force-kill/get-pid/send-to-workspace actions). Adds a
round launcher button to all 3 eww bar variants with matching glow/hover CSS.

Also retunes hypridle (lock 30s, sleep 1min) and adds a HibernateDelaySec
systemd-sleep.conf.d fragment (deployed by sysupdate.sh) so the machine
hibernates ~80 minutes after going idle instead of drifting to sleep indefinitely.
2026-07-17 09:51:02 +02:00
Amir Alexander Abdelbaki 2fd7b510ef feat(hyprlua): size a fresh workspace's initial column count to monitor width
A workspace with no seeded/published column count used to always start
at a hardcoded 2, regardless of monitor size. Now it picks
floor(usable_width / 700), so ~700px per column — 1080p still lands on
2, wider monitors start with more instead of one cramped column.
2026-07-16 09:08:23 +02:00
Amir Alexander Abdelbaki c313f0694c fix(hyprlua): columns layout breaks on fresh workspaces, wrap focus u/d
hypr-nav fell back to the raw "lua:columns" backend string on any
workspace that had never been explicitly switched via the menu (i.e.
every fresh workspace, since columns is the global default), which
didn't match the "columns" layout name it checks against — so focus/
move/resize silently took the generic dwindle-style path instead of
being forwarded to the custom layout. Strip the "lua:" prefix in the
fallback.

Also make focus/scroll u/d within a column wrap around instead of
stopping at the ends.
2026-07-16 09:01:21 +02:00
Amir Alexander Abdelbaki a24a27fff6 fix(archiso): source dotfiles from a single live-env clone, not skel-only
The dotfiles clone used to land directly in /mnt/etc/skel, gated solely by
clone_skel_dotfiles. That meant run_tui=YES + clone_skel_dotfiles=NO cloned
nothing anywhere, so tui-install.sh (which only exists inside the repo, not
in the embedded live-ISO scripts) had nothing to run and the TUI step always
failed.

Clone the repo once into the live environment ($HOME/Dotfiles) whenever
either toggle is YES, then serve both the skel copy and — as a fallback
right before the TUI runs — a direct copy into the new user's home from
that single clone, no extra network fetch needed. arch-autoinstall.sh's
skel clone also moved out of the chroot heredoc so it can share the same
live clone.

When clone_skel_dotfiles=NO, the fallback copy in the new user's home is
transient: after tui-install.sh finishes, materialize every symlink that
shell-setup.sh/hyprlua/hyprland/niri point into ~/Dotfiles (shell rc's,
nvim/starship/alot/yazi/spotify-tui configs, config-updater scripts) into
real copies, then remove the checkout — so declining skel persistence
doesn't leave a system full of dangling symlinks.
2026-07-13 14:27:12 +02:00
Amir Alexander Abdelbaki 3b67b23944 feat(archiso): support fully bare Arch installs and fix greetd/DM conflicts
Decouple the base installer's "run dotfiles TUI" choice from cloning the
dotfiles repo into /etc/skel and from the new user's default shell, so
declining both actually yields a bare system: no dotfiles, plain bash,
no DE, no display manager, and a working agetty login on tty1. Also add
an explicit toggle in the TUI for syncing the configured user's ~/.config
back into /etc/skel, previously unconditional.

Separately, core.sh's "svc" component unconditionally staged a greetd
config that launches Hyprland and enabled greetd.service — even when no
DE was selected, and even for DEs (GNOME, KDE Plasma, COSMIC, XFCE,
LXQt, Sway) that install their own display manager. That left greetd
racing the DE's native DM (or agetty, in the no-DE case) for tty1, and
Sway additionally got a greeter pointed at the wrong compositor. Gate
greetd on the DE selection and have every DE module disown getty/greetd
before enabling its own DM, matching the pattern hyprland.sh/niri.sh
already used; add a Sway-specific tuigreet config.
2026-07-13 12:08:13 +02:00
Amir Alexander Abdelbaki bdecba0cf3 feat(hyprlua): float xdg portal dialogs above their parent
Portal-backed file/app choosers (xdg-desktop-portal-gtk) — e.g. Steam's
"select library folder" picker, plus Chromium/Electron/Flatpak dialogs —
run in a separate process and open as parentless top-levels with no
xdg-dialog or X11-dialog hint. Hyprland can't tell they're dialogs and
tiles them, unlike in-process xdg dialogs which it auto-floats. Match the
portal backend's class and float + centre it so it lands above its parent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 04:28:42 +02:00
Amir Alexander Abdelbaki 05693a71b7 chore(presence-detect): remove now-unused v4l2loopback provisioning
presence-detect.sh no longer mirrors the camera through a v4l2loopback device,
so the module setup is dead weight. Remove it:

- delete etc-v4l2loopback/ (modprobe.d + modules-load.d configs)
- drop the deploy blocks and linux-headers/v4l2loopback-dkms/v4l2loopback-utils
  package installs from the niri and hyprlua DE installers
- remove _ensure_v4l2loopback() and its call from sysupdate.sh
- reword enroll-biometrics.sh's loopback filter comment (kept as a harmless
  defensive exclusion) so it no longer points at the deleted config

Live system cleanup (module unload + /etc configs) is separate and needs root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 04:11:16 +02:00
Amir Alexander Abdelbaki c2fa8d3e75 feat(presence-detect): back off while another app holds the camera
Before each tick, check whether another process already has the real camera
open (fuser, which sees same-user holders without root — every camera app runs
as us). If so, skip the read entirely for that tick: attempting would be
pointless (we'd get EBUSY) and a simultaneous open could make the other app
fail. A camera in use is proof the user is present, so mark presence and wait
it out, touching the camera zero times for the whole duration another app holds
it — instead of poking it every tick and racing the app for access.

V4L2 has no way to be notified that another app wants the device, so this
can't preempt an in-progress read; it just guarantees we never contend while
another app is already streaming. Falls through to a normal read (and the
Python helper's exit-3 busy path) if fuser isn't installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 04:11:16 +02:00
Amir Alexander Abdelbaki 83687764d8 feat(presence-detect): skip the camera entirely during a manual caffeine session
When idle is inhibited manually (a caffeine toggle, not this daemon), the lock
is held regardless of presence, so polling the camera is pointless. Guard the
loop: if the shared idle lock is alive but we don't own it (same test as
caffeine-manual-status.sh), skip the read and leave the camera free for other
apps for the whole manual session. PRESENCE_FLAG is cleared while skipping,
since we're no longer watching — so the widget's presence input reads false
during a manual session instead of showing a stale "camera sees you".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 04:01:16 +02:00
Amir Alexander Abdelbaki bc544898c1 fix(presence-detect): read camera directly instead of v4l2loopback mirror
The loopback feeder (defa69f) held /dev/video0 open 24/7, so real apps —
video calls, howdy — could never open the camera, and the single loopback
device only ever served one reader at a time (a second consumer got
VIDIOC_REQBUFS -EBUSY). Net effect: the camera was permanently blocked and
nothing could access it.

Drop the ffmpeg mirror and read the physical camera directly, only for the
~0.5s per tick it takes to grab frames, then release it — so the camera is
free for other apps the rest of the time. When another app is already holding
the camera, the Python detector now distinguishes "busy/in use" (device node
present but unreadable → exit 3) from "unavailable" (node gone → exit 2), and
the daemon treats busy as presence: a camera in use is itself proof the user
is here, so a video call keeps the session awake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 03:51:44 +02:00
Amir Alexander Abdelbaki 1fa5ebee70 feat(presence-detect): 4-state caffeine widget + steadier, faster detection
Rework the Eww caffeine widget into two independent inputs — manual inhibit
and presence detected — yielding four states across all three bars:
  neither            → 󰒲 sleep,  purple
  manual only        →  coffee, purple
  presence only      → 󰈈 eye,    pink
  manual + presence  →  coffee, pink
Colour tracks presence; the coffee cup shows only while manually inhibiting.

To make the two inputs independent, the daemon now writes a standalone
/tmp/presence-detected flag each tick (decoupled from lock ownership, so it's
true even during a manual session). presence-status.sh reports that flag
(presence input); new caffeine-manual-status.sh reports lock-held-and-not-
presence-owned (manual input).

Make detection less finnicky: space the detector's frame grabs (0.06s) so slow
movement registers and loopback reads never diff a stale duplicate frame, and
lower the thresholds (delta 25→18, area 2%→0.8%). Add a 90s grace window so
brief stillness never flickers presence off; any motion resets the clock.

Add a third poll tier — INTERVAL_FAST=2s when CPU-or-RAM usage is below 30%
(LOAD_LIGHT) — alongside the existing 20s normal and 120s busy tiers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 03:07:22 +02:00
Amir Alexander Abdelbaki dc9cf6d830 feat(presence-detect): tint caffeine eww widget hot pink while presence holds the lock
Add presence-status.sh reporting true only when the daemon (not a manual
caffeine toggle) owns the shared idle lock, via the existing OWNED_FLAG.
Wire it into all three eww bars: the caffeine button gains a
.caffeine-presence class → the #E40046 Active accent used elsewhere, so
camera-driven auto-awake is visually distinct from a manual session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 02:09:44 +02:00
Amir Alexander Abdelbaki defa69faab feat(presence-detect): mirror camera via v4l2loopback so the daemon never blocks it
presence-detect.sh now feeds the real webcam into a v4l2loopback virtual
device (/dev/video42) via a self-healing ffmpeg background process, and reads
motion off that mirror instead of opening the physical camera directly. This
means the daemon's 20s-interval checks never contend with other apps (video
calls, howdy) for exclusive camera access. Falls back to direct camera access
if the loopback isn't set up yet, so the feature degrades gracefully rather
than breaking.

New etc-v4l2loopback/ ships the modules-load.d/modprobe.d config (fixed
video_nr=42, exclusive_caps=1) following the repo's existing etc-greetd/
etc-lightdm/ convention. Deliberately no runtime sudo/modprobe calls inside
the daemon itself — the module loads declaratively at boot instead, matching
this repo's existing caution around unattended sudo.

hyprlua.sh and niri.sh installers now pull in linux-headers,
v4l2loopback-dkms, and v4l2loopback-utils and deploy the module config
immediately. sysupdate.sh gained _ensure_v4l2loopback(), wired into
sync_configs(), so existing machines pick up the new packages and /etc
config on their next --config or --both run without needing a fresh install.

enroll-biometrics.sh's camera picker now excludes the loopback device itself
(by card_label) so configuring the real camera can't accidentally target the
mirror it feeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:39:11 +02:00
Amir Alexander Abdelbaki a0221b42dc fix(flatpak): apply QT_QPA_PLATFORMTHEME override alongside GTK_THEME
Flatpak apps don't inherit the host's QT_QPA_PLATFORMTHEME=gtk3, so Qt apps
(Wireshark, PrismLauncher, ...) ignored the cyberqueer theme even though
GTK_THEME was already being overridden per-app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:23:49 +02:00
Amir Alexander Abdelbaki f90dbdae8b feat(presence-detect): switch to motion detection, adaptive interval, shared caffeine lock
Replace the Haar-cascade face detector with lightweight frame-differencing
motion detection so the daemon can poll every 20s (backing off to 120s under
heavy CPU/RAM load) without adding noticeable overhead.

Presence detection now shares caffeine's systemd-inhibit lock instead of
holding its own, so the caffeine status widget reflects either source. A new
ownership flag ensures the daemon only ever releases a lock it acquired
itself, so a manually-started caffeine session is never interrupted by the
user stepping away. Also re-enables the daemon in hyprlua's autostart (was
previously only wired up for niri).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:23:49 +02:00
Amir Alexander Abdelbaki 9d4cdc35bd fix(hyprlua): spatial (transform-aware) monitor-jump fallback
Root cause of "can jump one way across monitors but not back" on a
desk with a rotated monitor: both columns.lua's own edge-crossing and
the fallback this adds go through Hyprland's focusmonitor/movewindow
mon: -1/+1, which cycle through Hyprland's internal monitor *list
order* — not actual spatial position. A monitor's transform swaps its
logical width/height (same math as monitor-manager's logical_width/
logical_height) but doesn't reorder that list, so list order and
physical left-to-right order can silently diverge once one monitor is
rotated — confirmed with the reported layout (main 3840x2160 normal +
rotated 1920x1080 to its right): jumping right resolved correctly,
jumping back left did not.

scripts/monitor-adjacent replaces the index cycling with real spatial
adjacency: it sorts monitors by logical bounding-box center each time
and dispatches focusmonitor/movewindow to the actual next/prev monitor
in the requested screen direction. Verified against the exact reported
monitor layout (simulated, transform=1 on the right monitor) — both
directions now resolve to the correct monitor.

Wired in two places (Super+,/. is untouched, still index-cycling, by
request):
  - columns.lua's edge-crossing (off the left/right edge of the column
    strip already flowed onto the next monitor; now via spatial lookup)
  - hypr-nav's scrolling-layout perpendicular "no neighbour" case, as a
    new fallback: regular directional movement (Super+hjkl) that
    previously just stopped at the edge of a monitor now spills onto
    whichever monitor is spatially in that direction, same as columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 16:49:18 +02:00
Amir Alexander Abdelbaki dc0436136b fix(astal-menu): fix layout-tab sync race across workspace switches
_sync_layout_controls() fires 3 concurrent hyprctl calls, and refresh()
can trigger it twice per open (once from the clients query, once from
the activeworkspace one) — each rebuilding a fresh set of layout
widgets. Two bugs followed:

1. The 3 callbacks each toggled the shared self._syncing flag
   independently (True at start, False at end). Whichever resolved
   first dropped the guard while the others — notably the tab-
   selection one, which programmatically flips the visible stack
   child — were still in flight. That let _on_tab_switch/_on_opt_change
   misfire as if the user had clicked, re-applying stale layout data
   via layouts.set() to whatever workspace was active by the time the
   callback landed.

2. A second _sync_layout_controls() call (from the redundant rebuild)
   could land mid-flight of the first, so a stale callback from sync
   #1 could decrement sync #2's guard or write sync #1's data into
   sync #2's freshly built widgets.

Fixed with a generation token: any sync superseded by a newer one is
invalidated outright, and self._syncing only lifts once the *current*
generation's 3 calls have all resolved. This is what "query the
current workspace's layout when opened, so it doesn't break switching
workspaces" needed — refresh() already re-queried on open, the gap was
in trusting stale in-flight results.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 16:48:42 +02:00
Amir Alexander Abdelbaki 052611abf5 feat(hyprlua): default to columns, special:magic to scrolling
Session default flips from scrolling to columns; special:magic keeps
a downward scrolling layout of its own. layouts.restore() still wins
if the user has since picked something else for it via the astal-menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 16:48:12 +02:00
Amir Alexander Abdelbaki 7a5c715da3 feat(hyprlua): open opendeck on special:magic workspace
Matches the existing kitty autostart rule so opendeck launches hidden
on the scratchpad workspace instead of the active one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 15:59:32 +02:00
Amir Alexander Abdelbaki 6194b5c271 fix(hyprlua): drop racy cw+ccw autostart rotation workaround
The double-rotate on every Hyprland start existed to fix monitor scale
getting corrupted by rotation. Verified live (Hyprland 0.55.4): scale
stays exactly pinned across a transform change as long as it's set
explicitly (monitors.lua already does scale=1.0, not "auto"), so the
workaround is no longer needed upstream.

It was also a source of a real race: unified-rotate.sh reads current
transform via `hyprctl monitors -j`, and the fixed 0.3s gap between the
two invocations isn't guaranteed long enough for the first rotate to
commit before the second reads state — especially with a dozen other
autostart commands firing concurrently in the same block. A lost race
leaves the monitor (and touchscreen, which unified-rotate.sh also
retargets) transform mismatched after boot. Root-caused this while
investigating why a QEMU test VM's monitor stayed stuck at transform:1
after every fresh boot.

unified-rotate.sh itself is untouched — still used by the manual
rotate buttons on the touch bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 15:48:23 +02:00
Amir Alexander Abdelbaki 3e51579564 fix(desktop-environments): missing EWW build dep + non-idempotent clone
Found via a fresh-install test in a QEMU VM using the repo's answerfile
system (hyprlua, tablet/touch eww bar variant).

1. hyprlua.sh/hyprland.sh/niri.sh all compile EWW (GTK3) from source but
   never installed gtk-layer-shell — only gtk4-layer-shell, which is a
   separate package for astal-menu's GTK4 surface. Every fresh install
   failed at the gtk-layer-shell-sys build script with a pkg-config
   error ("gtk-layer-shell-0 ... was not found").

2. The EWW `git clone` isn't idempotent: a leftover ~/install-tmp/eww
   from an earlier interrupted/retried install (e.g. the failure above)
   makes the next clone abort with "already exists and is not an empty
   directory", killing the whole script under set -e. `rm -rf eww`
   before cloning fixes retries.

Both reproduced and fixed directly in the test VM before being applied
here: installing gtk-layer-shell and clearing the stale checkout let
the same run complete successfully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 12:56:24 +02:00
Amir Alexander Abdelbaki 682e3769f6 feat(setup/tools): add passwordless-sudo.sh toggle script
For sessions that need to run a string of sudo commands with nothing
around to answer a password prompt (unattended VM builds, agent-driven
scripting). Drops a validated sudoers.d NOPASSWD entry for the current
user on enable, removes it on disable. Off by default, not part of the
regular install/update flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 09:55:54 +02:00
Amir Alexander Abdelbaki 716902f8e1 perf(astal-menu): use gdbus Activate fast path in menu-toggle.sh
menu-toggle.sh always cold-started a second python3+PyGObject+GTK4
process to forward one verb to the already-running daemon, per
GApplication's command-line-forwarding mechanism. That's ~0.5s
baseline and spikes past 5s under load, easily read as an
unresponsive/broken menu button on repeat taps.

main.py's own do_command_line comment already describes the intended
fix: call the registered org.gtk.Actions directly over D-Bus for an
already-running instance. Wire that up (gdbus call, ~20-100ms) and
keep the python3 spawn only as the cold-bootstrap path when no
instance is registered yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 09:18:34 +02:00
Amir Alexander Abdelbaki 0541177974 fix(eww-touch): use nobattery bar's astal-menu launcher button
The touch bar's menu button used a bare icon-btn with no explicit
toggle side, unlike the nobattery bar's accent-coloured launcher.
Mirror the nobattery implementation (class + explicit "toggle top"
args + .menu-launcher styling) for visual and behavioral consistency
between bar variants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
2026-07-09 08:34:41 +02:00
Amir Alexander Abdelbaki 5a0c671dea fix(greetd): pass cage -d to stop ReGreet's CSD titlebar fallback
Without -d, cage never negotiates xdg-decoration, so GTK4/ReGreet falls
back to drawing its own client-side title bar chrome on some machines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 00:40:55 +02:00
Amir Alexander Abdelbaki 3da57a457a fix(greetd): password-then-FIDO order; add ReGreet background tool
greetd's PAM stack included system-login, whose own pam_u2f line runs
before the password stack — combined with greetd's own trailing
pam_u2f line, ReGreet prompted FIDO, then password, then FIDO again.
Spell out system-login's auth phase minus that stray line so FIDO is
asked once, after the password, matching hyprlock's order.

Also add setup/tools/set-regreet-background.sh to pin a specific image
as the greeter background, independent of the wallpaper-sync unit.
2026-07-09 00:13:19 +02:00
Amir Alexander Abdelbaki 84420733bb adjustments for astal+ewwbar 2026-07-08 21:56:51 +02:00
Amir Alexander Abdelbaki d680730d30 feat(sysupdate): Plymouth/ReGreet migration, astal-menu deps, hyprshutdown, usr redeploy
sysupdate.sh's config sync now closes several gaps where a fresh or drifted
machine would silently be missing pieces that config-updater can't reach
(it only copies files, never installs packages or touches /etc):

- _ensure_plymouth_regreet: detects whether the machine is still on
  greetd+tuigreet (or no greeter at all) and offers to run the existing
  migrate-to-greetd.sh tool rather than duplicating its logic.
- _ensure_astal_menu_deps / _ensure_hyprshutdown: detect and offer to install
  the AUR/pacman packages astal-menu and the new power binds need but that
  config-updater never installs on its own.
- _redeploy_usr_configs: hypr/usr/ is intentionally excluded from the
  automated sync to protect per-device customisation (monitors.lua,
  wallpaper.conf), which left feature/bind updates in the dotfiles template
  with no path onto an already-provisioned machine. Adds an explicit,
  file-by-file dialog checklist (device-specific files default off) so those
  updates can be pulled in deliberately instead of hand-copied.
2026-07-08 13:35:56 +02:00
Amir Alexander Abdelbaki ed44b84afa feat(hyprlua/columns): center-focused toggle, mirroring scrolling's fit method
Adds a "center-focused" switch to the Columns layout's menu tab: when a column
overflows and scrolls, it can now center the focused window in the column
instead of only nudging the pan the minimum amount to keep it in view. Off by
default so existing behavior is unchanged unless explicitly enabled.

The astal-menu switch widget already existed generically (built for scrolling's
native focus_fit_method) but was hard-wired to that one Hyprland option; it's
now layout-aware and reads/writes columns.lua's own persisted state instead.
2026-07-08 13:35:47 +02:00
Amir Alexander Abdelbaki 964ce103a0 feat(hyprlua/columns): resizable+scrolling columns, window parking, reload-safe
Overhaul the custom "columns" Lua layout and its navigation wiring:

- Flexible sizing: per-column width weights + per-window height weights
  (Super+Alt resize), scale-to-fill when a column is underfull, scroll/pan
  when overfull. Drops the old fixed viewport/rows window heights.
- Window parking: move u/d within a column parks two windows side by side in
  one slot (persistent, split across the column axis with a divider parallel
  to the scroll direction; the column closes up so there is no gap); moving a
  parked window again pops it out past its partner.
- New windows stack into the working column (with a balance guard) instead of
  scattering, so two windows share a column/height by default.
- Cross-monitor focus/move at the column edges (relative +1/-1 selectors).
- hypr-nav: adds resize + scroll kinds; Super+wheel travels along the focused
  strip (column / tape) and falls back to workspace switch elsewhere.
- Super+Alt arrows/hjkl route resize through the layout; Super+rightclick
  auto-floats then resizes ONLY in columns (no pointer-resize hook for custom
  layouts) and is a plain resize everywhere else.
- Reload-safety: hyprctl reload dropped per-ws layout rules + Lua state,
  silently reverting columns workspaces to scrolling. init.lua now replays
  layouts-state.json (M.restore) and columns.lua re-seeds cols/rows/orient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:49:28 +02:00
Amir Alexander Abdelbaki 4c747221f2 style(astal-menu): unfullscreen glyphs, animated takeover, CyberQueer TUIs
- Expand/collapse controls now use consistent fullscreen ()/unfullscreen ()
  glyphs: the quad "Back" and the taskbar-panel "Back" became  buttons (matching
  the appdrawer's - toggle).
- The taskbar panel takeover now plays the same bouncy quad-pop scale as the quads
  when it expands, so every expansion in the menu is animated.
- monitor-manager (curses) and timer-pick (bash TUI) now use the CyberQueer accent
  palette (red #E40046 + violet #5018dd, true-colour with ANSI fallback) instead of
  cyan/green, matching the rest of the config.
- Added a .stepper-value style for the Columns count stepper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 13:43:11 +02:00
Amir Alexander Abdelbaki 48e32e7eea fix(greetd): pin greeter GPU + software cursors + single-output centering
Diagnosed the intermittent "login won't take input / frozen cursor" on this
dual-AMD-GPU box: cage (wlroots) could pick the display-less iGPU, and amdgpu
hardware cursors can freeze. regreet-session.sh now:
- pins WLR_DRM_DEVICES to the first DRM card with a connected output (portable,
  evaluated live — no hard-coded PCI path),
- sets WLR_NO_HARDWARE_CURSORS=1 (fixes the stuck cursor),
- runs cage with `-m last` so the login card centres on a single monitor instead
  of the default "extend", which stretched the canvas across all three monitors
  and left the card floating in the middle of the span.

sysupdate.sh now deploys /etc/greetd config (config/regreet/session, not PAM) when
greetd is the active greeter, using the single primed sudo credential.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 13:37:22 +02:00
Amir Alexander Abdelbaki de666acac7 feat(astal-menu): Columns count stepper + expanded taskbar is one module
- Columns layout publishes per-workspace column/row counts to columns-state.json
  and handles a direct "cols +1/-1/N" message. The menu's Columns page now shows a
  "Columns  (−)[N](+)" stepper that reads the live count and drives it.
- Expanding the taskbar now fully collapses the strip (header included) via its
  revealer, so the panel that covers the quads reads as a single module instead of a
  thin strip plus a detached card below it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 13:31:45 +02:00
Amir Alexander Abdelbaki 9d1ec69a11 feat: hyprshutdown power binds, 76% scrolling windows, single-sudo sysupdate
- hyprshutdown wired into the graceful power flow (Super+Shift+O logout,
  Super+Ctrl+O power off, Super+Ctrl+Shift+O reboot) so apps close cleanly with a
  UI before Hyprland exits, instead of a hard systemctl call. Added to the hyprlua
  package list.
- Scrolling layout: default window is now 76% of the monitor along the scroll axis
  (column_width 0.5 -> 0.76).
- sysupdate.sh: primes sudo once at startup and keeps the credential alive for the
  whole run, so no later step re-prompts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 13:26:48 +02:00
Amir Alexander Abdelbaki 48505a5726 feat(hyprlua): real independent-pan "columns" layout (custom Lua layout)
Replaces the scrolling-preset "columns" with a genuine custom tiling layout
(hl.layout.register "lua:columns"): a row of columns where each column is an
INDEPENDENTLY panning vertical strip. Moving focus up/down inside a column pans
only that column to keep the focus in view; the other columns stay frozen — the
combination-lock behaviour that the built-in scrolling engine (single shared tape
offset) structurally can't do.

- columns.lua: recalculate places every window box; per-workspace state holds each
  window's column assignment and each column's pan offset. layout_msg handles the
  focus/move/count/orient commands. New windows fill the least-populated column;
  changing the column count reflows evenly.
- init.lua: for Lua layouts (lua = true), orientation is sent via layout_msg
  ("orient h|v") instead of the global scrolling:direction option.
- hypr-nav: forwards focus/move/count for the columns layout to hl.dsp.layout(...);
  the built-in scrolling path is unchanged.
- binds.lua: Super+Ctrl+Alt+H/J/K/L manage the column / windows-per-view count
  (axes follow the workspace's horizontal/vertical orientation).

Menu dropdown for "Columns" now selects orientation (Horizontal / Vertical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 10:08:34 +02:00
Amir Alexander Abdelbaki 6631405570 feat(hyprlua): taskbar quad-takeover, columns layout, screen-absolute nav
astal-menu: the taskbar ⤢ toggle now collapses the compact strip and mounts
its workspace/window panel as a takeover overlay covering the 2x2 quads (with
a Back button), instead of a popover/inline reveal. QuadGrid gains a generic
takeover overlay; the menu window coordinates show/hide and resets on close.

hypr: new "Columns" layout (scrolling backend presented as independent columns)
with a menu dropdown to pick orientation — "Left / Right" or "Up / Down" — via a
new dir_labels manifest field. The layout menu now tracks direction dropdowns and
fit switches per-layout, so a second directional layout no longer clobbers the
first.

binds/scripts: focus & window-move keys (hjkl + arrows) route through a new
hypr-nav helper so they stay screen-absolute in the scrolling/columns layouts
(the layout otherwise resolves a perpendicular direction onto "scroll the tape",
rotating the keys with scrolling:direction). Adds explicit cross-monitor binds:
Super+,/. focus prev/next monitor, Super+Shift+,/. move the window there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 09:28:41 +02:00
Amir Alexander Abdelbaki 4196b84ad3 feat(astal-menu): tabbed workspace-layout selector
Replace the layout dropdown with a tab bar (one tab per layout — Dwindle · Master ·
Monocle · Scrolling, from the manifest) over a Gtk.Stack of per-layout options that
switches with the tab. Selecting a tab applies that layout to the currently focused
workspace; the scrolling page carries its options — a "direction" dropdown and a
"center-focused" switch (focus_fit_method). The active tab reflects the workspace's
real (per-ws) layout via the layouts state file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP
2026-07-06 11:59:47 +02:00
Amir Alexander Abdelbaki fa097da436 feat(hyprlua): per-file layout registry (default scrolling↓); menu layout+window controls
- hypr/layouts/: drop-in layout registry. init.lua auto-discovers layouts/<name>.lua
  (each self-describing) and exposes the global `layouts` table: set_default (picked
  in hyprland.lua — now scrolling, direction down), set(ws,name,dir) for live
  per-workspace switching (verified it re-tiles the active ws), set_fit(0/1). Writes
  a manifest + per-ws state for the menu. Ships scrolling/dwindle/master/monocle.
- Scrolling default: column_width 0.5 (active column < viewport so prev/next stay
  clickable), focus_fit_method 1 (centered follow), fullscreen_on_one_column.
- astal-menu taskbar pop-open: workspace layout selector + direction + a "Centered"
  toggle (focus_fit_method), and a per-window list — [icon+title → jump][⇤ here →
  pull to current workspace], keeping jump-to-window intact.
- eww bar menu buttons open the menu explicitly from the top (menu-toggle.sh toggle top).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP
2026-07-06 11:52:46 +02:00
Amir Alexander Abdelbaki 2ea9c664a5 added nohup.out to gitignore 2026-07-06 10:57:54 +02:00
Amir Alexander Abdelbaki 4fe6181a93 made the hw-stats bar taller, so the close button doesn't touch any borders 2026-07-06 10:12:13 +02:00
346 changed files with 32765 additions and 757 deletions

1
.gitignore vendored
View File

@ -46,6 +46,7 @@ lazy-lock.json
*.vhdx
*.deb
*.rpm
*/nohup.out
# Logs
**/*.log

View File

@ -36,11 +36,17 @@ USER_FILES=(
"desktopenvs/hyprland/kitty/current-theme.conf|$HOME/.config/kitty/current-theme.conf"
"desktopenvs/hyprland/kitty/kitty.conf|$HOME/.config/kitty/kitty.conf"
"desktopenvs/hyprland/kitty/themes/cyberqueer.conf|$HOME/.config/kitty/themes/cyberqueer.conf"
"desktopenvs/hyprdrive/alacritty/alacritty.toml|$HOME/.config/alacritty/alacritty.toml"
"desktopenvs/hyprdrive/ghostty/config|$HOME/.config/ghostty/config"
"desktopenvs/hyprland/waybar/style.css|$HOME/.config/waybar/style.css"
"desktopenvs/hyprland/wofi/style.css|$HOME/.config/wofi/style.css"
"desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css"
"desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css"
"desktopenvs/hyprlua/astal-menu/style/_colors.css|$HOME/.config/astal-menu/style/_colors.css"
"desktopenvs/hyprdrive/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css"
"desktopenvs/hyprdrive/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css"
"desktopenvs/hyprdrive/astro-menu/style/_colors.css|$HOME/.config/astro-menu/style/_colors.css"
"desktopenvs/hyprdrive/station-bar/style/_colors.css|$HOME/.config/station-bar/style/_colors.css"
"desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
"desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"

View File

@ -0,0 +1,27 @@
{
"backgroundColor": "#1a1a1a",
"fontColor": "#d6abab",
"flickering": 0.2,
"horizontalSync": 0.3762,
"staticNoise": 0.0955,
"chromaColor": 1,
"saturationColor": 0.4983,
"screenCurvature": 0.1716,
"glowingLine": 0.1476,
"burnIn": 0.0754,
"bloom": 0.5017,
"rasterization": 1,
"jitter": 0.2368,
"rbgShift": 0.0224,
"brightness": 0.5014,
"contrast": 0.85,
"ambientLight": 0.0835,
"windowOpacity": 0.2561,
"fontName": "TERMINUS_SCALED",
"fontWidth": 1,
"margin": 0.1,
"blinkingCursor": true,
"frameMargin": 0,
"name": "Cyberqeer",
"version": 2
}

View File

@ -0,0 +1,632 @@
{
"autoUpdate": true,
"autoUpdateNotification": true,
"useQuickCss": true,
"themeLinks": [],
"eagerPatches": false,
"enabledThemes": [
"cyberqueer.theme.css"
],
"enableReactDevtools": true,
"frameless": false,
"transparent": false,
"winCtrlQ": false,
"disableMinSize": false,
"winNativeTitleBar": false,
"plugins": {
"ChatInputButtonAPI": {
"enabled": true
},
"CommandsAPI": {
"enabled": true
},
"DynamicImageModalAPI": {
"enabled": false
},
"MemberListDecoratorsAPI": {
"enabled": true
},
"MessageAccessoriesAPI": {
"enabled": true
},
"MessageDecorationsAPI": {
"enabled": true
},
"MessageEventsAPI": {
"enabled": false
},
"MessagePopoverAPI": {
"enabled": true
},
"MessageUpdaterAPI": {
"enabled": true
},
"ServerListAPI": {
"enabled": true
},
"UserSettingsAPI": {
"enabled": true
},
"AccountPanelServerProfile": {
"enabled": true
},
"AlwaysAnimate": {
"enabled": true
},
"AlwaysExpandRoles": {
"enabled": true
},
"AlwaysTrust": {
"enabled": true,
"domain": true,
"file": true
},
"AnonymiseFileNames": {
"enabled": true
},
"AppleMusicRichPresence": {
"enabled": false
},
"BANger": {
"enabled": false
},
"BetterFolders": {
"enabled": false,
"sidebar": true,
"showFolderIcon": 1,
"keepIcons": false,
"closeAllHomeButton": false,
"closeAllFolders": false,
"forceOpen": false,
"sidebarAnim": true,
"closeOthers": false
},
"BetterGifAltText": {
"enabled": true
},
"BetterGifPicker": {
"enabled": true
},
"BetterNotesBox": {
"enabled": true,
"hide": false
},
"BetterRoleContext": {
"enabled": true
},
"BetterRoleDot": {
"enabled": true,
"bothStyles": false,
"copyRoleColorInProfilePopout": false
},
"BetterSessions": {
"enabled": true,
"backgroundCheck": false
},
"BetterSettings": {
"enabled": true,
"disableFade": true,
"eagerLoad": true,
"organizeMenu": true
},
"BetterUploadButton": {
"enabled": true
},
"BiggerStreamPreview": {
"enabled": true
},
"BlurNSFW": {
"enabled": false
},
"CallTimer": {
"enabled": true
},
"ClearURLs": {
"enabled": false
},
"ClientTheme": {
"enabled": false
},
"ColorSighted": {
"enabled": false
},
"ConsoleJanitor": {
"enabled": false
},
"ConsoleShortcuts": {
"enabled": false
},
"CopyEmojiMarkdown": {
"enabled": false
},
"CopyFileContents": {
"enabled": false
},
"CopyUserURLs": {
"enabled": true
},
"CrashHandler": {
"enabled": true
},
"CtrlEnterSend": {
"enabled": false
},
"CustomRPC": {
"enabled": true,
"type": 0
},
"CustomIdle": {
"enabled": false
},
"Dearrow": {
"enabled": false
},
"Decor": {
"enabled": false
},
"DisableCallIdle": {
"enabled": false
},
"DontRoundMyTimestamps": {
"enabled": false
},
"EmoteCloner": {
"enabled": false
},
"Experiments": {
"enabled": false
},
"F8Break": {
"enabled": false
},
"FakeNitro": {
"enabled": false
},
"FakeProfileThemes": {
"enabled": false
},
"FavoriteEmojiFirst": {
"enabled": false
},
"FavoriteGifSearch": {
"enabled": false
},
"FixCodeblockGap": {
"enabled": true
},
"FixImagesQuality": {
"enabled": true
},
"FixSpotifyEmbeds": {
"enabled": true
},
"FixYoutubeEmbeds": {
"enabled": true
},
"ForceOwnerCrown": {
"enabled": true
},
"FriendInvites": {
"enabled": false
},
"FriendsSince": {
"enabled": true
},
"FullSearchContext": {
"enabled": false
},
"FullUserInChatbox": {
"enabled": false
},
"GameActivityToggle": {
"enabled": false
},
"GifPaste": {
"enabled": false
},
"GreetStickerPicker": {
"enabled": false
},
"HideMedia": {
"enabled": false
},
"iLoveSpam": {
"enabled": false
},
"IgnoreActivities": {
"enabled": false
},
"ImageLink": {
"enabled": false
},
"ImageZoom": {
"enabled": false
},
"ImplicitRelationships": {
"enabled": true,
"sortByAffinity": true
},
"InvisibleChat": {
"enabled": true
},
"IrcColors": {
"enabled": false,
"memberListColors": true,
"lightness": 70,
"applyColorOnlyInDms": false,
"applyColorOnlyToUsersWithoutColor": false
},
"KeepCurrentChannel": {
"enabled": false
},
"LastFMRichPresence": {
"enabled": false
},
"LoadingQuotes": {
"enabled": true,
"replaceEvents": true,
"enableDiscordPresetQuotes": false,
"additionalQuotes": "",
"additionalQuotesDelimiter": "|",
"enablePluginPresetQuotes": true
},
"MemberCount": {
"enabled": false
},
"MentionAvatars": {
"enabled": false
},
"MessageClickActions": {
"enabled": false
},
"MessageLatency": {
"enabled": false
},
"MessageLinkEmbeds": {
"enabled": false
},
"MessageLogger": {
"enabled": false
},
"MessageTags": {
"enabled": false
},
"MoreCommands": {
"enabled": false
},
"MoreKaomoji": {
"enabled": false
},
"Moyai": {
"enabled": false
},
"MutualGroupDMs": {
"enabled": false
},
"NewGuildSettings": {
"enabled": false
},
"NoBlockedMessages": {
"enabled": false
},
"NoDevtoolsWarning": {
"enabled": false
},
"NoF1": {
"enabled": false
},
"NoMaskedUrlPaste": {
"enabled": false
},
"NoMosaic": {
"enabled": false
},
"NoOnboardingDelay": {
"enabled": false
},
"NoPendingCount": {
"enabled": false
},
"NoProfileThemes": {
"enabled": false
},
"NoRPC": {
"enabled": false
},
"NoReplyMention": {
"enabled": false
},
"NoServerEmojis": {
"enabled": true
},
"NoSystemBadge": {
"enabled": false
},
"NoTypingAnimation": {
"enabled": false
},
"NoUnblockToJump": {
"enabled": false
},
"NormalizeMessageLinks": {
"enabled": false
},
"NotificationVolume": {
"enabled": false
},
"NSFWGateBypass": {
"enabled": false
},
"OnePingPerDM": {
"enabled": false
},
"oneko": {
"enabled": false
},
"OpenInApp": {
"enabled": false
},
"OverrideForumDefaults": {
"enabled": false
},
"PartyMode": {
"enabled": false
},
"PauseInvitesForever": {
"enabled": false
},
"PermissionFreeWill": {
"enabled": false
},
"PermissionsViewer": {
"enabled": false
},
"petpet": {
"enabled": false
},
"PictureInPicture": {
"enabled": false
},
"PinDMs": {
"enabled": false
},
"PlainFolderIcon": {
"enabled": true
},
"PlatformIndicators": {
"enabled": true,
"colorMobileIndicator": true,
"list": true,
"badges": true,
"messages": true
},
"PreviewMessage": {
"enabled": false
},
"QuickMention": {
"enabled": true
},
"QuickReply": {
"enabled": true
},
"ReactErrorDecoder": {
"enabled": false
},
"ReadAllNotificationsButton": {
"enabled": true
},
"RelationshipNotifier": {
"enabled": true,
"offlineRemovals": true,
"groups": true,
"servers": true,
"friends": true,
"friendRequestCancels": true
},
"ReplaceGoogleSearch": {
"enabled": false
},
"ReplyTimestamp": {
"enabled": false
},
"RevealAllSpoilers": {
"enabled": false
},
"ReverseImageSearch": {
"enabled": true
},
"ReviewDB": {
"enabled": true,
"notifyReviews": true
},
"RoleColorEverywhere": {
"enabled": true,
"chatMentions": true,
"memberList": true,
"voiceUsers": true,
"reactorsList": true,
"pollResults": true,
"colorChatMessages": false
},
"SecretRingToneEnabler": {
"enabled": false
},
"Summaries": {
"enabled": false
},
"SendTimestamps": {
"enabled": false
},
"ServerInfo": {
"enabled": false
},
"ServerListIndicators": {
"enabled": false
},
"ShikiCodeblocks": {
"enabled": false
},
"ShowAllMessageButtons": {
"enabled": false
},
"ShowConnections": {
"enabled": false
},
"ShowHiddenChannels": {
"enabled": false
},
"ShowHiddenThings": {
"enabled": false
},
"ShowMeYourName": {
"enabled": false
},
"ShowTimeoutDuration": {
"enabled": false
},
"SilentMessageToggle": {
"enabled": false
},
"SilentTyping": {
"enabled": false
},
"SortFriendRequests": {
"enabled": false
},
"SpotifyControls": {
"enabled": true,
"hoverControls": false
},
"SpotifyCrack": {
"enabled": false
},
"SpotifyShareCommands": {
"enabled": false
},
"StartupTimings": {
"enabled": false
},
"StickerPaste": {
"enabled": false
},
"StreamerModeOnStream": {
"enabled": false
},
"SuperReactionTweaks": {
"enabled": false
},
"TextReplace": {
"enabled": false
},
"ThemeAttributes": {
"enabled": false
},
"Translate": {
"enabled": false
},
"TypingIndicator": {
"enabled": false
},
"TypingTweaks": {
"enabled": true,
"alternativeFormatting": true
},
"Unindent": {
"enabled": false
},
"UnlockedAvatarZoom": {
"enabled": false
},
"UnsuppressEmbeds": {
"enabled": false
},
"UserMessagesPronouns": {
"enabled": true,
"showSelf": true,
"pronounsFormat": "LOWERCASE"
},
"UserVoiceShow": {
"enabled": false
},
"USRBG": {
"enabled": true,
"voiceBackground": true,
"nitroFirst": true
},
"ValidReply": {
"enabled": true
},
"ValidUser": {
"enabled": true
},
"VoiceChatDoubleClick": {
"enabled": false
},
"VcNarrator": {
"enabled": false
},
"VencordToolbox": {
"enabled": true
},
"ViewIcons": {
"enabled": false
},
"ViewRaw": {
"enabled": false
},
"VoiceDownload": {
"enabled": true
},
"VoiceMessages": {
"enabled": true
},
"VolumeBooster": {
"enabled": false
},
"WhoReacted": {
"enabled": true
},
"XSOverlay": {
"enabled": true,
"webSocketPort": 42070,
"botNotifications": false,
"pingColor": "#7289da",
"channelPingColor": "#8a2be2",
"serverNotifications": true,
"lengthBasedTimeout": true,
"opacity": 1,
"volume": 0.2,
"soundPath": "default",
"preferUDP": false
},
"YoutubeAdblock": {
"enabled": true
},
"BadgeAPI": {
"enabled": true
},
"NoTrack": {
"enabled": true,
"disableAnalytics": true
},
"Settings": {
"enabled": true,
"settingsLocation": "aboveNitro"
},
"SupportHelper": {
"enabled": true
}
},
"notifications": {
"timeout": 5000,
"position": "bottom-right",
"useNative": "not-focused",
"logLimit": 50
},
"cloud": {
"authenticated": false,
"url": "https://api.vencord.dev/",
"settingsSync": false,
"settingsSyncVersion": 1744550478260
}
}

View File

@ -0,0 +1,89 @@
/**
* @name system24
* @description A tui-style discord theme.
* @author refact0r
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
/* @import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
*{
font-family: 'Agave Nerd Font Mono';
}
/* customize things here */
:root {
font: 'Agave Nerd Font'; /* UI font name. it must be installed on your system. */
letter-spacing: 0; /* decreases letter spacing for better readability. */
font-weight: 400; /* UI font weight. */
--label-font-weight: 400; /* font weight for panel labels. */
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
--pad: 20px; /* padding between panels. */
--txt-pad: 5px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: #1a1a1a; /* main background color. */
--bg-1: #1a1a1a; /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: #1a1a1a; /* color of neutral buttons. */
--bg-3: #641a1a; /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: #5018dd;/* color of hovered elements. */
--active: #E40046;/* color of elements when clicked. */
--selected: #5018dd; /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: #d6abab; /* color of bright/white text. */
--txt-1: #d6abab;/* main text color. */
--txt-2: #5018dd;/* color of secondary text like channel list. */
--txt-3: #d6abab;/* color of muted text. */
/* accent colors */
--acc-1: #E40046; /* main accent color. */
--acc-0: #5018dd; /* color of accent buttons when hovered. */
--acc-2: #5018dd; /* color of accent buttons when clicked. */
/* borders */
--border-width: 19px; /* panel border thickness. */
--border-color: #5018dd; /* panel border color. */
--border-hover-color: #E40046; /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: #5018dd; /* color of mention text. */
--mention-bg: #E40046; /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(73% 0.12 0);
--pink-1: oklch(63% 0.12 0);
--pink-2: oklch(53% 0.12 0);
--purple: oklch(73% 0.12 300);
--purple-1: oklch(63% 0.12 300);
--purple-2: oklch(53% 0.12 300);
--cyan: oklch(73% 0.12 200);
--yellow: oklch(78% 0.12 80);
--green: hsl(141, 76%, 48%);
--green-1: hsl(141, 73%, 42%);
--green-2: hsl(141, 73%, 36%);
}
/* .container_c48ade{
background-color: transparent;
} */

View File

@ -0,0 +1,6 @@
{
"tabWidth": 4,
"singleQuote": true,
"useTabs": true,
"printWidth": 200
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 refact0r
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,72 @@
> [!WARNING]
> recently discord has rolled out a new UI refresh update, which breaks the theme. for now, development is paused. if and when i find the motivation, the theme will be rewritten for the new UI. until then, please check out my other theme, [midnight](https://github.com/refact0r/midnight-discord).
![preivew](/assets/preview.png)
# system24
a tui-style discord theme, inspired by [spicetify text theme](https://github.com/spicetify/spicetify-themes/tree/master/text).
![screenshot](/assets/screenshot3.png)
## discord server
need help? want to get notified about updates? have feedback? join <https://discord.gg/nz87hXyvcy>
## install
### vencord/betterdiscord (or client with theme folder)
1. go the [default theme file](https://github.com/refact0r/system24/blob/main/theme/system24.theme.css) or choose a flavor from the options below.
2. click the download button in the top right.
3. move the downloaded file into your theme folder. (there should be a button to open the theme folder in theme settings)
4. you may need to enable the theme in your theme settings by clicking a toggle switch.
5. (optional) customize the theme by editing the variables in `system24.theme.css`.
### install through link
1. add `https://refact0r.github.io/system24/theme/system24.theme.css` to your theme import links.
## flavors
customized variants of the theme.
### spotify-text
matches the spotify/spicetify ["text" theme](https://github.com/spicetify/spicetify-themes/tree/master/text). thinner borders and different font weights from the default system24.
[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/spotify-text.theme.css)
![image](https://github.com/user-attachments/assets/f459f9fc-7b6c-4e9d-a8c9-4adefb0be417)
### catppuccin mocha
based on the mocha palette of [catppuccin](https://catppuccin.com/palette).
[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/catppuccin-mocha.theme.css)
![image](https://github.com/user-attachments/assets/994d35f6-90d8-4f99-bfce-c3df13f415d4)
### gruvbox material
based on the vim theme [gruvbox material](https://github.com/sainnhe/gruvbox-material).
[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/gruvbox-material.theme.css)
![image](/assets/gruv-material.png)
### monochrome
A monochrome version of the default theme with mostly neutral colors.
[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/monochrome.theme.css)
![image](/assets/monochrome.png)
### rose pine
based on the main palette of [rose pine](https://rosepinetheme.com/palette/).
[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/rosepine.theme.css)
![image](/assets/rosepine.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

View File

@ -0,0 +1,56 @@
function benchmarkSelectors(cssString) {
function extractSelectors(cssString) {
cssString = cssString.replace(/\/\*[\s\S]*?\*\//g, '') // remove comments
// remove nested brackets
let result = ''
let depth = 0
for (let char of cssString) {
if (char === '{') {
depth++
} else if (char === '}') {
depth--
} else if (depth === 0) {
result += char
}
}
cssString = result
let selectors = cssString
.split(/,(?![^(]*\))|[\n\r]+/) // split by commas or newline
.map((s) => s.trim().replace(/::(?:before|after)/, '')) // trim pseudo-elements
.filter(Boolean) // remove empty strings
return selectors
}
const benchmarkSelector = (selector) => {
const start = performance.now()
let matches = 0
for (let i = 0; i < 1000; i++) {
matches = document.querySelectorAll(selector).length
}
return [(performance.now() - start) / 1000, matches]
}
return extractSelectors(cssString)
.map((selector) => {
try {
const [time, matches] = benchmarkSelector(selector)
return { selector, time, matches }
} catch (error) {
console.error(
`Error benchmarking "${selector}": ${error.message}`
)
return null
}
})
.filter(Boolean)
.sort((a, b) => b.time - a.time)
.map(
({ selector, time, matches }) =>
`"${selector}",${time.toFixed(6)},${matches}`
)
.join('\n')
.replace(/^/, 'Selector,Time (ms),Matches\n')
}

View File

@ -0,0 +1,195 @@
Selector,Time (ms),Matches
"[class^='chatContent_']:has([class^='messagesWrapper_']:hover)",0.256000,0
"[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_']",0.252800,0
"[class^=chat_] > [class*=container_]",0.191900,0
"[class*=embedFull_]",0.191100,1
"[class*=lookFilled_][class*=colorPrimary_]",0.187400,0
"#app-mount [class*=lookLink_][class*=colorPrimary_]",0.186500,0
"#app-mount [class*=controlIcon_]",0.180000,0
"[class^=container_] > [class^=wrapper_]",0.134800,1
"[class^=container_] > [class^=wrapper_]",0.133600,1
"[class^=container_] > [class^=wrapper_]",0.132400,1
"[class^=message_][class*=replying_]",0.132300,0
"[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_]",0.131900,1
"[class^=panels_] > [class^=wrapper_] > [class^=container]",0.131600,0
"[class^=sidebar_] > [class^=container_]",0.131200,1
"[class^=container_] > [class^=wrapper_]",0.131200,1
"[class^='form_'] > [class^='wrapper_']",0.131000,0
"[class^=chat_] > [class^=content_] > [class^=container_]",0.130700,1
"[class^=message_][class*=mentioned_]",0.130200,0
"[class^=container_] > [class^=wrapper_]",0.130000,1
"[class^=panels_] > [class^=container_]",0.129600,1
"#app-mount [class^=sidebar_] > [class^=container_]",0.128500,1
"[class^='sidebar_'] > [class^='container_']",0.128500,1
"[class^=member_]",0.126500,28
"[class^=panels_]",0.126500,1
"[class^='membersWrap_']",0.126400,1
"[class^=navButton_][class*=navButtonActive_]",0.126000,0
"[class^='sidebar_'] > [class^='container_']",0.125800,1
"[class^='dot_'][class*='maskSecondary_']",0.125800,0
"[class^='membersWrap_']",0.125600,1
"[class^=panels_]",0.125600,1
"[class^=sidebar_]",0.125500,1
"[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_]",0.125300,1
"[class^=privateChannels_] > [class^=scroller_]",0.125200,0
"[class^=childWrapper_]",0.125200,9
"#app-mount [class^=partyStatus_] [class^=anchor_]",0.125200,0
"[class^=iconBadge_]:not([class*=isCurrentUserConnected_])",0.125200,1
"[class^='toolbar_'] > [class^='search_']",0.125100,1
"[class^=container_] > [class^=base_]",0.125100,1
"[class^='panels_']",0.125000,1
"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.124900,0
"[class^=chatContent_] > [class^=messagesWrapper_]",0.124800,1
"[class^='form_'] [class^='channelTextArea_']",0.124700,1
"#app-mount [class^=sidebar_]",0.124700,1
"[class^=members_]",0.124500,1
"[class*=bannerVisible_] [class^=headerContent_]",0.124500,0
"[class^=form_] [class^=channelTextArea_]",0.124500,1
"[class^='typing_']",0.124500,0
"[class^='upperBadge_']",0.124500,1
"[class^=tabBody_]",0.124400,0
"[class^=resizeHandle_]",0.124200,0
"[class^='userPanelInner_']",0.124200,0
"[class^=attachedBars_]",0.124100,0
"[class^=container_] > [class^=base_]",0.124100,1
"[class^=gifFavoriteButton_]",0.124000,0
"[class^=membersWrap_]",0.124000,1
"[class^=panels_] > [class^=panel_]",0.124000,0
"[class^='searchResultsWrap_']",0.124000,0
"[class^=profilePanel_] > [class^=userPanelOuter_]",0.123900,0
"[class^=chat_]",0.123900,1
"[class^=sidebar_]",0.123900,1
"[class^='form_'] [class^='channelTextArea_']",0.123900,1
"[class^=panels_] > [class^=panel_]",0.123800,0
"[class^='membersWrap_']",0.123800,1
"[class^='nowPlayingColumn_']",0.123800,0
"[class^=replyBar_]",0.123700,0
"[class^=iconWrapper_]",0.123700,7
"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.123700,0
"[class^=searchResultsWrap_]",0.123700,0
"[class^='unreadPill_'][class*='endCap_']",0.123600,1
"[class^=overlayBottom_] > [class^=text-]",0.123600,0
"[class^=privateChannels_]",0.123600,0
"[class^=categoryText_]",0.123500,0
"[class^=profilePanel_]",0.123500,0
"[class^='searchResultsWrap_']",0.123400,0
"[class^='membersWrap_']",0.123400,1
"[class^='peopleListItem_']",0.123400,0
"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.123300,0
"[class^=tabBody_]",0.123200,0
"[class^=privateChannels_]",0.123200,0
"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.123200,0
"[class^='chatContent_'] > [class^='messagesWrapper_']",0.123100,1
"[class^='profilePanel_']",0.123100,0
"[class^=videoControls_]",0.123000,0
"[class^=sidebar_]",0.123000,1
"[class^=searchAnswer_]",0.122800,0
"[class^='profilePanel_']",0.122800,0
"[class^='peopleColumn_']",0.122800,0
"[class^='nowPlayingColumn_']",0.122800,0
"[class^='sectionDivider_']",0.122800,1
"[class^=notAppAsidePanel_] > [class^=layerContainer_]",0.122700,2
"[class^='privateChannels_']",0.122700,0
"[class^=privateChannels_]",0.122500,0
"[class^=container_] > [class^=base_]",0.122500,1
"[class^='lowerBadge_']",0.122400,0
"[class^=searchResultsWrap_]",0.122400,0
"[class^=threadSuggestionBar_]",0.122200,0
"[class^='chatContent_'] > [class^='messagesWrapper_']",0.122200,1
"[class^=searchResultsWrap_]",0.122200,0
"[class^=peopleColumn_]",0.122200,0
"[class^='nowPlayingColumn_']",0.122100,0
"[class^=searchFilter_]",0.122000,0
"[class^=privateChannels_]",0.122000,0
"[class^=nowPlayingColumn_]",0.122000,0
"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.121900,6
"[class^='container_'] > [class^='base_']",0.121900,1
"[class^='panels_']",0.121800,1
"[class^=friendRequestsButton_] [class^=numberBadge_]",0.121600,0
"[class^=peopleColumn_]",0.121400,0
"#app-mount [class^=partyStatus_]",0.121300,0
"[class^='form_'] [class^='channelTextArea_']",0.121100,1
"[class^='privateChannels_']",0.121100,0
"[class^='peopleColumn_']",0.120900,0
"#app-mount [class^='divider_']",0.120100,3
".wrapper_c51b4e:has(rect[fill='#593695'])",0.109200,0
".wrapper_c51b4e:has(rect[fill='#80848e'])",0.107600,0
".wrapper_c51b4e:has(rect[fill='#23a55a'])",0.096000,18
".wrapper_c51b4e:has(rect[fill='#f0b232'])",0.095800,4
".wrapper_c51b4e:has(rect[fill='#f23f43'])",0.089600,8
".wrapper_c51b4e:has(rect)",0.087900,30
"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.074800,0
"svg[style='color: rgb(88, 101, 242);']",0.062200,8
"#app-mount button path[fill='var(--white-500)']",0.061600,0
"[role='button'] path[fill='var(--white-500)']",0.060400,0
"[class^='mask_'] > svg",0.060400,2
"[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask",0.058600,48
"[class^='bannerSVGWrapper_'] > mask > circle",0.056000,1
"svg[class^=slider_] > rect[fill='white']",0.055400,0
"[class^='mask_'] > rect",0.055400,30
":root",0.055200,1
"[class^='mask_'] > foreignObject",0.055100,32
"[class^='svg_'] > foreignObject",0.054900,70
"[class^='mask_'] > circle",0.054300,1
":root",0.053800,1
":root",0.052300,1
"[class^=jumpToPresentBar_] > button",0.051700,0
"aside[class^='profilePanel_']",0.051000,0
".custom-profile-theme [class*=color_][class*=text_]",0.049900,0
".horizontal_bba380 > .flex_bba380",0.044800,0
".container_fb4810",0.044300,0
".container_a4d4d9",0.043900,1
".clipContainer_b11c5e",0.043900,0
".content_a7d72e",0.043600,1
".container_fc4f04",0.043600,1
".scroller_e2e187",0.043400,1
"html.theme-light",0.043400,0
".platform-osx [class^=container_] > [class^=wrapper_]",0.043300,0
".container_fc4f04",0.043300,1
".container_fc4f04",0.043300,1
".container_adcaac",0.043200,0
".attachButton_f298d4",0.043200,1
".wrapper_fea3ef",0.043200,1
".container_d1c246",0.043200,0
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.043100,0
".textArea_d0696b",0.043000,2
".chat_a7d72e",0.043000,1
".wordmarkWindows_a934d8 > svg",0.043000,1
".vc-spotify-time-right",0.042900,1
".theme-dark",0.042900,3
".container_c75f85",0.042800,0
".form_a7d72e",0.042800,1
".container_a6d69a",0.042800,0
".vc-spotify-time-left",0.042800,1
".form_a7d72e",0.042700,1
".container_b2ca13",0.042700,1
".container_a6d69a",0.042700,0
".container_c2739c",0.042700,0
".tabBody_c2739c",0.042700,0
".vc-spotify-time-right",0.042700,1
".container_e85446 > [class^='svg_'] > foreignObject > [class^='status_']",0.042600,0
".container_d1c246",0.042600,0
".panels_a4d4d9",0.042600,1
".container_adcaac",0.042600,0
".container_c2739c",0.042500,0
".scroller_fb4810",0.042500,0
".form_a7d72e",0.042400,1
".newMessagesBar_cf58b5",0.042400,1
".animatedContainer_fd6364",0.042400,1
".vc-spotify-time-left",0.042400,1
".scroller_fb4810",0.042300,0
".wordmarkWindows_a934d8",0.042300,1
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.042300,0
".grabber_c7a159",0.042200,1
".container_a6d69a",0.042100,0
".scroller_fb4810",0.042100,0
".scroller_fb4810",0.042100,0
".container_fc4f04",0.041600,1
"*",0.036500,6407
"#vc-spotify-progress-bar [class^='bar_']",0.000300,1
"#app-mount #vc-spotify-player",0.000200,1
"#vc-spotify-player",0.000200,1
"#vc-spotify-player",0.000200,1
"#vc-spotify-progress-bar",0.000200,1
"::-webkit-scrollbar-thumb",0.000200,0
"#vc-spotify-player",0.000100,1
1 Selector Time (ms) Matches
2 [class^='chatContent_']:has([class^='messagesWrapper_']:hover) 0.256000 0
3 [class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] 0.252800 0
4 [class^=chat_] > [class*=container_] 0.191900 0
5 [class*=embedFull_] 0.191100 1
6 [class*=lookFilled_][class*=colorPrimary_] 0.187400 0
7 #app-mount [class*=lookLink_][class*=colorPrimary_] 0.186500 0
8 #app-mount [class*=controlIcon_] 0.180000 0
9 [class^=container_] > [class^=wrapper_] 0.134800 1
10 [class^=container_] > [class^=wrapper_] 0.133600 1
11 [class^=container_] > [class^=wrapper_] 0.132400 1
12 [class^=message_][class*=replying_] 0.132300 0
13 [class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_] 0.131900 1
14 [class^=panels_] > [class^=wrapper_] > [class^=container] 0.131600 0
15 [class^=sidebar_] > [class^=container_] 0.131200 1
16 [class^=container_] > [class^=wrapper_] 0.131200 1
17 [class^='form_'] > [class^='wrapper_'] 0.131000 0
18 [class^=chat_] > [class^=content_] > [class^=container_] 0.130700 1
19 [class^=message_][class*=mentioned_] 0.130200 0
20 [class^=container_] > [class^=wrapper_] 0.130000 1
21 [class^=panels_] > [class^=container_] 0.129600 1
22 #app-mount [class^=sidebar_] > [class^=container_] 0.128500 1
23 [class^='sidebar_'] > [class^='container_'] 0.128500 1
24 [class^=member_] 0.126500 28
25 [class^=panels_] 0.126500 1
26 [class^='membersWrap_'] 0.126400 1
27 [class^=navButton_][class*=navButtonActive_] 0.126000 0
28 [class^='sidebar_'] > [class^='container_'] 0.125800 1
29 [class^='dot_'][class*='maskSecondary_'] 0.125800 0
30 [class^='membersWrap_'] 0.125600 1
31 [class^=panels_] 0.125600 1
32 [class^=sidebar_] 0.125500 1
33 [class^=centerContent_] > [class^=wrapper_] > [class^=anchor_] 0.125300 1
34 [class^=privateChannels_] > [class^=scroller_] 0.125200 0
35 [class^=childWrapper_] 0.125200 9
36 #app-mount [class^=partyStatus_] [class^=anchor_] 0.125200 0
37 [class^=iconBadge_]:not([class*=isCurrentUserConnected_]) 0.125200 1
38 [class^='toolbar_'] > [class^='search_'] 0.125100 1
39 [class^=container_] > [class^=base_] 0.125100 1
40 [class^='panels_'] 0.125000 1
41 [class^='status_'][style='background-color: rgb(242, 63, 67);'] 0.124900 0
42 [class^=chatContent_] > [class^=messagesWrapper_] 0.124800 1
43 [class^='form_'] [class^='channelTextArea_'] 0.124700 1
44 #app-mount [class^=sidebar_] 0.124700 1
45 [class^=members_] 0.124500 1
46 [class*=bannerVisible_] [class^=headerContent_] 0.124500 0
47 [class^=form_] [class^=channelTextArea_] 0.124500 1
48 [class^='typing_'] 0.124500 0
49 [class^='upperBadge_'] 0.124500 1
50 [class^=tabBody_] 0.124400 0
51 [class^=resizeHandle_] 0.124200 0
52 [class^='userPanelInner_'] 0.124200 0
53 [class^=attachedBars_] 0.124100 0
54 [class^=container_] > [class^=base_] 0.124100 1
55 [class^=gifFavoriteButton_] 0.124000 0
56 [class^=membersWrap_] 0.124000 1
57 [class^=panels_] > [class^=panel_] 0.124000 0
58 [class^='searchResultsWrap_'] 0.124000 0
59 [class^=profilePanel_] > [class^=userPanelOuter_] 0.123900 0
60 [class^=chat_] 0.123900 1
61 [class^=sidebar_] 0.123900 1
62 [class^='form_'] [class^='channelTextArea_'] 0.123900 1
63 [class^=panels_] > [class^=panel_] 0.123800 0
64 [class^='membersWrap_'] 0.123800 1
65 [class^='nowPlayingColumn_'] 0.123800 0
66 [class^=replyBar_] 0.123700 0
67 [class^=iconWrapper_] 0.123700 7
68 [class^='status_'][style='background-color: rgb(35, 165, 90);'] 0.123700 0
69 [class^=searchResultsWrap_] 0.123700 0
70 [class^='unreadPill_'][class*='endCap_'] 0.123600 1
71 [class^=overlayBottom_] > [class^=text-] 0.123600 0
72 [class^=privateChannels_] 0.123600 0
73 [class^=categoryText_] 0.123500 0
74 [class^=profilePanel_] 0.123500 0
75 [class^='searchResultsWrap_'] 0.123400 0
76 [class^='membersWrap_'] 0.123400 1
77 [class^='peopleListItem_'] 0.123400 0
78 [class^='status_'][style='background-color: rgb(240, 178, 50);'] 0.123300 0
79 [class^=tabBody_] 0.123200 0
80 [class^=privateChannels_] 0.123200 0
81 [class^='status_'][style='background-color: rgb(128, 132, 142);'] 0.123200 0
82 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.123100 1
83 [class^='profilePanel_'] 0.123100 0
84 [class^=videoControls_] 0.123000 0
85 [class^=sidebar_] 0.123000 1
86 [class^=searchAnswer_] 0.122800 0
87 [class^='profilePanel_'] 0.122800 0
88 [class^='peopleColumn_'] 0.122800 0
89 [class^='nowPlayingColumn_'] 0.122800 0
90 [class^='sectionDivider_'] 0.122800 1
91 [class^=notAppAsidePanel_] > [class^=layerContainer_] 0.122700 2
92 [class^='privateChannels_'] 0.122700 0
93 [class^=privateChannels_] 0.122500 0
94 [class^=container_] > [class^=base_] 0.122500 1
95 [class^='lowerBadge_'] 0.122400 0
96 [class^=searchResultsWrap_] 0.122400 0
97 [class^=threadSuggestionBar_] 0.122200 0
98 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.122200 1
99 [class^=searchResultsWrap_] 0.122200 0
100 [class^=peopleColumn_] 0.122200 0
101 [class^='nowPlayingColumn_'] 0.122100 0
102 [class^=searchFilter_] 0.122000 0
103 [class^=privateChannels_] 0.122000 0
104 [class^=nowPlayingColumn_] 0.122000 0
105 [class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);'] 0.121900 6
106 [class^='container_'] > [class^='base_'] 0.121900 1
107 [class^='panels_'] 0.121800 1
108 [class^=friendRequestsButton_] [class^=numberBadge_] 0.121600 0
109 [class^=peopleColumn_] 0.121400 0
110 #app-mount [class^=partyStatus_] 0.121300 0
111 [class^='form_'] [class^='channelTextArea_'] 0.121100 1
112 [class^='privateChannels_'] 0.121100 0
113 [class^='peopleColumn_'] 0.120900 0
114 #app-mount [class^='divider_'] 0.120100 3
115 .wrapper_c51b4e:has(rect[fill='#593695']) 0.109200 0
116 .wrapper_c51b4e:has(rect[fill='#80848e']) 0.107600 0
117 .wrapper_c51b4e:has(rect[fill='#23a55a']) 0.096000 18
118 .wrapper_c51b4e:has(rect[fill='#f0b232']) 0.095800 4
119 .wrapper_c51b4e:has(rect[fill='#f23f43']) 0.089600 8
120 .wrapper_c51b4e:has(rect) 0.087900 30
121 [style='opacity: 1; background-color: rgb(35, 165, 90);'] 0.074800 0
122 svg[style='color: rgb(88, 101, 242);'] 0.062200 8
123 #app-mount button path[fill='var(--white-500)'] 0.061600 0
124 [role='button'] path[fill='var(--white-500)'] 0.060400 0
125 [class^='mask_'] > svg 0.060400 2
126 [class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask 0.058600 48
127 [class^='bannerSVGWrapper_'] > mask > circle 0.056000 1
128 svg[class^=slider_] > rect[fill='white'] 0.055400 0
129 [class^='mask_'] > rect 0.055400 30
130 :root 0.055200 1
131 [class^='mask_'] > foreignObject 0.055100 32
132 [class^='svg_'] > foreignObject 0.054900 70
133 [class^='mask_'] > circle 0.054300 1
134 :root 0.053800 1
135 :root 0.052300 1
136 [class^=jumpToPresentBar_] > button 0.051700 0
137 aside[class^='profilePanel_'] 0.051000 0
138 .custom-profile-theme [class*=color_][class*=text_] 0.049900 0
139 .horizontal_bba380 > .flex_bba380 0.044800 0
140 .container_fb4810 0.044300 0
141 .container_a4d4d9 0.043900 1
142 .clipContainer_b11c5e 0.043900 0
143 .content_a7d72e 0.043600 1
144 .container_fc4f04 0.043600 1
145 .scroller_e2e187 0.043400 1
146 html.theme-light 0.043400 0
147 .platform-osx [class^=container_] > [class^=wrapper_] 0.043300 0
148 .container_fc4f04 0.043300 1
149 .container_fc4f04 0.043300 1
150 .container_adcaac 0.043200 0
151 .attachButton_f298d4 0.043200 1
152 .wrapper_fea3ef 0.043200 1
153 .container_d1c246 0.043200 0
154 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.043100 0
155 .textArea_d0696b 0.043000 2
156 .chat_a7d72e 0.043000 1
157 .wordmarkWindows_a934d8 > svg 0.043000 1
158 .vc-spotify-time-right 0.042900 1
159 .theme-dark 0.042900 3
160 .container_c75f85 0.042800 0
161 .form_a7d72e 0.042800 1
162 .container_a6d69a 0.042800 0
163 .vc-spotify-time-left 0.042800 1
164 .form_a7d72e 0.042700 1
165 .container_b2ca13 0.042700 1
166 .container_a6d69a 0.042700 0
167 .container_c2739c 0.042700 0
168 .tabBody_c2739c 0.042700 0
169 .vc-spotify-time-right 0.042700 1
170 .container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'] 0.042600 0
171 .container_d1c246 0.042600 0
172 .panels_a4d4d9 0.042600 1
173 .container_adcaac 0.042600 0
174 .container_c2739c 0.042500 0
175 .scroller_fb4810 0.042500 0
176 .form_a7d72e 0.042400 1
177 .newMessagesBar_cf58b5 0.042400 1
178 .animatedContainer_fd6364 0.042400 1
179 .vc-spotify-time-left 0.042400 1
180 .scroller_fb4810 0.042300 0
181 .wordmarkWindows_a934d8 0.042300 1
182 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.042300 0
183 .grabber_c7a159 0.042200 1
184 .container_a6d69a 0.042100 0
185 .scroller_fb4810 0.042100 0
186 .scroller_fb4810 0.042100 0
187 .container_fc4f04 0.041600 1
188 * 0.036500 6407
189 #vc-spotify-progress-bar [class^='bar_'] 0.000300 1
190 #app-mount #vc-spotify-player 0.000200 1
191 #vc-spotify-player 0.000200 1
192 #vc-spotify-player 0.000200 1
193 #vc-spotify-progress-bar 0.000200 1
194 ::-webkit-scrollbar-thumb 0.000200 0
195 #vc-spotify-player 0.000100 1

View File

@ -0,0 +1,232 @@
Selector,Time (ms),Matches,Total
[class*=buttonColor_],0.4697,0,38.4736
[class*=embedFull_],0.446,2,
[class^='chatContent_']:has([class^='typing_']:hover),0.4342,0,
#app-mount [class*=lookLink_][class*=colorPrimary_],0.4331,0,
[class*=lookFilled_][class*=colorPrimary_],0.4315,0,
[class^='chatContent_']:has([class^='messagesWrapper_']:hover),0.4189,0,
[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'],0.4146,0,
#app-mount [class*=controlIcon_],0.3911,0,
[class^=searchFilter_],0.3541,0,
[class^='newBadge_'],0.3385,0,
[class^=friendRequestsButton_] [class^=numberBadge_],0.3314,0,
[class^=chat_] > [class*=container_],0.32,0,
[class^=videoControls_],0.3099,0,
[class*=backgroundAccent_],0.3097,0,
[class^=searchAnswer_],0.3096,0,
#app-mount [class^=partyStatus_],0.3063,0,
[class^='newChannel_'],0.2995,0,
[class^=iconBadge_]:not([class*=isCurrentUserConnected_]),0.2892,0,
#app-mount [class^=partyStatus_] [class^=anchor_],0.2834,0,
[class^=childWrapper_],0.2817,15,
[class^=overlayBottom_] > [class^=text-],0.2786,0,
[class^=gifFavoriteButton_],0.2739,0,
[class^='form_'] [class^='channelTextArea_'],0.2703,1,
[class^='panels_'],0.2612,1,
[class^=panels_],0.2599,1,
[class^=sidebar_],0.2585,1,
[class^=button_][class*=bannerColor_],0.2576,0,
[class^='content_']>[class^='outer_'],0.2546,0,
[class^=channelTextArea] > [class^=scrollableContainer],0.2527,1,
[class^='content_']>[class^='outer_'],0.2511,0,
[class^=sidebar_] > [class^=container_],0.2439,1,
[class^='channelBottomBarArea_'],0.2423,1,
[class^=panels_] > [class^=wrapper_] > [class^=container],0.2386,0,
[class^='form_'] > [class^='wrapper_'],0.2365,0,
[class^=navButton_][class*=navButtonActive_],0.2356,0,
[class^=sidebar_],0.2315,1,
[class^=membersWrap_],0.2308,1,
[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_],0.2273,0,
[class^=categoryText_],0.2255,0,
[class^='form_'] > [class^='wrapper_'],0.2233,0,
[class^=sidebar_],0.2232,1,
[class^=members_],0.2213,1,
"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.2209,0,
[class^=peopleColumn_],0.2206,0,
[class^=nowPlayingColumn_],0.2206,0,
[class^=message_][class*=replying_],0.2181,0,
[class^=message_][class*=mentioned_],0.2175,5,
[class^=bottomControls_],0.2173,0,
[class*=bannerVisible_] [class^=headerContent_],0.2168,0,
[class^=container_] > [class^=base_],0.2148,1,
[class^=tabBody_],0.2147,0,
[class^=privateChannels_],0.2141,0,
[class^='sidebar_'] > [class^='container_'],0.2129,1,
[class^='dot_'][class*='maskSecondary_'],0.2125,0,
"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.2124,0,
[class^=privateChannels_],0.2119,0,
[class^='sidebar_'] > [class^='container_'],0.2112,1,
[class^='content_']>[class^='outer_'],0.2105,0,
[class^=channelBottomBarArea_],0.2103,1,
[class^='form_'] [class^='channelTextArea_'],0.2101,1,
[class^=privateChannels_],0.21,0,
[class^='channelAppLauncher_'],0.2099,1,
[class^=gradientBottom_],0.2099,0,
[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_],0.2099,0,
[class^='form_'] > [class^='typing_'],0.2095,0,
[class^='content_']>[class^='outer_']>[class^='inner_'],0.2094,0,
#app-mount [class^=sidebar_] > [class^=container_],0.2093,1,
[class^='nowPlayingColumn_'],0.209,0,
"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.2087,0,
[class^=peopleColumn_],0.2082,0,
[class^='nowPlayingColumn_'],0.2079,0,
[class^=session_],0.2078,0,
[class^='container_'] > [class^='base_'],0.2076,1,
[class^=searchResultsWrap_],0.2076,0,
[class^=panels_] > [class^=panel_],0.2076,0,
[class^='membersWrap_'],0.2076,1,
[class^=searchResultsWrap_],0.2073,0,
[class^=guildSeparator_],0.2072,2,
[class^=tableCell_],0.2072,0,
[class^=chat_] > [class^=content_] > [class^=container_],0.2071,1,
[class^='membersWrap_'],0.207,1,
[class^='sectionDivider_'],0.2069,0,
[class^='privateChannels_'],0.2067,0,
[class^='peopleColumn_'],0.2065,0,
[class^=attachedBars_],0.2061,0,
[class^=searchResultsWrap_],0.206,0,
[class^='callContainer_'],0.2055,0,
[class^='peopleColumn_'],0.2051,0,
[class^='notAppAsidePanel_'] > [class^='layerContainer_'],0.2049,2,
[class^='unreadPill_'][class*='endCap_'],0.2048,0,
[class^='panels_'],0.2047,1,
#app-mount [class^=sidebar_],0.2045,1,
[class^=chat_],0.2044,1,
[class^=panels_],0.2044,1,
[class^=panels_] > [class^=container_],0.2042,1,
[class^='chatContent_'] > [class^='messagesWrapper_'],0.2042,1,
[class^='nowPlayingColumn_'],0.2041,0,
[class^='membersWrap_'],0.2041,1,
[class^=container_] > [class^=base_],0.2039,1,
[class^='toolbar_'] > [class^='search_'],0.2034,1,
"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.2034,0,
[class^=tabBody_],0.2031,0,
[class^='lowerBadge_'],0.2031,0,
[class^=privateChannels_] > [class^=scroller_],0.2029,0,
[class^='searchResultsWrap_'],0.2028,0,
[class^=customizationSection_],0.2028,0,
[class^=chatContent_] > [class^=messagesWrapper_],0.2027,1,
[class^='upperBadge_'],0.2027,0,
"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.2025,0,
[class^=threadSuggestionBar_],0.2021,0,
[class^=panels_] > [class^=panel_],0.202,0,
[class^='searchResultsWrap_'],0.202,0,
[class^=columnHeading_],0.2016,0,
#app-mount [class^='divider_'],0.2016,2,
[class^=callContainer_],0.2014,0,
[class^='chatContent_'] > [class^='messagesWrapper_'],0.2011,1,
[class^='channelBottomBarArea_']:hover,0.201,0,
[class^=gradientTop_],0.2009,0,
[class^=iconWrapper_],0.2008,6,
[class^='guildSeparator_'],0.1998,2,
[class^='content_']>[class^='outer_'],0.1998,0,
[class^='privateChannels_'],0.1997,0,
[class^=container_] > [class^=base_],0.1996,1,
[class^=member_],0.1985,25,
[class^='content_']>[class^='outer_'],0.1984,0,
[class^='sectionDivider_'],0.198,0,
[class^=topControls_],0.198,0,
[class^='membersWrap_'],0.1974,1,
[class^=privateChannels_],0.1971,0,
[class^=content_] > [class^=resizeHandle_],0.1969,0,
[class^=replyBar_],0.196,0,
[class^='form_'] [class^='channelTextArea_'],0.1958,1,
[class^='divider_']:not(.isUnread_c2654d),0.194,2,
.wrapper_c51b4e:has(rect[fill='#f0b232']),0.1871,8,
[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask,0.1624,39,
:root,0.1432,1,
.wrapper_c51b4e:has(rect[fill='#80848e']),0.1408,1,
.wrapper_c51b4e:has(rect[fill='#593695']),0.1371,0,
.wrapper_c51b4e:has(rect[fill='#f23f43']),0.1349,5,
.wrapper_c51b4e:has(rect),0.1342,26,
.wrapper_c51b4e:has(rect[fill='#23a55a']),0.1332,12,
[class^='svg_'] > foreignObject,0.123,99,
[role='button'] path[fill='var(--white-500)'],0.1159,0,
"svg[style='color: rgb(88, 101, 242);']",0.1122,13,
[class^=jumpToPresentBar_] > button,0.1117,2,
"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.1095,0,
:root,0.1094,1,
[class^='mask_'] > rect,0.1049,26,
[class^='mask_'] > foreignObject,0.1041,26,
[class^='mask_'] > svg,0.1035,1,
#app-mount button path[fill='var(--white-500)'],0.1031,0,
[class^='bannerSVGWrapper_'] > mask > circle,0.102,0,
svg[class^=slider_] > rect[fill='white'],0.1016,0,
:root,0.0946,1,
[class^='mask_'] > circle,0.0937,0,
[class^=callContainer_] [class^=topControls_] section,0.0882,0,
.container_a6d69a,0.0797,0,
.guilds_a4d4d9,0.0771,1,
.container_adcaac,0.0769,0,
.vc-spotify-time-left,0.0766,0,
.vc-spotify-time-right,0.0746,0,
.guilds_a4d4d9,0.0733,1,
.form_a7d72e,0.0731,1,
.container_fc4f04,0.0723,1,
.container_c2739c,0.072,0,
.form_a7d72e,0.0719,1,
*,0.0714,6196,
.timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca,0.0713,13,
.container_d1c246,0.0713,0,
.vc-ranb-button,0.0709,0,
.container_b2ca13,0.0707,1,
.wrapper_fea3ef,0.0706,1,
.statusBubbleOuter_d0a252,0.0706,0,
.container_fc4f04,0.0704,1,
.scroller_bf550a,0.0703,0,
.guilds_a4d4d9,0.0703,1,
.attachButton_f298d4,0.0702,1,
.container_a6d69a,0.07,0,
.clipContainer_b11c5e,0.0699,0,
.chat_a7d72e,0.0699,1,
.form_a7d72e,0.0698,1,
.panels_a4d4d9,0.0698,1,
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0698,0,
.textArea_bdf0de,0.0697,1,
.content_a7d72e,0.0695,1,
.container_adcaac,0.0695,0,
.animatedContainer_fd6364,0.0693,1,
.guilds_a4d4d9,0.0692,1,
.container_fb4810,0.0692,0,
.separator_a0,0.0692,0,
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0692,0,
.scroller_fb4810,0.0691,0,
.wordmarkWindows_a934d8 > svg,0.0691,1,
.wordmarkWindows_a934d8,0.0688,1,
.container_fc4f04,0.0688,1,
.scroller_fb4810,0.0688,0,
.guilds_a4d4d9,0.0687,1,
.container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'],0.0686,0,
.newMessagesBar_cf58b5,0.0685,0,
.container_a4d4d9,0.0684,1,
.scroller_e2e187,0.0684,1,
.platform-osx .guilds_a4d4d9,0.0684,0,
.scroller_fb4810,0.0683,0,
.container_c75f85,0.0683,0,
.container_c2739c,0.0681,0,
html.theme-light,0.0681,0,
.container_a6d69a,0.0681,0,
.grabber_c7a159,0.068,0,
.scroller_fb4810,0.0679,0,
.theme-dark,0.0678,1,
.statusBubbleOuter_d0a252,0.0678,0,
.tabBody_c2739c,0.0677,0,
.container_d1c246,0.0676,0,
.horizontal_dc333f > .flex_dc333f,0.0673,0,
.separator_a0,0.0669,0,
.vc-spotify-time-left,0.0666,0,
.container_fc4f04,0.0662,1,
.vc-spotify-time-right,0.0661,0,
.wrapper_fea3ef.hidden_fea3ef,0.0661,0,
#vc-spotify-player,0.0004,0,
#vc-spotify-progress-bar [class^='bar_'],0.0004,0,
#vc-spotify-player,0.0004,0,
.overlay_f94206:after,0.0004,0,
#app-mount #vc-spotify-player,0.0003,0,
#vc-spotify-player,0.0003,0,
#vc-spotify-progress-bar,0.0003,0,
::placeholder,0.0003,0,
::selection,0.0003,0,
::-webkit-scrollbar-thumb,0.0003,0,
.overlay_f18fcb:after,0.0003,0,
#vc-spotify-player,0.0002,0,
1 Selector Time (ms) Matches Total
2 [class*=buttonColor_] 0.4697 0 38.4736
3 [class*=embedFull_] 0.446 2
4 [class^='chatContent_']:has([class^='typing_']:hover) 0.4342 0
5 #app-mount [class*=lookLink_][class*=colorPrimary_] 0.4331 0
6 [class*=lookFilled_][class*=colorPrimary_] 0.4315 0
7 [class^='chatContent_']:has([class^='messagesWrapper_']:hover) 0.4189 0
8 [class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'] 0.4146 0
9 #app-mount [class*=controlIcon_] 0.3911 0
10 [class^=searchFilter_] 0.3541 0
11 [class^='newBadge_'] 0.3385 0
12 [class^=friendRequestsButton_] [class^=numberBadge_] 0.3314 0
13 [class^=chat_] > [class*=container_] 0.32 0
14 [class^=videoControls_] 0.3099 0
15 [class*=backgroundAccent_] 0.3097 0
16 [class^=searchAnswer_] 0.3096 0
17 #app-mount [class^=partyStatus_] 0.3063 0
18 [class^='newChannel_'] 0.2995 0
19 [class^=iconBadge_]:not([class*=isCurrentUserConnected_]) 0.2892 0
20 #app-mount [class^=partyStatus_] [class^=anchor_] 0.2834 0
21 [class^=childWrapper_] 0.2817 15
22 [class^=overlayBottom_] > [class^=text-] 0.2786 0
23 [class^=gifFavoriteButton_] 0.2739 0
24 [class^='form_'] [class^='channelTextArea_'] 0.2703 1
25 [class^='panels_'] 0.2612 1
26 [class^=panels_] 0.2599 1
27 [class^=sidebar_] 0.2585 1
28 [class^=button_][class*=bannerColor_] 0.2576 0
29 [class^='content_']>[class^='outer_'] 0.2546 0
30 [class^=channelTextArea] > [class^=scrollableContainer] 0.2527 1
31 [class^='content_']>[class^='outer_'] 0.2511 0
32 [class^=sidebar_] > [class^=container_] 0.2439 1
33 [class^='channelBottomBarArea_'] 0.2423 1
34 [class^=panels_] > [class^=wrapper_] > [class^=container] 0.2386 0
35 [class^='form_'] > [class^='wrapper_'] 0.2365 0
36 [class^=navButton_][class*=navButtonActive_] 0.2356 0
37 [class^=sidebar_] 0.2315 1
38 [class^=membersWrap_] 0.2308 1
39 [class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_] 0.2273 0
40 [class^=categoryText_] 0.2255 0
41 [class^='form_'] > [class^='wrapper_'] 0.2233 0
42 [class^=sidebar_] 0.2232 1
43 [class^=members_] 0.2213 1
44 [class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);'] 0.2209 0
45 [class^=peopleColumn_] 0.2206 0
46 [class^=nowPlayingColumn_] 0.2206 0
47 [class^=message_][class*=replying_] 0.2181 0
48 [class^=message_][class*=mentioned_] 0.2175 5
49 [class^=bottomControls_] 0.2173 0
50 [class*=bannerVisible_] [class^=headerContent_] 0.2168 0
51 [class^=container_] > [class^=base_] 0.2148 1
52 [class^=tabBody_] 0.2147 0
53 [class^=privateChannels_] 0.2141 0
54 [class^='sidebar_'] > [class^='container_'] 0.2129 1
55 [class^='dot_'][class*='maskSecondary_'] 0.2125 0
56 [class^='status_'][style='background-color: rgb(35, 165, 90);'] 0.2124 0
57 [class^=privateChannels_] 0.2119 0
58 [class^='sidebar_'] > [class^='container_'] 0.2112 1
59 [class^='content_']>[class^='outer_'] 0.2105 0
60 [class^=channelBottomBarArea_] 0.2103 1
61 [class^='form_'] [class^='channelTextArea_'] 0.2101 1
62 [class^=privateChannels_] 0.21 0
63 [class^='channelAppLauncher_'] 0.2099 1
64 [class^=gradientBottom_] 0.2099 0
65 [class^=centerContent_] > [class^=wrapper_] > [class^=anchor_] 0.2099 0
66 [class^='form_'] > [class^='typing_'] 0.2095 0
67 [class^='content_']>[class^='outer_']>[class^='inner_'] 0.2094 0
68 #app-mount [class^=sidebar_] > [class^=container_] 0.2093 1
69 [class^='nowPlayingColumn_'] 0.209 0
70 [class^='status_'][style='background-color: rgb(128, 132, 142);'] 0.2087 0
71 [class^=peopleColumn_] 0.2082 0
72 [class^='nowPlayingColumn_'] 0.2079 0
73 [class^=session_] 0.2078 0
74 [class^='container_'] > [class^='base_'] 0.2076 1
75 [class^=searchResultsWrap_] 0.2076 0
76 [class^=panels_] > [class^=panel_] 0.2076 0
77 [class^='membersWrap_'] 0.2076 1
78 [class^=searchResultsWrap_] 0.2073 0
79 [class^=guildSeparator_] 0.2072 2
80 [class^=tableCell_] 0.2072 0
81 [class^=chat_] > [class^=content_] > [class^=container_] 0.2071 1
82 [class^='membersWrap_'] 0.207 1
83 [class^='sectionDivider_'] 0.2069 0
84 [class^='privateChannels_'] 0.2067 0
85 [class^='peopleColumn_'] 0.2065 0
86 [class^=attachedBars_] 0.2061 0
87 [class^=searchResultsWrap_] 0.206 0
88 [class^='callContainer_'] 0.2055 0
89 [class^='peopleColumn_'] 0.2051 0
90 [class^='notAppAsidePanel_'] > [class^='layerContainer_'] 0.2049 2
91 [class^='unreadPill_'][class*='endCap_'] 0.2048 0
92 [class^='panels_'] 0.2047 1
93 #app-mount [class^=sidebar_] 0.2045 1
94 [class^=chat_] 0.2044 1
95 [class^=panels_] 0.2044 1
96 [class^=panels_] > [class^=container_] 0.2042 1
97 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.2042 1
98 [class^='nowPlayingColumn_'] 0.2041 0
99 [class^='membersWrap_'] 0.2041 1
100 [class^=container_] > [class^=base_] 0.2039 1
101 [class^='toolbar_'] > [class^='search_'] 0.2034 1
102 [class^='status_'][style='background-color: rgb(240, 178, 50);'] 0.2034 0
103 [class^=tabBody_] 0.2031 0
104 [class^='lowerBadge_'] 0.2031 0
105 [class^=privateChannels_] > [class^=scroller_] 0.2029 0
106 [class^='searchResultsWrap_'] 0.2028 0
107 [class^=customizationSection_] 0.2028 0
108 [class^=chatContent_] > [class^=messagesWrapper_] 0.2027 1
109 [class^='upperBadge_'] 0.2027 0
110 [class^='status_'][style='background-color: rgb(242, 63, 67);'] 0.2025 0
111 [class^=threadSuggestionBar_] 0.2021 0
112 [class^=panels_] > [class^=panel_] 0.202 0
113 [class^='searchResultsWrap_'] 0.202 0
114 [class^=columnHeading_] 0.2016 0
115 #app-mount [class^='divider_'] 0.2016 2
116 [class^=callContainer_] 0.2014 0
117 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.2011 1
118 [class^='channelBottomBarArea_']:hover 0.201 0
119 [class^=gradientTop_] 0.2009 0
120 [class^=iconWrapper_] 0.2008 6
121 [class^='guildSeparator_'] 0.1998 2
122 [class^='content_']>[class^='outer_'] 0.1998 0
123 [class^='privateChannels_'] 0.1997 0
124 [class^=container_] > [class^=base_] 0.1996 1
125 [class^=member_] 0.1985 25
126 [class^='content_']>[class^='outer_'] 0.1984 0
127 [class^='sectionDivider_'] 0.198 0
128 [class^=topControls_] 0.198 0
129 [class^='membersWrap_'] 0.1974 1
130 [class^=privateChannels_] 0.1971 0
131 [class^=content_] > [class^=resizeHandle_] 0.1969 0
132 [class^=replyBar_] 0.196 0
133 [class^='form_'] [class^='channelTextArea_'] 0.1958 1
134 [class^='divider_']:not(.isUnread_c2654d) 0.194 2
135 .wrapper_c51b4e:has(rect[fill='#f0b232']) 0.1871 8
136 [class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask 0.1624 39
137 :root 0.1432 1
138 .wrapper_c51b4e:has(rect[fill='#80848e']) 0.1408 1
139 .wrapper_c51b4e:has(rect[fill='#593695']) 0.1371 0
140 .wrapper_c51b4e:has(rect[fill='#f23f43']) 0.1349 5
141 .wrapper_c51b4e:has(rect) 0.1342 26
142 .wrapper_c51b4e:has(rect[fill='#23a55a']) 0.1332 12
143 [class^='svg_'] > foreignObject 0.123 99
144 [role='button'] path[fill='var(--white-500)'] 0.1159 0
145 svg[style='color: rgb(88, 101, 242);'] 0.1122 13
146 [class^=jumpToPresentBar_] > button 0.1117 2
147 [style='opacity: 1; background-color: rgb(35, 165, 90);'] 0.1095 0
148 :root 0.1094 1
149 [class^='mask_'] > rect 0.1049 26
150 [class^='mask_'] > foreignObject 0.1041 26
151 [class^='mask_'] > svg 0.1035 1
152 #app-mount button path[fill='var(--white-500)'] 0.1031 0
153 [class^='bannerSVGWrapper_'] > mask > circle 0.102 0
154 svg[class^=slider_] > rect[fill='white'] 0.1016 0
155 :root 0.0946 1
156 [class^='mask_'] > circle 0.0937 0
157 [class^=callContainer_] [class^=topControls_] section 0.0882 0
158 .container_a6d69a 0.0797 0
159 .guilds_a4d4d9 0.0771 1
160 .container_adcaac 0.0769 0
161 .vc-spotify-time-left 0.0766 0
162 .vc-spotify-time-right 0.0746 0
163 .guilds_a4d4d9 0.0733 1
164 .form_a7d72e 0.0731 1
165 .container_fc4f04 0.0723 1
166 .container_c2739c 0.072 0
167 .form_a7d72e 0.0719 1
168 * 0.0714 6196
169 .timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca 0.0713 13
170 .container_d1c246 0.0713 0
171 .vc-ranb-button 0.0709 0
172 .container_b2ca13 0.0707 1
173 .wrapper_fea3ef 0.0706 1
174 .statusBubbleOuter_d0a252 0.0706 0
175 .container_fc4f04 0.0704 1
176 .scroller_bf550a 0.0703 0
177 .guilds_a4d4d9 0.0703 1
178 .attachButton_f298d4 0.0702 1
179 .container_a6d69a 0.07 0
180 .clipContainer_b11c5e 0.0699 0
181 .chat_a7d72e 0.0699 1
182 .form_a7d72e 0.0698 1
183 .panels_a4d4d9 0.0698 1
184 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.0698 0
185 .textArea_bdf0de 0.0697 1
186 .content_a7d72e 0.0695 1
187 .container_adcaac 0.0695 0
188 .animatedContainer_fd6364 0.0693 1
189 .guilds_a4d4d9 0.0692 1
190 .container_fb4810 0.0692 0
191 .separator_a0 0.0692 0
192 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.0692 0
193 .scroller_fb4810 0.0691 0
194 .wordmarkWindows_a934d8 > svg 0.0691 1
195 .wordmarkWindows_a934d8 0.0688 1
196 .container_fc4f04 0.0688 1
197 .scroller_fb4810 0.0688 0
198 .guilds_a4d4d9 0.0687 1
199 .container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'] 0.0686 0
200 .newMessagesBar_cf58b5 0.0685 0
201 .container_a4d4d9 0.0684 1
202 .scroller_e2e187 0.0684 1
203 .platform-osx .guilds_a4d4d9 0.0684 0
204 .scroller_fb4810 0.0683 0
205 .container_c75f85 0.0683 0
206 .container_c2739c 0.0681 0
207 html.theme-light 0.0681 0
208 .container_a6d69a 0.0681 0
209 .grabber_c7a159 0.068 0
210 .scroller_fb4810 0.0679 0
211 .theme-dark 0.0678 1
212 .statusBubbleOuter_d0a252 0.0678 0
213 .tabBody_c2739c 0.0677 0
214 .container_d1c246 0.0676 0
215 .horizontal_dc333f > .flex_dc333f 0.0673 0
216 .separator_a0 0.0669 0
217 .vc-spotify-time-left 0.0666 0
218 .container_fc4f04 0.0662 1
219 .vc-spotify-time-right 0.0661 0
220 .wrapper_fea3ef.hidden_fea3ef 0.0661 0
221 #vc-spotify-player 0.0004 0
222 #vc-spotify-progress-bar [class^='bar_'] 0.0004 0
223 #vc-spotify-player 0.0004 0
224 .overlay_f94206:after 0.0004 0
225 #app-mount #vc-spotify-player 0.0003 0
226 #vc-spotify-player 0.0003 0
227 #vc-spotify-progress-bar 0.0003 0
228 ::placeholder 0.0003 0
229 ::selection 0.0003 0
230 ::-webkit-scrollbar-thumb 0.0003 0
231 .overlay_f18fcb:after 0.0003 0
232 #vc-spotify-player 0.0002 0

View File

@ -0,0 +1,231 @@
Selector,Time (ms),Matches,Total
[class^='chatContent_']:has([class^='messagesWrapper_']:hover),0.4053,0,29.2904
[class^='chatContent_']:has([class^='typing_']:hover),0.3815,0,
[class^=chat_] > [class*=container_],0.3098,0,
[class^=chat_] > [class^=content_] > [class^=container_],0.238,0,
[class*=backgroundAccent_],0.2297,0,
[class^='form_'] [class^='channelTextArea_'],0.2236,1,
[class^='membersWrap_'],0.2207,0,
[class^=panels_],0.2156,1,
[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_],0.2081,0,
[class^=searchResultsWrap_],0.1998,0,
[class^='content_']>[class^='outer_'],0.1997,0,
[class^='form_'] > [class^='wrapper_'],0.1982,0,
[class^=videoControls_],0.1967,0,
[class^=sidebar_] > [class^=container_],0.1947,1,
[class^=privateChannels_],0.1911,0,
[class^='content_'] > [class^='outer_'],0.19,0,
"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.19,0,
[class^='form_'] > [class^='wrapper_'],0.1888,0,
[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_],0.1887,0,
[class^=iconBadge_]:not([class*=isCurrentUserConnected_]),0.1881,1,
[class^='sidebar_'] > [class^='container_'],0.1842,1,
[class^='peopleColumn_'],0.1839,0,
[class^='lowerBadge_'],0.1839,0,
[class^='gradientBottom_'],0.1836,0,
[class^=overlayBottom_] > [class^=text-],0.1834,0,
"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.1834,0,
[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'],0.1834,0,
"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.1834,0,
"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.1832,0,
[class^='form_'] > [class^='typing_'],0.183,1,
[class^=panels_],0.1826,1,
[class^='searchResultsWrap_'],0.1826,0,
[class^='callContainer_'],0.182,0,
[class^='upperBadge_'],0.1817,0,
[class^=childWrapper_],0.1817,15,
[class^=members_],0.1815,0,
[class^=button_][class*=bannerColor_],0.181,0,
[class^='gradientTop_'],0.181,0,
[class^='sidebar_'] > [class^='container_'],0.1808,1,
[class^=searchFilter_],0.1807,0,
[class^=privateChannels_],0.1804,0,
[class^='panels_'],0.1794,1,
[class^=member_],0.1791,0,
[class^='membersWrap_'],0.1791,0,
[class^=message_][class*=replying_],0.179,0,
[class^='unreadPill_'][class*='endCap_'],0.1788,0,
[class^='privateChannels_'],0.1787,0,
[class^='bottomControls_'],0.1787,0,
[class^=message_][class*=mentioned_],0.1784,5,
[class^='content_'] > [class^='outer_'],0.1784,0,
#app-mount [class^=partyStatus_],0.1784,0,
[class^=searchAnswer_],0.1781,0,
[class^=panels_] > [class^=wrapper_] > [class^=container],0.178,0,
[class^='peopleColumn_'],0.1779,0,
[class^='form_'] [class^='channelTextArea_'],0.1779,1,
#app-mount [class^=partyStatus_] [class^=anchor_],0.1776,0,
[class^='panels_'],0.1766,1,
[class^='form_'] [class^='channelTextArea_'],0.1766,1,
[class^=replyBar_],0.1765,0,
[class^='privateChannels_'],0.1765,0,
[class^=channelBottomBarArea_],0.1764,1,
[class^='divider_']:not(.isUnread_c2654d),0.1763,2,
[class^=navButton_][class*=navButtonActive_],0.1762,0,
[class^=container_] > [class^=base_],0.1756,1,
[class^='nowPlayingColumn_'],0.1755,0,
[class^='membersWrap_'],0.1755,0,
[class^=sidebar_],0.1753,1,
[class^='newChannel_'],0.1752,0,
[class^=nowPlayingColumn_],0.1752,0,
[class^=guildSeparator_],0.1751,2,
[class^='topControls_'],0.175,0,
[class^='container_'] > [class^='base_'],0.1748,1,
[class^=panels_] > [class^=container_],0.1742,1,
[class^='notAppAsidePanel_'] > [class^='layerContainer_'],0.1742,2,
[class^=columnHeading_],0.174,0,
[class^='channelBottomBarArea_']:hover,0.1739,0,
[class^=attachedBars_],0.1738,0,
[class^='membersWrap_'],0.1738,0,
[class^=searchResultsWrap_],0.1737,0,
[class^=gifFavoriteButton_],0.1736,0,
[class^=membersWrap_],0.1736,0,
[class^=customizationSection_],0.1732,0,
#app-mount [class^=sidebar_] > [class^=container_],0.1727,1,
[class^='newBadge_'],0.1726,0,
[class^='channelBottomBarArea_'],0.1723,1,
[class^=sidebar_],0.1722,1,
[class^=privateChannels_],0.1722,0,
[class^=sidebar_],0.1721,1,
[class^='toolbar_'] > [class^='search_'],0.172,1,
[class^=privateChannels_],0.1713,0,
[class^=friendRequestsButton_] [class^=numberBadge_],0.1713,0,
[class*=bannerVisible_] [class^=headerContent_],0.1712,0,
[class^=iconWrapper_],0.1712,7,
[class^=chatContent_] > [class^=messagesWrapper_],0.1712,1,
[class^='content_']>[class^='outer_']>[class^='inner_'],0.171,0,
[class^='nowPlayingColumn_'],0.1709,0,
[class^=peopleColumn_],0.1709,0,
[class^='content_']>[class^='outer_'],0.1707,0,
[class^=searchResultsWrap_],0.1707,0,
[class^='chatContent_'] > [class^='messagesWrapper_'],0.1707,1,
[class^=tableCell_],0.1707,0,
[class^=tabBody_],0.1706,0,
[class^=peopleColumn_],0.1705,0,
[class^=tabBody_],0.1703,0,
[class^='dot_'][class*='maskSecondary_'],0.1702,0,
[class^='sectionDivider_'],0.1701,1,
[class^='searchResultsWrap_'],0.1701,0,
[class^=privateChannels_] > [class^=scroller_],0.17,0,
[class^=container_] > [class^=base_],0.17,1,
[class^='content_'] > [class^='outer_'],0.1699,0,
[class^=chat_],0.1696,1,
[class^='channelAppLauncher_'],0.1696,1,
[class^='nowPlayingColumn_'],0.1696,0,
#app-mount [class^=sidebar_],0.1689,1,
[class^='guildSeparator_'],0.1688,2,
[class^=panels_] > [class^=panel_],0.1686,0,
[class^=threadSuggestionBar_],0.1685,0,
[class^='chatContent_'] > [class^='messagesWrapper_'],0.1684,1,
[class^=session_],0.1684,0,
[class^=categoryText_],0.1683,0,
[class^=channelTextArea] > [class^=scrollableContainer],0.1679,1,
[class^=panels_] > [class^=panel_],0.1677,0,
"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.1674,0,
[class^='sectionDivider_'],0.1671,1,
[class^='callContainer_'],0.1669,0,
[class^=content_] > [class^=resizeHandle_],0.1663,0,
[class^=container_] > [class^=base_],0.1661,1,
#app-mount [class^='divider_'],0.1639,2,
[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask,0.123,45,
*,0.1194,5612,
[class^='mask_'] > foreignObject,0.1165,1,
[class^='bannerSVGWrapper_'] > mask > circle,0.1122,0,
[class^='mask_'] > rect,0.1074,1,
[class^='svg_'] > foreignObject,0.1069,100,
[class^='mask_'] > svg,0.1043,0,
#app-mount .controlIcon_ef18ee,0.1034,0,
#app-mount .lookLink_dd4f85.colorPrimary_dd4f85,0.1023,0,
[class^='mask_'] > circle,0.099,0,
"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.0979,0,
"svg[style='color: rgb(88, 101, 242);']",0.0884,13,
#app-mount button path[fill='var(--white-500)'],0.087,0,
[role='button'] path[fill='var(--white-500)'],0.083,0,
:root,0.0804,1,
:root,0.0792,1,
[class^='callContainer_'] [class^='topControls_'] section,0.0784,0,
svg[class^=slider_] > rect[fill='white'],0.0784,0,
:root,0.0767,1,
[class^=jumpToPresentBar_] > button,0.0735,0,
.wrapper_c51b4e:has(rect[fill='#80848e']),0.0695,1,
.textArea_bdf0de,0.0636,1,
.attachButton_f298d4,0.0619,1,
.wrapper_c51b4e:has(rect[fill='#593695']),0.0614,0,
.wrapper_c51b4e:has(rect[fill='#f0b232']),0.0609,0,
.guilds_a4d4d9,0.0607,1,
.wrapper_c51b4e:has(rect),0.0607,1,
.wrapper_c51b4e:has(rect[fill='#f23f43']),0.0606,0,
.theme-dark,0.0602,1,
.wrapper_c51b4e:has(rect[fill='#23a55a']),0.0597,0,
.lookFilled_dd4f85.colorPrimary_dd4f85,0.0584,0,
.container_fc4f04,0.0581,1,
.guilds_a4d4d9,0.058,1,
.timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca,0.0577,32,
.container_c2739c,0.0572,0,
.container_b2ca13,0.0571,1,
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0568,0,
.embedFull_b0068a,0.0567,1,
.separator_a0,0.0564,0,
.scroller_fb4810,0.0562,0,
.scroller_fb4810,0.0562,0,
.statusBubbleOuter_d0a252,0.056,0,
.statusBubbleOuter_d0a252,0.0559,0,
.container_a4d4d9,0.0558,1,
.container_a6d69a,0.0557,0,
.container_fc4f04,0.0557,1,
.container_a6d69a,0.0557,0,
.form_a7d72e,0.0556,1,
.wrapper_fea3ef.hidden_fea3ef,0.0556,0,
.guilds_a4d4d9,0.0555,1,
.vc-ranb-button,0.0555,0,
.horizontal_dc333f > .flex_dc333f,0.0555,0,
.wordmarkWindows_a934d8 > svg,0.0554,1,
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0554,0,
.vc-spotify-time-right,0.0553,0,
.newMessagesBar_cf58b5,0.0553,0,
.chat_a7d72e,0.0552,1,
.panels_a4d4d9,0.0552,1,
.container_fb4810,0.0552,0,
.vc-spotify-time-right,0.0552,0,
.content_a7d72e,0.0551,1,
.container_d1c246,0.0551,0,
.form_a7d72e,0.0551,1,
.wordmarkWindows_a934d8,0.0551,1,
.vc-spotify-time-left,0.0551,0,
.scroller_bf550a,0.055,0,
.clipContainer_b11c5e,0.0549,0,
.vc-spotify-time-left,0.0549,0,
.wrapper_fea3ef,0.0549,1,
.grabber_c7a159,0.0549,0,
.platform-osx .guilds_a4d4d9,0.0548,0,
.container_fc4f04,0.0548,1,
.guilds_a4d4d9,0.0547,1,
.container_a6d69a,0.0547,0,
.scroller_e2e187,0.0546,1,
.container_c75f85,0.0546,0,
.container_c2739c,0.0546,0,
.animatedContainer_fd6364,0.0544,0,
.container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'],0.0544,0,
.container_fc4f04,0.0544,1,
.container_d1c246,0.0544,0,
.separator_a0,0.0544,0,
.container_adcaac,0.0542,0,
.guilds_a4d4d9,0.0542,1,
html.theme-light,0.054,0,
.form_a7d72e,0.0539,1,
.tabBody_c2739c,0.0539,0,
.scroller_fb4810,0.0539,0,
.container_adcaac,0.0538,0,
.scroller_fb4810,0.0533,0,
::selection,0.0028,0,
#vc-spotify-player,0.0003,0,
#vc-spotify-player,0.0003,0,
#vc-spotify-player,0.0003,0,
#vc-spotify-progress-bar,0.0003,0,
#vc-spotify-progress-bar [class^='bar_'],0.0003,0,
.overlay_f94206:after,0.0003,0,
#app-mount #vc-spotify-player,0.0002,0,
#vc-spotify-player,0.0002,0,
.overlay_f18fcb:after,0.0002,0,
::-webkit-scrollbar-thumb,0.0001,0,
::placeholder,0.0001,0,
1 Selector Time (ms) Matches Total
2 [class^='chatContent_']:has([class^='messagesWrapper_']:hover) 0.4053 0 29.2904
3 [class^='chatContent_']:has([class^='typing_']:hover) 0.3815 0
4 [class^=chat_] > [class*=container_] 0.3098 0
5 [class^=chat_] > [class^=content_] > [class^=container_] 0.238 0
6 [class*=backgroundAccent_] 0.2297 0
7 [class^='form_'] [class^='channelTextArea_'] 0.2236 1
8 [class^='membersWrap_'] 0.2207 0
9 [class^=panels_] 0.2156 1
10 [class^=centerContent_] > [class^=wrapper_] > [class^=anchor_] 0.2081 0
11 [class^=searchResultsWrap_] 0.1998 0
12 [class^='content_']>[class^='outer_'] 0.1997 0
13 [class^='form_'] > [class^='wrapper_'] 0.1982 0
14 [class^=videoControls_] 0.1967 0
15 [class^=sidebar_] > [class^=container_] 0.1947 1
16 [class^=privateChannels_] 0.1911 0
17 [class^='content_'] > [class^='outer_'] 0.19 0
18 [class^='status_'][style='background-color: rgb(240, 178, 50);'] 0.19 0
19 [class^='form_'] > [class^='wrapper_'] 0.1888 0
20 [class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_] 0.1887 0
21 [class^=iconBadge_]:not([class*=isCurrentUserConnected_]) 0.1881 1
22 [class^='sidebar_'] > [class^='container_'] 0.1842 1
23 [class^='peopleColumn_'] 0.1839 0
24 [class^='lowerBadge_'] 0.1839 0
25 [class^='gradientBottom_'] 0.1836 0
26 [class^=overlayBottom_] > [class^=text-] 0.1834 0
27 [class^='status_'][style='background-color: rgb(35, 165, 90);'] 0.1834 0
28 [class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'] 0.1834 0
29 [class^='status_'][style='background-color: rgb(128, 132, 142);'] 0.1834 0
30 [class^='status_'][style='background-color: rgb(242, 63, 67);'] 0.1832 0
31 [class^='form_'] > [class^='typing_'] 0.183 1
32 [class^=panels_] 0.1826 1
33 [class^='searchResultsWrap_'] 0.1826 0
34 [class^='callContainer_'] 0.182 0
35 [class^='upperBadge_'] 0.1817 0
36 [class^=childWrapper_] 0.1817 15
37 [class^=members_] 0.1815 0
38 [class^=button_][class*=bannerColor_] 0.181 0
39 [class^='gradientTop_'] 0.181 0
40 [class^='sidebar_'] > [class^='container_'] 0.1808 1
41 [class^=searchFilter_] 0.1807 0
42 [class^=privateChannels_] 0.1804 0
43 [class^='panels_'] 0.1794 1
44 [class^=member_] 0.1791 0
45 [class^='membersWrap_'] 0.1791 0
46 [class^=message_][class*=replying_] 0.179 0
47 [class^='unreadPill_'][class*='endCap_'] 0.1788 0
48 [class^='privateChannels_'] 0.1787 0
49 [class^='bottomControls_'] 0.1787 0
50 [class^=message_][class*=mentioned_] 0.1784 5
51 [class^='content_'] > [class^='outer_'] 0.1784 0
52 #app-mount [class^=partyStatus_] 0.1784 0
53 [class^=searchAnswer_] 0.1781 0
54 [class^=panels_] > [class^=wrapper_] > [class^=container] 0.178 0
55 [class^='peopleColumn_'] 0.1779 0
56 [class^='form_'] [class^='channelTextArea_'] 0.1779 1
57 #app-mount [class^=partyStatus_] [class^=anchor_] 0.1776 0
58 [class^='panels_'] 0.1766 1
59 [class^='form_'] [class^='channelTextArea_'] 0.1766 1
60 [class^=replyBar_] 0.1765 0
61 [class^='privateChannels_'] 0.1765 0
62 [class^=channelBottomBarArea_] 0.1764 1
63 [class^='divider_']:not(.isUnread_c2654d) 0.1763 2
64 [class^=navButton_][class*=navButtonActive_] 0.1762 0
65 [class^=container_] > [class^=base_] 0.1756 1
66 [class^='nowPlayingColumn_'] 0.1755 0
67 [class^='membersWrap_'] 0.1755 0
68 [class^=sidebar_] 0.1753 1
69 [class^='newChannel_'] 0.1752 0
70 [class^=nowPlayingColumn_] 0.1752 0
71 [class^=guildSeparator_] 0.1751 2
72 [class^='topControls_'] 0.175 0
73 [class^='container_'] > [class^='base_'] 0.1748 1
74 [class^=panels_] > [class^=container_] 0.1742 1
75 [class^='notAppAsidePanel_'] > [class^='layerContainer_'] 0.1742 2
76 [class^=columnHeading_] 0.174 0
77 [class^='channelBottomBarArea_']:hover 0.1739 0
78 [class^=attachedBars_] 0.1738 0
79 [class^='membersWrap_'] 0.1738 0
80 [class^=searchResultsWrap_] 0.1737 0
81 [class^=gifFavoriteButton_] 0.1736 0
82 [class^=membersWrap_] 0.1736 0
83 [class^=customizationSection_] 0.1732 0
84 #app-mount [class^=sidebar_] > [class^=container_] 0.1727 1
85 [class^='newBadge_'] 0.1726 0
86 [class^='channelBottomBarArea_'] 0.1723 1
87 [class^=sidebar_] 0.1722 1
88 [class^=privateChannels_] 0.1722 0
89 [class^=sidebar_] 0.1721 1
90 [class^='toolbar_'] > [class^='search_'] 0.172 1
91 [class^=privateChannels_] 0.1713 0
92 [class^=friendRequestsButton_] [class^=numberBadge_] 0.1713 0
93 [class*=bannerVisible_] [class^=headerContent_] 0.1712 0
94 [class^=iconWrapper_] 0.1712 7
95 [class^=chatContent_] > [class^=messagesWrapper_] 0.1712 1
96 [class^='content_']>[class^='outer_']>[class^='inner_'] 0.171 0
97 [class^='nowPlayingColumn_'] 0.1709 0
98 [class^=peopleColumn_] 0.1709 0
99 [class^='content_']>[class^='outer_'] 0.1707 0
100 [class^=searchResultsWrap_] 0.1707 0
101 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.1707 1
102 [class^=tableCell_] 0.1707 0
103 [class^=tabBody_] 0.1706 0
104 [class^=peopleColumn_] 0.1705 0
105 [class^=tabBody_] 0.1703 0
106 [class^='dot_'][class*='maskSecondary_'] 0.1702 0
107 [class^='sectionDivider_'] 0.1701 1
108 [class^='searchResultsWrap_'] 0.1701 0
109 [class^=privateChannels_] > [class^=scroller_] 0.17 0
110 [class^=container_] > [class^=base_] 0.17 1
111 [class^='content_'] > [class^='outer_'] 0.1699 0
112 [class^=chat_] 0.1696 1
113 [class^='channelAppLauncher_'] 0.1696 1
114 [class^='nowPlayingColumn_'] 0.1696 0
115 #app-mount [class^=sidebar_] 0.1689 1
116 [class^='guildSeparator_'] 0.1688 2
117 [class^=panels_] > [class^=panel_] 0.1686 0
118 [class^=threadSuggestionBar_] 0.1685 0
119 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.1684 1
120 [class^=session_] 0.1684 0
121 [class^=categoryText_] 0.1683 0
122 [class^=channelTextArea] > [class^=scrollableContainer] 0.1679 1
123 [class^=panels_] > [class^=panel_] 0.1677 0
124 [class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);'] 0.1674 0
125 [class^='sectionDivider_'] 0.1671 1
126 [class^='callContainer_'] 0.1669 0
127 [class^=content_] > [class^=resizeHandle_] 0.1663 0
128 [class^=container_] > [class^=base_] 0.1661 1
129 #app-mount [class^='divider_'] 0.1639 2
130 [class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask 0.123 45
131 * 0.1194 5612
132 [class^='mask_'] > foreignObject 0.1165 1
133 [class^='bannerSVGWrapper_'] > mask > circle 0.1122 0
134 [class^='mask_'] > rect 0.1074 1
135 [class^='svg_'] > foreignObject 0.1069 100
136 [class^='mask_'] > svg 0.1043 0
137 #app-mount .controlIcon_ef18ee 0.1034 0
138 #app-mount .lookLink_dd4f85.colorPrimary_dd4f85 0.1023 0
139 [class^='mask_'] > circle 0.099 0
140 [style='opacity: 1; background-color: rgb(35, 165, 90);'] 0.0979 0
141 svg[style='color: rgb(88, 101, 242);'] 0.0884 13
142 #app-mount button path[fill='var(--white-500)'] 0.087 0
143 [role='button'] path[fill='var(--white-500)'] 0.083 0
144 :root 0.0804 1
145 :root 0.0792 1
146 [class^='callContainer_'] [class^='topControls_'] section 0.0784 0
147 svg[class^=slider_] > rect[fill='white'] 0.0784 0
148 :root 0.0767 1
149 [class^=jumpToPresentBar_] > button 0.0735 0
150 .wrapper_c51b4e:has(rect[fill='#80848e']) 0.0695 1
151 .textArea_bdf0de 0.0636 1
152 .attachButton_f298d4 0.0619 1
153 .wrapper_c51b4e:has(rect[fill='#593695']) 0.0614 0
154 .wrapper_c51b4e:has(rect[fill='#f0b232']) 0.0609 0
155 .guilds_a4d4d9 0.0607 1
156 .wrapper_c51b4e:has(rect) 0.0607 1
157 .wrapper_c51b4e:has(rect[fill='#f23f43']) 0.0606 0
158 .theme-dark 0.0602 1
159 .wrapper_c51b4e:has(rect[fill='#23a55a']) 0.0597 0
160 .lookFilled_dd4f85.colorPrimary_dd4f85 0.0584 0
161 .container_fc4f04 0.0581 1
162 .guilds_a4d4d9 0.058 1
163 .timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca 0.0577 32
164 .container_c2739c 0.0572 0
165 .container_b2ca13 0.0571 1
166 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.0568 0
167 .embedFull_b0068a 0.0567 1
168 .separator_a0 0.0564 0
169 .scroller_fb4810 0.0562 0
170 .scroller_fb4810 0.0562 0
171 .statusBubbleOuter_d0a252 0.056 0
172 .statusBubbleOuter_d0a252 0.0559 0
173 .container_a4d4d9 0.0558 1
174 .container_a6d69a 0.0557 0
175 .container_fc4f04 0.0557 1
176 .container_a6d69a 0.0557 0
177 .form_a7d72e 0.0556 1
178 .wrapper_fea3ef.hidden_fea3ef 0.0556 0
179 .guilds_a4d4d9 0.0555 1
180 .vc-ranb-button 0.0555 0
181 .horizontal_dc333f > .flex_dc333f 0.0555 0
182 .wordmarkWindows_a934d8 > svg 0.0554 1
183 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.0554 0
184 .vc-spotify-time-right 0.0553 0
185 .newMessagesBar_cf58b5 0.0553 0
186 .chat_a7d72e 0.0552 1
187 .panels_a4d4d9 0.0552 1
188 .container_fb4810 0.0552 0
189 .vc-spotify-time-right 0.0552 0
190 .content_a7d72e 0.0551 1
191 .container_d1c246 0.0551 0
192 .form_a7d72e 0.0551 1
193 .wordmarkWindows_a934d8 0.0551 1
194 .vc-spotify-time-left 0.0551 0
195 .scroller_bf550a 0.055 0
196 .clipContainer_b11c5e 0.0549 0
197 .vc-spotify-time-left 0.0549 0
198 .wrapper_fea3ef 0.0549 1
199 .grabber_c7a159 0.0549 0
200 .platform-osx .guilds_a4d4d9 0.0548 0
201 .container_fc4f04 0.0548 1
202 .guilds_a4d4d9 0.0547 1
203 .container_a6d69a 0.0547 0
204 .scroller_e2e187 0.0546 1
205 .container_c75f85 0.0546 0
206 .container_c2739c 0.0546 0
207 .animatedContainer_fd6364 0.0544 0
208 .container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'] 0.0544 0
209 .container_fc4f04 0.0544 1
210 .container_d1c246 0.0544 0
211 .separator_a0 0.0544 0
212 .container_adcaac 0.0542 0
213 .guilds_a4d4d9 0.0542 1
214 html.theme-light 0.054 0
215 .form_a7d72e 0.0539 1
216 .tabBody_c2739c 0.0539 0
217 .scroller_fb4810 0.0539 0
218 .container_adcaac 0.0538 0
219 .scroller_fb4810 0.0533 0
220 ::selection 0.0028 0
221 #vc-spotify-player 0.0003 0
222 #vc-spotify-player 0.0003 0
223 #vc-spotify-player 0.0003 0
224 #vc-spotify-progress-bar 0.0003 0
225 #vc-spotify-progress-bar [class^='bar_'] 0.0003 0
226 .overlay_f94206:after 0.0003 0
227 #app-mount #vc-spotify-player 0.0002 0
228 #vc-spotify-player 0.0002 0
229 .overlay_f18fcb:after 0.0002 0
230 ::-webkit-scrollbar-thumb 0.0001 0
231 ::placeholder 0.0001 0

View File

@ -0,0 +1,215 @@
{
"name": "systemtheme",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "systemtheme",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"chokidar": "^3.6.0",
"dotenv": "^16.4.5"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/dotenv": {
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"name": "systemtheme",
"version": "1.0.0",
"scripts": {
"dev": "node scripts/dev.js",
"build": "node scripts/build.js"
},
"author": "refact0r",
"license": "MIT",
"devDependencies": {
"chokidar": "^3.6.0",
"dotenv": "^16.4.5"
}
}

View File

@ -0,0 +1,46 @@
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const srcDir = path.join(__dirname, '..', 'src');
const baseFile = path.join(__dirname, '..', 'system24.theme.css');
const outputFile = process.env.DEV_OUTPUT_PATH;
const pathToIgnore = 'https://refact0r.github.io/system24/';
if (!outputFile) {
console.error('DEV_OUTPUT_PATH is not set in .env file');
process.exit(1);
}
async function replaceImports(content) {
const importRegex = /@import url\('([^']+)'\);/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
const importUrl = match[1];
const filePath = importUrl.replace(pathToIgnore, '');
const localFilePath = path.join(__dirname, '..', filePath);
if (fs.existsSync(localFilePath)) {
const importedContent = fs.readFileSync(localFilePath, 'utf8');
content = content.replace(match[0], importedContent);
} else {
console.error(`File not found: ${localFilePath}`);
}
}
return content;
}
async function combineCSS() {
let combinedCSS = fs.readFileSync(baseFile, 'utf8');
combinedCSS = await replaceImports(combinedCSS);
fs.writeFileSync(outputFile, combinedCSS);
console.log('Updated development CSS file.');
}
combineCSS();
chokidar.watch(srcDir).on('change', (event, path) => {
console.log('Changes detected. Rebuilding...');
combineCSS();
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,67 @@
/* unrounding */
::-webkit-scrollbar-thumb,
* {
border-radius: 0 !important;
}
/* hide round masks */
[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask,
[class^='bannerSVGWrapper_'] > mask > circle {
display: none;
}
[class^='mask_'] > foreignObject,
[class^='svg_'] > foreignObject {
mask-image: none;
}
/* fiil circle mask on profile avatar */
.overlay__75742:after, /* profile popup */
.overlay_c0bea0:after {
/* user settings */
border-radius: 0;
}
/* hide status indicators */
[class^='mask_'] > rect,
[class^='mask_'] > circle,
[class^='mask_'] > svg {
display: none;
}
/* re-add status indicators */
.wrapper__44b0c:has(rect)::after {
content: '';
display: block;
position: absolute;
height: 8px;
width: 8px;
bottom: -4px;
right: -4px;
border: 2px solid var(--bg-0);
}
.wrapper__44b0c:has(rect[fill='#23a55a'])::after {
background: var(--online-dot) !important;
}
.wrapper__44b0c:has(rect[fill='#f23f43'])::after {
background: var(--dnd-dot) !important;
}
.wrapper__44b0c:has(rect[fill='#f0b232'])::after {
background: var(--idle-dot) !important;
}
.wrapper__44b0c:has(rect[fill='#80848e'])::after {
background: var(--txt-3) !important;
}
.wrapper__44b0c:has(rect[fill='#593695'])::after {
background: var(--streaming-dot);
}
/* fix server list badges */
[class^='lowerBadge_'] {
border: 2px solid var(--bg-0);
bottom: -4px;
right: -4px;
}
[class^='upperBadge_'] {
border: 2px solid var(--bg-0);
top: -4px;
right: -4px;
}

View File

@ -0,0 +1,87 @@
/**
* @name system24 (catppuccin macchiato lavender)
* @description A tui-style discord theme. Based on the catppuccin maccchiato color palette.
* @author refact0r, bean
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/flavors/catppuccin-mocha.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
font-weight: 300; /* UI font weight. */
--label-font-weight: 500; /* font weight for panel labels. */
--corner-text: 'macchiato24'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: #24273a; /* main background color. */
--bg-1: #1e2030; /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: #363a4f; /* color of neutral buttons. */
--bg-3: #494d64; /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */
--active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: #eaeefa; /* color of bright/white text. */
--txt-1: #cad3f5; /* main text color. */
--txt-2: #939ab7; /* color of secondary text like channel list. */
--txt-3: #5b6078; /* color of muted text. */
/* accent colors */
--acc-0: var(--lavender); /* main accent color. */
--acc-1: var(--lavender-1); /* color of accent buttons when hovered. */
--acc-2: var(--lavender-2); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-2); /* panel border color. */
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--cyan); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(73.7% 0.125 11.19);
--pink-1: oklch(63.7% 0.125 11.19);
--pink-2: oklch(53.7% 0.125 11.19);
--purple: oklch(77.15% 0.126 303.9);
--purple-1: oklch(67.15% 0.126 303.9);
--purple-2: oklch(57.15% 0.126 303.9);
--cyan: oklch(78.51% 0.085 228.38);
--yellow: oklch(87.9% 0.074 84.75);
--green: oklch(83.5% 0.108 138.15);
--green-1: oklch(73.5% 0.108 138.15);
--green-2: oklch(63.5% 0.108 138.15);
--lavender: oklch(81.66% 0.091 277.31);
--lavender-1: oklch(71.66% 0.091 277.31);
--lavender-2: oklch(61.66% 0.091 277.31);
}

View File

@ -0,0 +1,84 @@
/**
* @name system24 (catppuccin mocha)
* @description A tui-style discord theme. Based on the catppuccin mocha color palette.
* @author refact0r
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/flavors/catppuccin-mocha.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
font-weight: 300; /* UI font weight. */
--label-font-weight: 500; /* font weight for panel labels. */
--corner-text: 'catppuccin24'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: #1e1e2e; /* main background color. */
--bg-1: #181825; /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: #313244; /* color of neutral buttons. */
--bg-3: #45475a; /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */
--active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: #eaeefa; /* color of bright/white text. */
--txt-1: #cdd6f4; /* main text color. */
--txt-2: #9399b2; /* color of secondary text like channel list. */
--txt-3: #585b70; /* color of muted text. */
/* accent colors */
--acc-0: var(--purple); /* main accent color. */
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
--acc-2: var(--purple-2); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-2); /* panel border color. */
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: #f38ba8;
--pink-1: #d16c89;
--pink-2: #af4e6c;
--purple: #cba6f7;
--purple-1: #ab87d6;
--purple-2: #8d69b5;
--cyan: #74c7ec;
--yellow: #f9e2af;
--green: #a6e3a1;
--green-1: #87c282;
--green-2: #68a364;
}

View File

@ -0,0 +1,84 @@
/**
* @name system24
* @description A tui-style discord theme.
* @author refact0r
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'Agave Nerd Font'; /* UI font name. it must be installed on your system. */
letter-spacing: 0; /* decreases letter spacing for better readability. */
font-weight: 400; /* UI font weight. */
--label-font-weight: 400; /* font weight for panel labels. */
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
--pad: 18px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: #1a1a1a; /* main background color. */
--bg-1: #1a1a1a; /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: #1a1a1a; /* color of neutral buttons. */
--bg-3: #641a1a; /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: #641a1a; /* color of hovered elements. */
--active: #5018dd; /* color of elements when clicked. */
--selected: #E40046; /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: #d6abab; /* color of bright/white text. */
--txt-1: #d6abab;/* main text color. */
--txt-2: #5018dd;/* color of secondary text like channel list. */
--txt-3: #d6abab;/* color of muted text. */
/* accent colors */
--acc-1: #E40046; /* main accent color. */
--acc-0: #5018dd; /* color of accent buttons when hovered. */
--acc-2: #5018dd; /* color of accent buttons when clicked. */
/* borders */
--border-width: 1px; /* panel border thickness. */
--border-color: #5018dd; /* panel border color. */
--border-hover-color: #E40046; /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: #f50505; /* color of mention text. */
--mention-bg: #5018dd; /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(73% 0.12 0);
--pink-1: oklch(63% 0.12 0);
--pink-2: oklch(53% 0.12 0);
--purple: oklch(73% 0.12 300);
--purple-1: oklch(63% 0.12 300);
--purple-2: oklch(53% 0.12 300);
--cyan: oklch(73% 0.12 200);
--yellow: oklch(78% 0.12 80);
--green: hsl(141, 76%, 48%);
--green-1: hsl(141, 73%, 42%);
--green-2: hsl(141, 73%, 36%);
}

View File

@ -0,0 +1,78 @@
/**
* @name system24 (gruvbox material)
* @description A tui-style discord theme. Based on the gruvbox material color palette.
* @author refact0r, Sophed
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
font-weight: 400; /* UI font weight. */
--label-font-weight: 500; /* font weight for panel labels. */
--corner-text: 'gruvbox material'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: #282828; /* main background color. */
--bg-1: #1d2021; /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: #3c3836; /* color of neutral buttons. */
--bg-3: #504945; /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */
--active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--aqua); /* color of links. */
--txt-0: #d4be98; /* color of bright/white text. */
--txt-1: #ddc7a1; /* main text color. */
--txt-2: #d4be98; /* color of secondary text like channel list. */
--txt-3: #928374; /* color of muted text. */
/* accent colors */
--acc-0: var(--aqua); /* main accent color. */
--acc-1: var(--green); /* color of accent buttons when hovered. */
--acc-2: var(--blue); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-2); /* panel border color. */
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--red); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--red: #ea6962;
--purple: #d3869b;
--yellow: #f9e2af;
--aqua: #89b482;
--blue: #7daea3;
--green: #a9b665;
}

View File

@ -0,0 +1,88 @@
/**
* @name system24 (light)
* @description A tui-style discord theme. light mode.
* @author refact0r
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/flavors/light.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
font-weight: 300; /* UI font weight. */
--label-font-weight: 500; /* font weight for panel labels. */
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: oklch(97.6% 0 0); /* main background color. */
--bg-1: oklch(92.6% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: oklch(87.6% 0 0); /* color of neutral buttons. */
--bg-3: oklch(82.6% 0 0); /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: oklch(40% 0 0 / 0.1); /* color of hovered elements. */
--active: oklch(40% 0 0 / 0.2); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: oklch(100% 0 0); /* color of bright/white text. */
--txt-1: oklch(30% 0 0); /* main text color. */
--txt-2: oklch(50% 0 0); /* color of secondary text like channel list. */
--txt-3: oklch(70% 0 0); /* color of muted text. */
/* accent colors */
--acc-0: var(--purple); /* main accent color. */
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
--acc-2: var(--purple-2); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-2); /* panel border color. */
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 85%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 85%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(57% 0.13 0);
--pink-1: oklch(50% 0.13 0);
--pink-2: oklch(43% 0.13 0);
--purple: oklch(57% 0.13 300);
--purple-1: oklch(50% 0.13 300);
--purple-2: oklch(43% 0.13 300);
--cyan: oklch(57% 0.13 200);
--yellow: oklch(71% 0.13 80);
--green: oklch(57% 0.13 160);
--green-1: oklch(50% 0.13 160);
--green-2: oklch(43% 0.13 160);
}
[class^=jumpToPresentBar_] > button {
color: var(--txt-1);
}

View File

@ -0,0 +1,86 @@
/**
* @name system24 (monochrome)
* @description A tui-style discord theme. Has less colors and more neutral greys and whites.
* @author refact0r, DeadGrip
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @authorId 508863359777505290
* @authorLink https://refact0r.dev/
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
font-weight: 300; /* UI font weight. */
--label-font-weight: 500; /* font weight for panel labels. */
--corner-text: 'monochrome'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: oklch(19% 0 0); /* main background color. */
--bg-1: oklch(23% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: oklch(27% 0 0); /* color of neutral buttons. */
--bg-3: oklch(31% 0 0); /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */
--active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--shade-0); /* color of links. */
--txt-0: oklch(90% 0 0); /* color of bright/white text. */
--txt-1: oklch(80% 0 0); /* main text color. */
--txt-2: oklch(60% 0 0); /* color of secondary text like channel list. */
--txt-3: oklch(40% 0 0); /* color of muted text. */
/* accent colors */
--acc-0: var(--shade-0); /* main accent color. */
--acc-1: var(--shade-1); /* color of accent buttons when hovered. */
--acc-2: var(--shade-2); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-3); /* panel border color. */
--border-hover-color: var(--acc-1); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--shade-0); /* color of online dot. */
--dnd-dot: oklch(80% 0.08 0); /* color of do not disturb dot. */
--idle-dot: oklch(80% 0.08 100); /* color of idle dot. */
--streaming-dot: oklch(80% 0.08 300); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(90% 0 0);
--pink-1: oklch(70% 0 0);
--pink-2: oklch(50% 0 0);
--purple: oklch(90% 0 0);
--purple-1: oklch(70% 0 0);
--purple-2: oklch(50% 0 0);
--cyan: oklch(90% 0 0);
--yellow: oklch(90% 0 0);
--green: oklch(90% 0 0);
--green-1: oklch(70% 0 0);
--green-2: oklch(50% 0 0);
--shade-0: oklch(90% 0 0);
--shade-1: oklch(70% 0 0);
--shade-2: oklch(50% 0 0);
}

View File

@ -0,0 +1,87 @@
/**
* @name system24
* @description A tui-style discord theme.
* @author refact0r
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: 0; /* decreases letter spacing for better readability. */
font-weight: 400; /* UI font weight. */
--label-font-weight: 400; /* font weight for panel labels. */
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: #191724; /* main background color. */
--bg-1: #1f1d2e; /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: #26233a; /* color of neutral buttons. */
--bg-3: #403d52; /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: color-mix(in srgb, var(--bg-3), transparent 60%); /* color of hovered elements. */
--active: color-mix(in srgb, var(--bg-3), transparent 80%); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--rose); /* color of links. */
--txt-0: #ffffff; /* color of bright/white text. */
--txt-1: #e0def4; /* main text color. */
--txt-2: #908caa; /* color of secondary text like channel list. */
--txt-3: #6e6a86; /* color of muted text. */
/* accent colors */
--acc-0: var(--rose); /* main accent color. */
--acc-1: var(--rose-1); /* color of accent buttons when hovered. */
--acc-2: var(--rose-2); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-2); /* panel border color. */
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(69.77% 0.1565 4.22);
--pink-1: oklch(59.77% 0.1565 4.22);
--pink-2: oklch(49.77% 0.1565 4.22);
--purple: oklch(77.6% 0.0945 304.99);
--purple-1: oklch(67.6% 0.0945 304.99);
--purple-2: oklch(57.6% 0.0945 304.99);
--cyan: oklch(52.77% 0.0793 227.72);
--yellow: oklch(84.29% 0.11 74.6);
--green: oklch(82.19% 0.0543 209.56);
--green-1: oklch(72.19% 0.0543 209.56);
--green-2: oklch(62.19% 0.0543 209.56);
--rose: oklch(83.63% 0.0544 21.14);
--rose-1: oklch(73.63% 0.0544 21.14);
--rose-2: oklch(63.63% 0.0544 21.14);
}

View File

@ -0,0 +1,84 @@
/**
* @name system24
* @description A tui-style discord theme.
* @author refact0r
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: 0; /* decreases letter spacing for better readability. */
font-weight: 400; /* UI font weight. */
--label-font-weight: 400; /* font weight for panel labels. */
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
--pad: 18px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: hsl(0, 0%, 7%); /* main background color. */
--bg-1: hsl(0, 0%, 10%); /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: hsl(0, 0%, 13%); /* color of neutral buttons. */
--bg-3: hsl(0, 0%, 16%); /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: hsla(0, 0%, 40%, 0.1); /* color of hovered elements. */
--active: hsla(0, 0%, 40%, 0.2); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: hsl(0, 0%, 100%); /* color of bright/white text. */
--txt-1: hsl(0, 0%, 70%); /* main text color. */
--txt-2: hsl(0, 0%, 50%); /* color of secondary text like channel list. */
--txt-3: hsl(0, 0%, 30%); /* color of muted text. */
/* accent colors */
--acc-0: var(--green); /* main accent color. */
--acc-1: var(--green-1); /* color of accent buttons when hovered. */
--acc-2: var(--green-2); /* color of accent buttons when clicked. */
/* borders */
--border-width: 1px; /* panel border thickness. */
--border-color: #535353; /* panel border color. */
--border-hover-color: #1ed760; /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(73% 0.12 0);
--pink-1: oklch(63% 0.12 0);
--pink-2: oklch(53% 0.12 0);
--purple: oklch(73% 0.12 300);
--purple-1: oklch(63% 0.12 300);
--purple-2: oklch(53% 0.12 300);
--cyan: oklch(73% 0.12 200);
--yellow: oklch(78% 0.12 80);
--green: hsl(141, 76%, 48%);
--green-1: hsl(141, 73%, 42%);
--green-2: hsl(141, 73%, 36%);
}

View File

@ -0,0 +1,84 @@
/**
* @name system24
* @description A tui-style discord theme.
* @author refact0r, Liyua
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {{
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
font-weight: 300; /* UI font weight. */
--label-font-weight: 500; /* font weight for panel labels. */
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: oklch(from {background} l c h); /* main background color. */
--bg-1: oklch(from {background} calc(l + 0.04) c h); /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: oklch(from {background} calc(l + 0.08) c h); /* color of neutral buttons. */
--bg-3: oklch(from {background} calc(l + 0.12) c h); /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */
--active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: oklch(from {foreground} 100% c h); /* color of bright/white text. */
--txt-1: oklch(from {foreground} 80% c h); /* main text color. */
--txt-2: oklch(from {foreground} 60% c h); /* color of secondary text like channel list. */
--txt-3: oklch(from {foreground} 40% c h); /* color of muted text. */
/* accent colors */
--acc-0: var(--purple); /* main accent color. */
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
--acc-2: var(--purple-2); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-3); /* panel border color. */
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(from {color0} l c h);
--pink-1: oklch(from var(--pink) calc(l - 0.1) c h);
--pink-2: oklch(from var(--pink) calc(l - 0.2) c h);
--purple: oklch(from {color1} l c h);
--purple-1: oklch(from var(--purple) calc(l - 0.1) c h);
--purple-2: oklch(from var(--purple) calc(l - 0.2) c h);
--cyan: oklch(from {color2} l c h);
--yellow: oklch(from {color3} l c h);
--green: oklch(from {color4} l c h);
--green-1: oklch(from var(--green) calc(l - 0.1) c h);
--green-2: oklch(from var(--green) calc(l - 0.2) c h);
}}

View File

@ -0,0 +1,83 @@
/**
* @name system24 (vencord)
* @description A tui-style discord theme. Based on Vencord's color palette. (https://vencord.dev/)
* @author refact0r, makimwah
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
font-weight: 300; /* UI font weight. */
--label-font-weight: 500; /* font weight for panel labels. */
--corner-text: 'vencord24'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: oklch(28% 0.002 48); /* main background color. */
--bg-1: oklch(25% 0.001 48); /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: oklch(34% 0.004 48); /* color of neutral buttons. */
--bg-3: oklch(38% 0.006 48); /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: oklch(54% 0.006 48 / 0.1); /* color of hovered elements. */
--active: oklch(54% 0.006 48 / 0.2); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: oklch(90% 0 0); /* color of bright/white text. */
--txt-1: #ddc7a0; /* main text color. */
--txt-2: #918274; /* color of secondary text like channel list. */
--txt-3: #918274; /* color of muted text. */
/* accent colors */
--acc-0: var(--purple); /* main accent color. */
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
--acc-2: var(--purple-1); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-2); /* panel border color. */
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: #ea6962;
--pink-1: oklch(63% 0.12 0);
--pink-2: oklch(53% 0.12 0);
--purple: #d3869b;
--purple-1: #d27390;
--cyan: #7caea3;
--yellow: #d8a656;
--green: #a8b665;
--green-1: oklch(63% 0.12 160);
--green-2: oklch(53% 0.12 160);
}

View File

@ -0,0 +1,84 @@
/**
* @name system24
* @description A tui-style discord theme.
* @author refact0r
* @version 1.0.0
* @invite nz87hXyvcy
* @website https://github.com/refact0r/system24
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
* @authorId 508863359777505290
* @authorLink https://www.refact0r.dev
*/
/* import theme modules */
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
/* customize things here */
:root {
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
font-weight: 300; /* UI font weight. */
--label-font-weight: 500; /* font weight for panel labels. */
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
--pad: 16px; /* padding between panels. */
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
/* background colors */
--bg-0: oklch(19% 0 0); /* main background color. */
--bg-1: oklch(23% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */
--bg-2: oklch(27% 0 0); /* color of neutral buttons. */
--bg-3: oklch(31% 0 0); /* color of neutral buttons when hovered. */
/* state modifiers */
--hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */
--active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */
--selected: var(--active); /* color of selected elements. */
/* text colors */
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
--txt-link: var(--cyan); /* color of links. */
--txt-0: oklch(90% 0 0); /* color of bright/white text. */
--txt-1: oklch(80% 0 0); /* main text color. */
--txt-2: oklch(60% 0 0); /* color of secondary text like channel list. */
--txt-3: oklch(40% 0 0); /* color of muted text. */
/* accent colors */
--acc-0: var(--purple); /* main accent color. */
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
--acc-2: var(--purple-2); /* color of accent buttons when clicked. */
/* borders */
--border-width: 2px; /* panel border thickness. */
--border-color: var(--bg-3); /* panel border color. */
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
--border-transition: 0.2s ease; /* panel border transition. */
/* status dot colors */
--online-dot: var(--green); /* color of online dot. */
--dnd-dot: var(--pink); /* color of do not disturb dot. */
--idle-dot: var(--yellow); /* color of idle dot. */
--streaming-dot: var(--purple); /* color of streaming dot. */
/* mention/ping and message colors */
--mention-txt: var(--acc-0); /* color of mention text. */
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
/* color shades */
--pink: oklch(73% 0.12 0);
--pink-1: oklch(63% 0.12 0);
--pink-2: oklch(53% 0.12 0);
--purple: oklch(73% 0.12 300);
--purple-1: oklch(63% 0.12 300);
--purple-2: oklch(53% 0.12 300);
--cyan: oklch(73% 0.12 200);
--yellow: oklch(78% 0.12 80);
--green: oklch(73% 0.12 160);
--green-1: oklch(63% 0.12 160);
--green-2: oklch(53% 0.12 160);
}

View File

@ -0,0 +1 @@
/home/themiro/Dotfiles/desktopenvs/hyprland/alacritty

View File

@ -0,0 +1,44 @@
[font]
normal = {family = "Agave NerdFont", style = "Regular"}
[terminal]
[terminal.shell]
program = "/usr/bin/zsh"
args = ["-l"]
[window]
opacity = 0.3
# CyberQueer — matches ../kitty/themes/cyberqueer.conf and ../ghostty/config
# (colors.conf COLOR_TEXT/BG/HIGHLIGHT/DARK/RED plus the same neon tints)
[colors.primary]
background = "#1A1A1A"
foreground = "#D6ABAB"
[colors.cursor]
cursor = "#E40046"
text = "#5018DD"
[colors.selection]
background = "#5018DD"
text = "#E40046"
[colors.normal]
black = "#1A1A1A"
red = "#E40046"
green = "#33E6A0"
yellow = "#E8B23D"
blue = "#5018DD"
magenta = "#A6178C"
cyan = "#34C6C6"
white = "#D6ABAB"
[colors.bright]
black = "#4B3A55"
red = "#F50505"
green = "#5CFFC2"
yellow = "#FFD873"
blue = "#8C6FFF"
magenta = "#FF5FC0"
cyan = "#6FF5F0"
white = "#F2DEDE"

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -0,0 +1,60 @@
# astro-menu
A touch-friendly GTK4 popup control centre for the hyprlua desktop, replacing
`nwg-dock` and `nwg-drawer`. Triggered from the EWW top bar (or `Super+D`).
Layout: a **2×2 quad grid** of feature modules over a full-width **application
drawer**. Any quad can expand to overlay the other three; the drawer can expand to
the bottom of the screen. A single margined root box letterboxes the menu identically
in every state.
## Stack
- **Frontend:** Python + PyGObject + **GTK4**, as a `wlr-layer-shell` surface
(`gtk4-layer-shell`). Chosen over Lua because lgi/Astal-Lua only support GTK3.
- **Location map:** a static OSM image stitched from tiles (`backend/staticmap.py`,
Pillow). libshumate does not paint tiles in this environment (the official
`shumate-demo` shows the same blank map), though tile downloads work — hence the
static fallback.
- **Services:** Astal GObject libraries via introspection — `AstalNetwork`,
`AstalBluetooth`, `AstalApps` — plus our own IP-geolocation singleton.
- **Backends:** Python/Bash scripts in `backend/` (JSON on stdout), run async via
`Gio.Subprocess` so nothing blocks the UI.
- **Theme:** `style/_colors.css` (`@define-color`, generated from
`~/Dotfiles/colors.conf` by `apply-theme.sh`) + `style/style.css`.
## Running
`main.py` is single-instance. The autostart launches a hidden resident daemon;
verbs are forwarded over D-Bus:
scripts/astro-menu-start.sh # resident daemon (hidden); sets LD_PRELOAD
scripts/menu-toggle.sh # --toggle
scripts/menu-toggle.sh appdrawer # open with the app drawer expanded
`astro-menu-start.sh` must `LD_PRELOAD` libgtk4-layer-shell (it loads after
libwayland under PyGObject otherwise).
## Adding a module
1. Create `modules/<name>.py` exposing a top-level `SPEC = ModuleSpec(...)` whose
`build(ctx)` returns a `ModuleInstance(compact=…, expanded=…, …)`.
- `compact` shows in the 2×2 cell; a distinct `expanded` widget enables the
expand button (they must be separate instances — GTK widgets have one parent).
- Declare per-feature toggles via `features=[Feature("id", "Label", default)]`;
read them with `ctx.feature("id")`. A disabled quad never calls `build()`.
- Shared state (network, bluetooth, location) is on `ctx.services`.
2. Append its `SPEC` to `ALL_SPECS` in `registry.py`. Nothing else changes.
## Files
main.py app + single-instance IPC window.py layer-shell + letterbox
registry.py module list (extension seam) settings.py JSON toggles/order
module_base.py ModuleSpec / ModuleInstance / ModuleContext
appservices.py shared Astal services + location
ui/ quadcard, quadgrid, appdrawer
modules/ location, weather, bluetooth, network
services/ location (geolocation singleton)
lib/ ansi (SGR→TextTag), proc (async subprocess)
backend/ geolocate.py, weather.sh, network.sh
style/ _colors.css, style.css

View File

@ -0,0 +1,35 @@
"""Lazily-built shared services handed to every module via ModuleContext.
Astal's GObject service libraries (AstalNetwork, AstalBluetooth) are consumed here
through GObject-introspection; modules connect to their `notify::` signals instead
of polling. The location service is our own singleton.
"""
from __future__ import annotations
import gi
gi.require_version("AstalNetwork", "0.1")
gi.require_version("AstalBluetooth", "0.1")
from gi.repository import AstalBluetooth, AstalNetwork # noqa: E402
from services.location import get_location_service
class Services:
def __init__(self) -> None:
self._network = None
self._bluetooth = None
self.location = get_location_service()
@property
def network(self):
if self._network is None:
self._network = AstalNetwork.Network.get_default()
return self._network
@property
def bluetooth(self):
if self._bluetooth is None:
self._bluetooth = AstalBluetooth.Bluetooth.get_default()
return self._bluetooth

View File

@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""IP geolocation, cached with a TTL.
Discovers the public IP by tracerouting to 1.1.1.1 and taking the first
globally-routable hop (the ISP egress nearest the user), then resolves that IP to a
location through a public geolocation API. Falls back to locating this host's own
public IP when traceroute is unavailable or yields no public hop.
Prints JSON: {lat, lon, city, country, ip, source}. Diagnostics go to stderr,
non-zero exit on total failure. Used by services/location.py (and runnable
standalone for testing).
"""
from __future__ import annotations
import ipaddress
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "location.json"
TTL = 1800 # seconds
TARGET = "1.1.1.1"
_IPV4 = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b")
# Geolocation providers. Each builds a URL for a given IP; an empty IP asks the
# provider to resolve the caller's own public address (the self-IP fallback).
PROVIDERS = [
("ip-api",
lambda ip: f"http://ip-api.com/json/{ip}?fields=lat,lon,city,country,query",
lambda d: {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"),
"country": d.get("country"), "ip": d.get("query")}),
("ipapi.co",
lambda ip: f"https://ipapi.co/{ip}/json/" if ip else "https://ipapi.co/json/",
lambda d: {"lat": d["latitude"], "lon": d["longitude"], "city": d.get("city"),
"country": d.get("country_name"), "ip": d.get("ip")}),
("ipinfo",
lambda ip: f"https://ipinfo.io/{ip}/json" if ip else "https://ipinfo.io/json",
lambda d: {"lat": float(d["loc"].split(",")[0]), "lon": float(d["loc"].split(",")[1]),
"city": d.get("city"), "country": d.get("country"), "ip": d.get("ip")}),
]
def _cached() -> dict | None:
try:
blob = json.loads(CACHE.read_text())
if time.time() - blob.get("_ts", 0) < TTL:
return blob["data"]
except (FileNotFoundError, json.JSONDecodeError, KeyError):
pass
return None
def _store(data: dict) -> None:
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_text(json.dumps({"_ts": time.time(), "data": data}))
def _public_hop_ip() -> str | None:
"""First globally-routable hop on the path to 1.1.1.1 — i.e. the ISP egress
closest to the user. The private/CGNAT hops before it and the anycast target
itself (Cloudflare, useless for locating the user) are skipped."""
try:
proc = subprocess.run(
["traceroute", "-n", "-q", "1", "-w", "2", "-m", "12", TARGET],
capture_output=True, text=True, timeout=40,
)
except (FileNotFoundError, subprocess.SubprocessError) as exc:
print(f"traceroute: {exc}", file=sys.stderr)
return None
for line in proc.stdout.splitlines():
if line.lower().startswith("traceroute to"):
continue # header line names the target; not a hop
for ip in _IPV4.findall(line):
if ip == TARGET:
continue
try:
if ipaddress.ip_address(ip).is_global:
return ip
except ValueError:
continue
return None
def _fetch(url: str) -> dict:
req = urllib.request.Request(url, headers={"User-Agent": "astro-menu/1.0"})
with urllib.request.urlopen(req, timeout=8) as resp:
return json.loads(resp.read().decode())
def main() -> int:
if "--no-cache" not in sys.argv:
hit = _cached()
if hit:
print(json.dumps(hit))
return 0
hop = _public_hop_ip()
# Try the traced public hop first, then fall back to our own public IP.
candidates = ([hop] if hop else []) + [""]
for ip in candidates:
for name, url_of, parse in PROVIDERS:
try:
data = parse(_fetch(url_of(ip)))
if data.get("lat") is None or data.get("lon") is None:
raise ValueError("no coordinates")
data["source"] = f"{name} via {ip}" if ip else name
_store(data)
print(json.dumps(data))
return 0
except Exception as exc: # noqa: BLE001 — try the next provider/candidate
print(f"{name}({ip or 'self'}): {exc}", file=sys.stderr)
print("all geolocation attempts failed", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Networking backend for the astro-menu Network quad.
# Each subcommand prints JSON (or plain text for pubip) to stdout. Wi-Fi listing and
# state come from AstalNetwork in the UI; this covers the rest (ip/routes/ports,
# public IP, wifi connect via nmcli, and the manual/DHCP switch).
set -euo pipefail
active_conn() {
# First active connection on a real device (wifi/ethernet).
nmcli -t -g NAME,TYPE,DEVICE connection show --active 2>/dev/null \
| awk -F: '$2 ~ /wireless|ethernet/ {print $1; exit}'
}
cmd="${1:-}"; shift || true
case "$cmd" in
ip)
ip -j addr show 2>/dev/null || echo '[]'
;;
routes)
ip -j route show 2>/dev/null || echo '[]'
;;
ports)
# Listening TCP/UDP sockets -> JSON array.
ss -tulnH 2>/dev/null | awk '
BEGIN { print "["; sep="" }
{ printf "%s{\"proto\":\"%s\",\"local\":\"%s\"}", sep, $1, $5; sep="," }
END { print "]" }'
;;
pubip)
curl -sf --max-time 8 https://ifconfig.co 2>/dev/null \
|| curl -sf --max-time 8 https://api.ipify.org 2>/dev/null \
|| echo "unavailable"
;;
wifi-connect)
ssid="${1:-}"; pass="${2:-}"
if [[ -n "$pass" ]]; then
nmcli dev wifi connect "$ssid" password "$pass"
else
nmcli dev wifi connect "$ssid"
fi
;;
wifi-disconnect)
con="$(active_conn)"
[[ -n "$con" ]] && nmcli connection down "$con"
;;
set-manual)
con="${4:-$(active_conn)}"; addr="${1:-}"; gw="${2:-}"; dns="${3:-}"
[[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; }
nmcli connection modify "$con" ipv4.method manual \
ipv4.addresses "$addr" ipv4.gateway "$gw" ipv4.dns "$dns"
nmcli connection up "$con"
;;
set-dhcp)
con="${1:-$(active_conn)}"
[[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; }
nmcli connection modify "$con" ipv4.method auto
nmcli connection up "$con"
;;
active-conn)
active_conn
;;
*)
echo "usage: network.sh {ip|routes|ports|pubip|wifi-connect|wifi-disconnect|set-manual|set-dhcp|active-conn}" >&2
exit 2
;;
esac

View File

@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""NetworkManager backend for the Network quad's expanded settings.
Every subcommand prints JSON to stdout ({"ok": bool, ...} for mutations) and
diagnostics to stderr. Reads use `nmcli -g` (raw, unescaped single fields) and
`ip -j`; writes go through `nmcli connection modify/up`, so persistence and polkit
auth are handled by NetworkManager. Kept as one script so the UI has a single,
stable contract (see modules/network.py).
adapters list manageable devices + their connection
ipconfig <con> ipv4/ipv6 method/addresses/gateway/dns
set-ip <con> ipv4|ipv6 <method> [addr_cidr] [gateway] [dns_space_sep]
routes kernel routing table (read-only)
vlans configured VLAN connections
vlan-add <parent_dev> <id> [name] create + bring up a VLAN
vlan-del <name> delete a VLAN connection
vlan-up <name> activate a VLAN connection
dns effective DNS servers per device
set-dns <con> ipv4|ipv6 <servers_space_sep> <yes|no ignore-auto>
"""
from __future__ import annotations
import json
import subprocess
import sys
def _run(argv: list[str], timeout: int = 20) -> tuple[bool, str, str]:
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
except (FileNotFoundError, subprocess.SubprocessError) as exc:
return False, "", str(exc)
return p.returncode == 0, p.stdout, p.stderr
def _get(field: str, con: str) -> str:
ok, out, _ = _run(["nmcli", "-g", field, "connection", "show", con])
return out.strip() if ok else ""
def _list(field: str, con: str) -> list[str]:
raw = _get(field, con)
return [v.strip() for v in raw.replace(",", " ").split() if v.strip()]
def _emit(obj) -> int:
print(json.dumps(obj))
return 0
def _ok(ok: bool, err: str = "") -> int:
print(json.dumps({"ok": bool(ok), "error": err.strip()}))
return 0 if ok else 1
# -- reads -----------------------------------------------------------------
def adapters() -> int:
ok, out, _ = _run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
rows = []
if ok:
for line in out.splitlines():
# DEVICE may contain escaped colons (bt MACs); split from the right on
# the three trailing fields we know are colon-free-ish.
parts = line.split(":")
if len(parts) < 4:
continue
connection = parts[-1]
state = parts[-2]
typ = parts[-3]
device = ":".join(parts[:-3]).replace("\\:", ":")
if typ not in ("ethernet", "wifi"):
continue
rows.append({"device": device, "type": typ, "state": state,
"connection": connection.replace("\\:", ":")})
return _emit(rows)
def ipconfig(con: str) -> int:
def family(fam: str) -> dict:
return {
"method": _get(f"{fam}.method", con) or "auto",
"addresses": _list(f"{fam}.addresses", con),
"gateway": _get(f"{fam}.gateway", con),
"dns": _list(f"{fam}.dns", con),
"ignore_auto_dns": _get(f"{fam}.ignore-auto-dns", con) == "yes",
}
return _emit({"connection": con, "ipv4": family("ipv4"), "ipv6": family("ipv6")})
def routes() -> int:
ok, out, err = _run(["ip", "-j", "route", "show"])
if not ok:
return _emit([])
try:
return _emit(json.loads(out))
except json.JSONDecodeError:
return _emit([])
def vlans() -> int:
ok, out, _ = _run(["nmcli", "-t", "-f", "NAME,TYPE,DEVICE,ACTIVE", "connection", "show"])
rows = []
if ok:
for line in out.splitlines():
parts = line.rsplit(":", 3)
if len(parts) < 4 or parts[1] != "vlan":
continue
name, _typ, device, active = parts
rows.append({"name": name, "device": device, "active": active == "yes"})
return _emit(rows)
def dns() -> int:
"""Effective DNS servers, per device, from `nmcli device show`."""
ok, out, _ = _run(["nmcli", "-t", "-f",
"GENERAL.DEVICE,IP4.DNS,IP6.DNS", "device", "show"])
devices: dict[str, dict] = {}
cur = None
if ok:
for line in out.splitlines():
if ":" not in line:
continue
key, _, val = line.partition(":")
val = val.strip()
if key == "GENERAL.DEVICE":
cur = val
devices.setdefault(cur, {"device": cur, "servers": []})
elif cur and key.startswith(("IP4.DNS", "IP6.DNS")) and val:
devices[cur]["servers"].append(val)
return _emit([d for d in devices.values() if d["servers"]])
# -- writes ----------------------------------------------------------------
def set_ip(con: str, fam: str, method: str, addr: str = "",
gateway: str = "", dns: str = "") -> int:
if fam not in ("ipv4", "ipv6"):
return _ok(False, "family must be ipv4 or ipv6")
args = ["nmcli", "connection", "modify", con, f"{fam}.method", method]
if method == "manual":
args += [f"{fam}.addresses", addr or ""]
args += [f"{fam}.gateway", gateway or ""]
if dns:
args += [f"{fam}.dns", dns]
else: # auto: clear any manual leftovers so DHCP is clean
args += [f"{fam}.addresses", "", f"{fam}.gateway", ""]
ok, _, err = _run(args)
if ok:
ok, _, err = _run(["nmcli", "connection", "up", con])
return _ok(ok, err)
def set_dns(con: str, fam: str, servers: str, ignore_auto: str) -> int:
if fam not in ("ipv4", "ipv6"):
return _ok(False, "family must be ipv4 or ipv6")
ok, _, err = _run(["nmcli", "connection", "modify", con,
f"{fam}.dns", servers,
f"{fam}.ignore-auto-dns", "yes" if ignore_auto == "yes" else "no"])
if ok:
ok, _, err = _run(["nmcli", "connection", "up", con])
return _ok(ok, err)
def vlan_add(parent: str, vid: str, name: str = "") -> int:
name = name or f"vlan{vid}"
ok, _, err = _run(["nmcli", "connection", "add", "type", "vlan",
"con-name", name, "dev", parent, "id", str(vid)])
if ok:
ok, _, err = _run(["nmcli", "connection", "up", name])
return _ok(ok, err)
def vlan_del(name: str) -> int:
ok, _, err = _run(["nmcli", "connection", "delete", name])
return _ok(ok, err)
def vlan_up(name: str) -> int:
ok, _, err = _run(["nmcli", "connection", "up", name])
return _ok(ok, err)
def main(argv: list[str]) -> int:
if not argv:
print("no subcommand", file=sys.stderr)
return 2
cmd, args = argv[0], argv[1:]
table = {
"adapters": lambda: adapters(),
"ipconfig": lambda: ipconfig(args[0]),
"set-ip": lambda: set_ip(*args),
"routes": lambda: routes(),
"vlans": lambda: vlans(),
"vlan-add": lambda: vlan_add(*args),
"vlan-del": lambda: vlan_del(args[0]),
"vlan-up": lambda: vlan_up(args[0]),
"dns": lambda: dns(),
"set-dns": lambda: set_dns(*args),
}
fn = table.get(cmd)
if fn is None:
print(f"unknown subcommand: {cmd}", file=sys.stderr)
return 2
try:
return fn()
except TypeError as exc:
print(f"bad arguments for {cmd}: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Render a static slippy-map image centred on a coordinate, with a marker.
Used by the Location quad because libshumate does not render tiles in this
environment (the official shumate-demo shows the same blank map a library/GTK
render bug), while plain tile downloads work fine. Stitches raster tiles into one
PNG with Pillow and caches them. The quad re-renders this at a new centre to pan.
staticmap.py <center_lat> <center_lon> <zoom> <w> <h> <out> \
[style] [marker_lat] [marker_lon]
style: dark (default, CartoDB dark_matter) | satellite (Esri) | standard (OSM)
marker_*: where to draw the location pin; defaults to the centre. When the centre
is panned away, the pin clamps to the nearest edge so it stays visible.
"""
from __future__ import annotations
import io
import math
import os
import sys
import urllib.request
from pathlib import Path
from PIL import Image, ImageDraw
TILE = 256
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "tiles"
UA = "astro-menu/1.0 (personal dotfiles)"
BG = (26, 26, 26)
ACCENT = (228, 0, 70)
STYLES = {
"standard": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"dark": "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"satellite": "https://server.arcgisonline.com/ArcGIS/rest/services/"
"World_Imagery/MapServer/tile/{z}/{y}/{x}",
}
def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
n = 2 ** zoom
lat_r = math.radians(lat)
x = (lon + 180.0) / 360.0 * n
y = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
return x * TILE, y * TILE
def _tile(z: int, x: int, y: int, style: str) -> Image.Image | None:
CACHE.mkdir(parents=True, exist_ok=True)
fp = CACHE / f"{style}_{z}_{x}_{y}.png"
if fp.exists():
try:
return Image.open(fp).convert("RGB")
except Exception:
pass
url = STYLES[style].format(z=z, x=x, y=y)
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
data = urllib.request.urlopen(req, timeout=8).read()
except Exception as exc:
print(f"tile {style} {z}/{x}/{y}: {exc}", file=sys.stderr)
return None
img = Image.open(io.BytesIO(data)).convert("RGB")
img.save(fp) # normalise to PNG in the cache (satellite is served as JPEG)
return img
def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str,
style: str = "dark", marker: tuple[float, float] | None = None) -> None:
if style not in STYLES:
style = "dark"
n = 2 ** zoom
cx, cy = _center_px(lat, lon, zoom)
left, top = cx - w / 2, cy - h / 2
img = Image.new("RGB", (w, h), BG)
x0, x1 = int(left // TILE), int((left + w) // TILE)
y0, y1 = int(top // TILE), int((top + h) // TILE)
for tx in range(x0, x1 + 1):
for ty in range(y0, y1 + 1):
if ty < 0 or ty >= n:
continue
tile = _tile(zoom, tx % n, ty, style)
if tile is None:
continue
img.paste(tile, (int(tx * TILE - left), int(ty * TILE - top)))
# marker at the real location (defaults to centre); clamp to the edge so it stays
# on-screen when the view is panned away from it.
mlat, mlon = marker if marker else (lat, lon)
mwx, mwy = _center_px(mlat, mlon, zoom)
r = 8
mx = max(r, min(w - r, mwx - left))
my = max(r, min(h - r, mwy - top))
d = ImageDraw.Draw(img)
d.ellipse([mx - r, my - r, mx + r, my + r], fill=ACCENT, outline=(255, 255, 255), width=2)
Path(out).parent.mkdir(parents=True, exist_ok=True)
img.save(out)
if __name__ == "__main__":
a = sys.argv
style = a[7] if len(a) > 7 else "dark"
marker = (float(a[8]), float(a[9])) if len(a) > 9 else None
render(float(a[1]), float(a[2]), int(a[3]), int(a[4]), int(a[5]), a[6], style, marker)
print(a[6])

View File

@ -0,0 +1,101 @@
#!/usr/bin/env bash
# astro-menu system-monitor backend — the ship's "Systems Diagnostic".
# Emits one TAB-separated record per subsystem:
#
# CATEGORY <TAB> NAME <TAB> USAGE_PCT <TAB> TEMP_C <TAB> DETAIL
#
# CATEGORY is cpu|gpu|ram|disk (drives the icon). USAGE_PCT is 0..100. TEMP_C is
# an integer °C or empty when no sensor exists. One `disk` row per mounted real
# filesystem. Reads everything from /proc + /sys (no extra deps).
set -uo pipefail
milli_to_c() { awk '{printf "%d", ($1 + 500) / 1000}'; }
hwmon_temp() { # $1 = chip-name substring; first temp input of that chip, in °C
local h n t
for h in /sys/class/hwmon/hwmon*; do
n=$(cat "$h/name" 2>/dev/null) || continue
[[ "$n" == *"$1"* ]] || continue
for t in "$h"/temp*_input; do
[[ -r "$t" ]] && { milli_to_c < "$t"; return 0; }
done
done
return 1
}
hwmon_dir_temp() { # $1 = hwmon dir, $2 = preferred label substring
local d="$1" pref="$2" lf lbl base
for lf in "$d"/temp*_label; do
[[ -r "$lf" ]] || continue
[[ "$(cat "$lf" 2>/dev/null)" == *"$pref"* ]] || continue
base="${lf%_label}_input"
[[ -r "$base" ]] && { milli_to_c < "$base"; return 0; }
done
for base in "$d"/temp*_input; do
[[ -r "$base" ]] && { milli_to_c < "$base"; return 0; }
done
return 1
}
nvme_temp() { # $1 = disk (e.g. nvme0n1); temp of that specific controller, in °C
local ctrl h t
ctrl=$(basename "$(readlink -f "/sys/block/$1/device" 2>/dev/null)")
for h in /sys/class/hwmon/hwmon*; do
[[ "$(cat "$h/name" 2>/dev/null)" == "nvme" ]] || continue
[[ "$(basename "$(readlink -f "$h/device" 2>/dev/null)")" == "$ctrl" ]] || continue
for t in "$h"/temp*_input; do
[[ -r "$t" ]] && { milli_to_c < "$t"; return 0; }
done
done
return 1
}
# --- CPU: Thrusters ---------------------------------------------------------
cpu_snapshot() { awk '/^cpu /{t=0; for (i=2; i<=NF; i++) t+=$i; print t, $5}' /proc/stat; }
read t1 idle1 < <(cpu_snapshot); sleep 0.2; read t2 idle2 < <(cpu_snapshot)
dtotal=$(( t2 - t1 )); didle=$(( idle2 - idle1 )); cpu_usage=0
(( dtotal > 0 )) && cpu_usage=$(( ((dtotal - didle) * 100 + dtotal / 2) / dtotal ))
cpu_temp=$(hwmon_temp k10temp) || cpu_temp=$(hwmon_temp coretemp) || cpu_temp=""
printf 'cpu\tThrusters\t%s\t%s\t%s cores online\n' "$cpu_usage" "$cpu_temp" "$(nproc 2>/dev/null || echo '?')"
# --- GPU: Hyperdrive (discrete = the card with the most VRAM) ---------------
gpu_dev=""; best_vram=-1
for d in /sys/class/drm/card[0-9]*/device; do
[[ -r "$d/gpu_busy_percent" ]] || continue
v=$(cat "$d/mem_info_vram_total" 2>/dev/null || echo 0)
(( v > best_vram )) && { best_vram=$v; gpu_dev=$d; }
done
gpu_usage=0; gpu_temp=""; gpu_detail="offline"
if [[ -n "$gpu_dev" ]]; then
gpu_usage=$(cat "$gpu_dev/gpu_busy_percent" 2>/dev/null || echo 0)
for hd in "$gpu_dev"/hwmon/hwmon*; do
[[ -d "$hd" ]] && { gpu_temp=$(hwmon_dir_temp "$hd" junction) || gpu_temp=""; break; }
done
(( best_vram > 0 )) && gpu_detail=$(awk -v b="$best_vram" 'BEGIN{printf "%.0f GiB core", b/1073741824}')
fi
printf 'gpu\tHyperdrive\t%s\t%s\t%s\n' "$gpu_usage" "$gpu_temp" "$gpu_detail"
# --- RAM: Life Support Systems ---------------------------------------------
read ram_used ram_total < <(free -b | awk '/^Mem:/{print $3, $2}')
ram_usage=0; (( ram_total > 0 )) && ram_usage=$(( (ram_used * 100 + ram_total / 2) / ram_total ))
ram_temp=$(hwmon_temp spd5118) || ram_temp=$(hwmon_temp jc42) || ram_temp=""
ram_detail=$(awk -v u="$ram_used" -v t="$ram_total" 'BEGIN{printf "%.1f/%.1f GiB", u/1073741824, t/1073741824}')
printf 'ram\tLife Support Systems\t%s\t%s\t%s\n' "$ram_usage" "$ram_temp" "$ram_detail"
# --- Storage: Cargo Hold (one row per real filesystem, deduped by device) ---
declare -A seen
while read -r src used size; do
[[ "$src" == /dev/* ]] || continue
[[ -n "${seen[$src]:-}" ]] && continue
seen[$src]=1
dev=$(basename "$src") # e.g. sda1 / nvme0n1p2
disk=$(lsblk -no pkname "$src" 2>/dev/null | head -1) # parent disk, if partitioned
[[ -z "$disk" ]] && disk="$dev"
(( size > 0 )) || continue
usage=$(( (used * 100 + size / 2) / size ))
temp=""; [[ "$disk" == nvme* ]] && { temp=$(nvme_temp "$disk") || temp=""; }
[[ -z "$temp" ]] && temp=$(hwmon_temp drivetemp 2>/dev/null) || true
detail=$(awk -v d="$dev" -v u="$used" -v t="$size" \
'BEGIN{printf "%-10s %.0f/%.0f GiB", d, u/1073741824, t/1073741824}')
printf 'disk\tCargo Hold\t%s\t%s\t%s\n' "$usage" "$temp" "$detail"
done < <(df -B1 --output=source,used,size -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null | tail -n +2)

View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Weather backend: wttr.in with its native ANSI art (curl UA gets terminal colours).
# Args: $1 = location (may be empty -> wttr.in geolocates the caller), $2 = query opts.
# Prints the wttr.in body (ANSI or plain, per opts) to stdout.
set -uo pipefail
loc="${1:-}"
# Note: ${2-0}, not ${2:-0} — the expanded view passes an *empty* opts to request
# wttr.in's default 3-day forecast, and :- would wrongly rewrite that empty string
# to "0" (current conditions only). Default to "0" only when no opts arg is given.
opts="${2-0}"
# URL-encode spaces in a city name.
loc="${loc// /+}"
url="https://wttr.in/${loc}?${opts}"
# -A curl makes wttr.in return terminal (ANSI) output regardless of the real UA.
curl -sf -A curl --max-time 10 "$url"

View File

@ -0,0 +1,31 @@
"""Tiny user-editable config file: ~/.local/state/astro-menu/config.json.
Same pattern as orbit-menu/config.py and horizon-dock/config.py. Deliberately
separate from settings.py's Settings class: that one persists quad enable/
feature toggles and favorites (module-scoped, read via ctx.feature()), while
this is a single whole-window flag read once at startup.
"""
from __future__ import annotations
import json
from paths import CONFIG_FILE, ensure_dirs
_DEFAULTS = {"hologram": True}
def _load() -> dict:
try:
data = json.loads(CONFIG_FILE.read_text())
except (FileNotFoundError, json.JSONDecodeError):
data = {}
merged = {**_DEFAULTS, **data}
if not CONFIG_FILE.exists():
ensure_dirs()
CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
return merged
def hologram_enabled() -> bool:
return bool(_load().get("hologram", True))

View File

@ -0,0 +1,138 @@
"""Render ANSI/SGR-coloured terminal text (e.g. wttr.in) into a Gtk.TextView.
wttr.in returns real terminal escape sequences when curled. We parse the SGR
subset it uses (basic 8/16 colours, xterm-256, truecolor, bold, reset) and emit
Gtk.TextTags so the CLI art keeps its colours and block-glyph alignment. This is
deliberately reusable by any future "show me some CLI art" widget.
"""
from __future__ import annotations
import re
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402
_SGR = re.compile(r"\x1b\[([0-9;]*)m")
# xterm 16-colour base palette (approx, tuned to the CyberQueer dark background).
_BASE16 = [
(0x00, 0x00, 0x00), (0xCC, 0x24, 0x24), (0x33, 0xCC, 0x33), (0xCC, 0xCC, 0x33),
(0x33, 0x66, 0xCC), (0xCC, 0x33, 0xCC), (0x33, 0xCC, 0xCC), (0xD6, 0xAB, 0xAB),
(0x66, 0x66, 0x66), (0xF5, 0x05, 0x05), (0x55, 0xFF, 0x55), (0xFF, 0xFF, 0x55),
(0x55, 0x88, 0xFF), (0xE4, 0x00, 0x46), (0x55, 0xFF, 0xFF), (0xFF, 0xFF, 0xFF),
]
def _xterm256(n: int) -> tuple[int, int, int]:
if n < 16:
return _BASE16[n]
if n < 232: # 6x6x6 colour cube
n -= 16
r, g, b = n // 36, (n // 6) % 6, n % 6
conv = lambda c: 55 + c * 40 if c else 0
return conv(r), conv(g), conv(b)
v = 8 + (n - 232) * 10 # grayscale ramp
return v, v, v
def _rgba(rgb: tuple[int, int, int]) -> Gdk.RGBA:
c = Gdk.RGBA()
c.red, c.green, c.blue, c.alpha = rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1.0
return c
class AnsiRenderer:
"""Owns a TextView and repaints it from ANSI text. Tags are cached by state."""
def __init__(self) -> None:
self.view = Gtk.TextView(
editable=False, cursor_visible=False, monospace=True,
wrap_mode=Gtk.WrapMode.NONE,
)
self.view.add_css_class("ansi-view")
self.buffer = self.view.get_buffer()
self._tag_cache: dict[tuple, Gtk.TextTag] = {}
def _tag(self, fg, bg, bold) -> Gtk.TextTag | None:
if fg is None and bg is None and not bold:
return None
key = (fg, bg, bold)
tag = self._tag_cache.get(key)
if tag is None:
tag = self.buffer.create_tag()
if fg is not None:
tag.set_property("foreground-rgba", _rgba(fg))
if bg is not None:
tag.set_property("background-rgba", _rgba(bg))
if bold:
tag.set_property("weight", 700)
self._tag_cache[key] = tag
return tag
def set_text(self, text: str) -> None:
self.buffer.set_text("", 0)
fg = bg = None
bold = False
pos = 0
for m in _SGR.finditer(text):
chunk = text[pos:m.start()]
if chunk:
self._insert(chunk, fg, bg, bold)
fg, bg, bold = self._apply(m.group(1), fg, bg, bold)
pos = m.end()
tail = text[pos:]
if tail:
self._insert(tail, fg, bg, bold)
def _insert(self, chunk, fg, bg, bold) -> None:
end = self.buffer.get_end_iter()
tag = self._tag(fg, bg, bold)
if tag is None:
self.buffer.insert(end, chunk)
else:
self.buffer.insert_with_tags(end, chunk, tag)
@staticmethod
def _apply(params: str, fg, bg, bold):
codes = [int(x) if x else 0 for x in params.split(";")] if params else [0]
i = 0
while i < len(codes):
c = codes[i]
if c == 0:
fg = bg = None
bold = False
elif c == 1:
bold = True
elif c == 22:
bold = False
elif c == 39:
fg = None
elif c == 49:
bg = None
elif 30 <= c <= 37:
fg = _BASE16[c - 30]
elif 90 <= c <= 97:
fg = _BASE16[c - 90 + 8]
elif 40 <= c <= 47:
bg = _BASE16[c - 40]
elif 100 <= c <= 107:
bg = _BASE16[c - 100 + 8]
elif c in (38, 48):
target = "fg" if c == 38 else "bg"
if i + 1 < len(codes) and codes[i + 1] == 5:
val = _xterm256(codes[i + 2]) if i + 2 < len(codes) else None
i += 2
elif i + 1 < len(codes) and codes[i + 1] == 2:
val = tuple(codes[i + 2:i + 5]) if i + 4 < len(codes) else None
i += 4
else:
val = None
if target == "fg":
fg = val
else:
bg = val
i += 1
return fg, bg, bold

View File

@ -0,0 +1,87 @@
"""Draw a rounded border around any widget with a Cairo DrawingArea overlay.
This GTK build's renderer does not paint CSS border/background nodes on plain
container widgets (Box/Frame) only on buttons/entries but it renders Cairo
draw funcs and textures fine (the map image proves it). So module 'borders' are
drawn explicitly here instead of via CSS.
"""
from __future__ import annotations
import math
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
# CyberQueer accent/violet (matches @accent/@violet / COLOR_HIGHLIGHT/COLOR_DARK
# in colors.conf).
ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
# Module fills are translucent violet rather than solid gray/black — lets the
# hologram overlay's scanlines/noise (drawn above, on window.py's overlay) and
# the desktop behind the panel both read through, so the whole thing looks
# like projected light/glass instead of an opaque card.
FILL_COLOR = VIOLET
FILL_ALPHA = 0.28
def _rounded_rect(cr, x, y, w, h, r) -> None:
r = min(r, w / 2, h / 2)
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
cr.close_path()
# Soft outer glow around the border ring: Cairo has no native blur, so this is
# the usual poor-man's bloom — the same crisp stroke redrawn a few times at
# increasing width and decreasing alpha, underneath the final crisp line. Wider
# and stronger than before so the thin border reads as glowing, not just drawn.
_GLOW_LAYERS = [(22, 0.05), (15, 0.08), (9, 0.13), (4, 0.22)]
def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool):
def draw(_area, cr, w, h, *_a) -> None:
inset = border / 2
r, g, b = color
if glow:
for extra_w, alpha in _GLOW_LAYERS:
_rounded_rect(cr, inset, inset, w - border, h - border, radius)
cr.set_source_rgba(r, g, b, alpha)
cr.set_line_width(border + extra_w)
cr.stroke()
_rounded_rect(cr, inset, inset, w - border, h - border, radius)
if fill_bg:
cr.set_source_rgba(*FILL_COLOR, FILL_ALPHA)
cr.fill_preserve()
cr.set_source_rgb(*color)
cr.set_line_width(border)
cr.stroke()
return draw
def bordered(child: Gtk.Widget, border: int = 2, radius: int = 16,
color=ACCENT, fill_bg: bool = False, glow: bool = True) -> Gtk.Overlay:
"""Wrap child in an overlay whose background is a drawn rounded border —
with a soft holographic glow around it by default, matching orbit-menu/
horizon-dock's aesthetic.
The DrawingArea is the overlay's main child (drawn first, behind); the content
is an overlay child on top and drives the size. A small margin keeps the content
clear of the drawn border ring.
"""
overlay = Gtk.Overlay()
area = Gtk.DrawingArea()
area.set_draw_func(_make_draw(border, radius, color, fill_bg, glow))
overlay.set_child(area) # background: the border ring
child.set_margin_start(border + 4)
child.set_margin_end(border + 4)
child.set_margin_top(border + 4)
child.set_margin_bottom(border + 4)
overlay.add_overlay(child) # content on top
overlay.set_measure_overlay(child, True) # size the overlay to the content
return overlay

View File

@ -0,0 +1,266 @@
"""Holographic scanline/sweep/noise overlay — the same visual treatment as
orbit-menu's hologram effect, adapted for astro-menu's rectangular panel
instead of orbit-menu's circular canvas: no radial vignette mask here, since
the panel's own Cairo-drawn module borders (lib/border.py) already bound it —
covering the full rectangle reads as one continuous "HUD screen" rather than
tinting past its own edges.
Owns its own animation clock (advanced by window.py's tick callback via
.tick(dt)) and a persistent particle list for the noise specs, exactly like
orbit-menu's _update_hologram_particles: each spec keeps its position/color
for a real randomized lifetime (fade in, hold, fade out) instead of every
spec teleporting to a new position every frame.
"""
from __future__ import annotations
import math
import random
import cairo
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
# Same CyberQueer violet/magenta/red combo as orbit-menu's hologram overlay.
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
_MAGENTA = (0.92, 0.0, 0.65)
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
class HologramOverlay:
SCANLINE_GAP = 4.0
SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture
SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass
SWEEP_HALF_HEIGHT = 90.0
NOISE_COUNT = 95 # specs alive at once (each with its own lifetime)
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
INTRO_STATIC = 1200 # static specks at the very start of the intro
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
intro_duration: float | None = None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
# widget whose opacity is ramped 0->1 during the intro so the UI genuinely
# fades in (a gradual reveal), rather than a solid haze block popping on
self._fade_widget = fade_widget
if intro_duration is not None:
self.INTRO_DURATION = intro_duration # per-instance override of the class default
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None # >=0 while the materialise intro plays
self._outro_t: float | None = None # >=0 while the closing dissolve plays
self._outro_done = None # callback fired when the dissolve finishes
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
self.widget = Gtk.DrawingArea()
self.widget.set_can_target(False) # never steals clicks from content underneath
self.widget.add_css_class("astro-hologram")
self.widget.set_hexpand(True)
self.widget.set_vexpand(True)
self.widget.set_halign(Gtk.Align.FILL)
self.widget.set_valign(Gtk.Align.FILL)
self.widget.set_draw_func(self._draw_frame)
def tick(self, dt: float) -> None:
if not self.enabled:
return
self._sat_time += dt
if self._intro_t is not None:
self._intro_t += dt
if self._intro_t >= self.INTRO_DURATION:
self._intro_t = None
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0)
elif self._fade_widget is not None:
p = self._intro_t / self.INTRO_DURATION
self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1
if self._outro_t is not None:
self._outro_t += dt
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0
if self._outro_t >= self.OUTRO_DURATION:
done = self._outro_done
self._outro_t = None
self._outro_done = None
if done is not None:
done()
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'hologram materialising out of static' opening effect."""
if self.enabled:
self._outro_t = None # cancel any in-flight closing dissolve
self._outro_done = None
self._intro_t = 0.0
if self._fade_widget is not None:
self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up
def start_outro(self, on_done) -> None:
"""Play a quick reverse of the intro (content dissolving back into static),
then call on_done to actually hide. If disabled, hide immediately."""
if not self.enabled:
on_done()
return
self._intro_t = None # cancel any in-flight opening intro
self._outro_t = 0.0
self._outro_done = on_done
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
cr.clip()
# Render the holo field into a group, then composite it back through a
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
# more like a projected hologram than a hard-edged rectangle).
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
elif self._outro_t is not None:
self._draw_outro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
# No solid veil block: the content itself fades in (see tick's fade_widget
# ramp). Here we only lay churning static over it — dense at first, thinning
# to nothing — so the UI resolves out of noise as it fades up.
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height # a bright scan wiping down as it resolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_outro(self, cr, width: float, height: float) -> None:
# Reverse of the intro: the content is already fading back out (tick's
# fade_widget ramp 1->0); here the static thickens from nothing as it goes,
# so the panel dissolves into noise just before it vanishes.
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (po ** 0.5))
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = (1.0 - po) * height # scan wiping back up as it dissolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
r, g, b = _VIOLET
cr.save()
cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA)
cr.set_line_width(1.0)
y = 0.0
while y < height:
cr.move_to(0, y)
cr.line_to(width, y)
y += self.SCANLINE_GAP
cr.stroke()
cr.restore()
phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD
sweep_y = phase * height
hh = self.SWEEP_HALF_HEIGHT
grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh)
grad.add_color_stop_rgba(0.0, r, g, b, 0.0)
grad.add_color_stop_rgba(0.5, r, g, b, 0.07)
grad.add_color_stop_rgba(1.0, r, g, b, 0.0)
cr.set_source(grad)
cr.rectangle(0, sweep_y - hh, width, hh * 2)
cr.fill()
flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0)
cr.set_source_rgba(r, g, b, max(0.0, flicker))
cr.paint()
self._draw_noise(cr, width, height)
def _draw_noise(self, cr, width: float, height: float) -> None:
now = self._sat_time
self._particles = [p for p in self._particles if now - p["birth"] < p["life"]]
while len(self._particles) < self.NOISE_COUNT:
self._particles.append({
"x": random.uniform(0, width),
"y": random.uniform(0, height),
"w": random.uniform(1.0, 2.6),
"h": random.uniform(1.0, 2.0),
"color": random.choice(self.NOISE_COLORS),
"peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE),
"birth": now,
"life": random.uniform(*self.NOISE_LIFETIME),
})
for p in self._particles:
t = (now - p["birth"]) / p["life"]
if t < self.NOISE_FADE_IN:
envelope = t / self.NOISE_FADE_IN
elif t > 1.0 - self.NOISE_FADE_OUT:
envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT)
else:
envelope = 1.0
r, g, b = p["color"]
cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope)
cr.rectangle(p["x"], p["y"], p["w"], p["h"])
cr.fill()

View File

@ -0,0 +1,100 @@
"""Subprocess helpers built on Gio so nothing blocks the GTK main loop.
The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command
prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on
failure. These helpers run such commands asynchronously and hand the parsed result
back on the main thread.
"""
from __future__ import annotations
import json
import shlex
from typing import Callable, Sequence
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
def _as_argv(cmd: Sequence[str] | str) -> list[str]:
return shlex.split(cmd) if isinstance(cmd, str) else list(cmd)
def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None:
"""Run cmd, call cb(ok, stdout, stderr) on the main thread when done."""
try:
proc = Gio.Subprocess.new(
_as_argv(cmd),
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
)
except GLib.Error as err:
GLib.idle_add(cb, False, "", str(err))
return
def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None:
try:
_, out, errout = p.communicate_utf8_finish(res)
except GLib.Error as err:
cb(False, "", str(err))
return
cb(p.get_successful(), out or "", errout or "")
proc.communicate_utf8_async(None, None, _done)
def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None:
"""Run cmd expecting JSON on stdout; call cb(ok, data)."""
def _text(ok: bool, out: str, err: str) -> None:
if not ok or not out.strip():
cb(False, err.strip() or "no output")
return
try:
cb(True, json.loads(out))
except json.JSONDecodeError as exc:
cb(False, f"bad json: {exc}")
run_text(cmd, _text)
class Poller:
"""Repeatedly run a command (or callable) on an interval, main-thread callback.
Used for lightweight state that has no change signal (open ports, public IP,
weather refresh). Modules that back onto an Astal GObject service should prefer
connecting to that service's `notify::` signals instead of polling.
"""
def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False):
self.interval_s = interval_s
self.cmd = cmd
self.cb = cb
self.json_mode = json_mode
self._source_id: int | None = None
self._stopped = False
def start(self) -> "Poller":
self._stopped = False
self._tick()
self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick)
return self
def _tick(self) -> bool:
if self._stopped:
return GLib.SOURCE_REMOVE
if self.json_mode:
run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data))
else:
run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err))
return GLib.SOURCE_CONTINUE
def refresh_now(self) -> None:
self._tick()
def stop(self) -> None:
self._stopped = True
if self._source_id is not None:
GLib.source_remove(self._source_id)
self._source_id = None

View File

@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""astro-menu — a touch-friendly GTK4 popup control centre (Location, Weather,
Bluetooth, Network) plus an application drawer, replacing nwg-dock/nwg-drawer.
Single-instance: the first launch builds the (hidden) window and holds. Later
invocations forward their arguments to it over D-Bus, so `main.py --toggle`
toggles the running instance without spawning a new process.
main.py run the resident instance (stays hidden until toggled)
main.py --toggle toggle visibility
main.py --show show | --hide hide | --appdrawer show with drawer open
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# astro-menu-start.sh LD_PRELOADs libgtk4-layer-shell so it loads before
# libwayland-client (a load-ordering requirement of the layer-shell library). That
# only matters at *this* process's exec: the library is already resident now, so the
# variable is never read again. Drop it here so the GUI apps we launch via
# AstalApps.launch() (and the backend subprocesses) don't inherit it — Firefox, for
# one, aborts at startup with libgtk4-layer-shell preloaded.
os.environ.pop("LD_PRELOAD", None)
# Make sibling modules importable no matter the CWD.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import gi # noqa: E402
gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib, Gtk # noqa: E402
import theme # noqa: E402
from appservices import Services # noqa: E402
from paths import APP_ID, ensure_dirs # noqa: E402
from settings import Settings # noqa: E402
from window import MenuWindow # noqa: E402
class AstalMenuApp(Gtk.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID,
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
self.window: MenuWindow | None = None
def do_startup(self) -> None:
Gtk.Application.do_startup(self)
ensure_dirs()
theme.load_css()
settings = Settings()
services = Services()
self.window = MenuWindow(self, settings, services)
self._register_actions()
self.hold() # stay alive with no visible window
def _register_actions(self) -> None:
# Mirrors the --show/--hide/--toggle/--appdrawer verbs from do_command_line, but
# reachable via org.gtk.Actions.Activate. menu-toggle.sh calls these directly with
# `gdbus call` for already-running instances, which skips spawning a whole second
# python3 + GTK process (~0.5s) just to forward one verb over D-Bus.
def add(name: str, callback, has_side: bool) -> None:
action = Gio.SimpleAction.new(name, GLib.VariantType.new("s") if has_side else None)
action.connect("activate", callback)
self.add_action(action)
def side_of(param: GLib.Variant | None) -> str:
return param.get_string() if param is not None else ""
def on_show(_action, param) -> None:
assert self.window is not None
if side_of(param):
self.window.set_side(side_of(param))
self.window.show_menu()
def on_hide(_action, _param) -> None:
assert self.window is not None
self.window.hide_menu()
def on_toggle(_action, param) -> None:
assert self.window is not None
if side_of(param):
self.window.set_side(side_of(param))
self.window.toggle()
def on_appdrawer(_action, param) -> None:
assert self.window is not None
if side_of(param):
self.window.set_side(side_of(param))
self.window.show_menu(focus_appdrawer=True)
add("show", on_show, has_side=True)
add("hide", on_hide, has_side=False)
add("toggle", on_toggle, has_side=True)
add("appdrawer", on_appdrawer, has_side=True)
def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
args = list(cmdline.get_arguments()[1:])
# Optional "--side top|bottom|left|right": the menu slides in from and pins to
# that monitor edge. Applied before the verb so the surface maps animated.
side = None
if "--side" in args:
i = args.index("--side")
if i + 1 < len(args):
side = args[i + 1]
del args[i:i + 2]
# No args = start (or keep) the resident instance hidden. Only explicit
# verbs change visibility, so the autostart launch never pops the menu.
action = args[0] if args else "--daemon"
if self.window is None:
return 0
if side:
self.window.set_side(side)
if action == "--show":
self.window.show_menu()
elif action == "--hide":
self.window.hide_menu()
elif action == "--appdrawer":
self.window.show_menu(focus_appdrawer=True)
elif action == "--toggle":
self.window.toggle()
# --daemon and anything else: no-op (stay as-is)
return 0
def do_activate(self) -> None:
# Resident instance: nothing to do on plain activate.
pass
def main() -> int:
GLib.set_prgname("astro-menu")
return AstalMenuApp().run(sys.argv)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,73 @@
"""The contract every quad module implements.
Adding a module = drop a file in modules/ that defines a top-level `SPEC`
(a ModuleSpec), then append its import to registry.py. Nothing else needs to
change: enable/disable, feature toggles, the card chrome, and the expand/collapse
plumbing are all provided generically.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable, Optional
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
@dataclass
class Feature:
"""A per-quad on/off switch surfaced in the card's settings popover."""
id: str
label: str
default: bool = True
@dataclass
class ModuleInstance:
"""What a module's build() returns."""
compact: Gtk.Widget # shown in the 2x2 cell
expanded: Optional[Gtk.Widget] = None # shown when the quad is expanded
scroll_expanded: bool = True # wrap the expanded view in a scroller
on_show: Optional[Callable[[], None]] = None
on_hide: Optional[Callable[[], None]] = None
destroy: Optional[Callable[[], None]] = None
@dataclass
class ModuleSpec:
id: str
title: str
icon: str # nerd-font glyph
build: Callable[["ModuleContext"], ModuleInstance]
default_enabled: bool = True
features: list[Feature] = field(default_factory=list)
class ModuleContext:
"""Handed to a module's build(). Scopes settings to the module and exposes the
expand/collapse requests so a module can drive the layout without knowing it."""
def __init__(self, spec: ModuleSpec, settings, services,
request_expand: Callable[[str], None],
request_collapse: Callable[[], None]):
self.spec = spec
self.settings = settings
self.services = services
self._request_expand = request_expand
self._request_collapse = request_collapse
def expand(self) -> None:
self._request_expand(self.spec.id)
def collapse(self) -> None:
self._request_collapse()
# feature toggles, scoped to this module
def feature(self, feature_id: str, default: bool = True) -> bool:
return self.settings.feature(self.spec.id, feature_id, default)
def on_settings_changed(self, cb: Callable[[], None]) -> None:
self.settings.subscribe(cb)

View File

@ -0,0 +1,292 @@
"""Bluetooth quad: powered by AstalBluetooth (bluez wrapper).
Discovery, connect, disconnect and a local connection history (bluez keeps none, so
we record successful connects in ~/.cache/astro-menu/bt-history.json). 'discovery'
and 'history' are per-quad feature toggles.
"""
from __future__ import annotations
import json
import time
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
from lib.proc import run_text
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import CACHE_DIR
_HISTORY = CACHE_DIR / "bt-history.json"
def _load_history() -> list[dict]:
try:
return json.loads(_HISTORY.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return []
def _record_history(address: str, name: str) -> None:
hist = [h for h in _load_history() if h.get("address") != address]
hist.insert(0, {"address": address, "name": name, "ts": int(time.time())})
CACHE_DIR.mkdir(parents=True, exist_ok=True)
_HISTORY.write_text(json.dumps(hist[:50], indent=2))
class _BluetoothView(Gtk.Box):
def __init__(self, ctx: ModuleContext, full: bool):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add_css_class("bt-view")
self.ctx = ctx
self.full = full
self.bt = ctx.services.bluetooth
self._recorded: set[str] = set()
self._hooked: set[str] = set() # devices whose state signals we've wired
self._pending: set[str] = set() # addresses with an in-flight connect
self._failed: set[str] = set() # addresses whose last connect failed
self.append(self._build_header())
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
self._list.add_css_class("bt-list")
if full:
scroller = Gtk.ScrolledWindow(vexpand=True,
hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(self._list)
self.append(scroller)
else:
self.append(self._list)
if self.bt is not None:
self.bt.connect("notify::devices", lambda *_: self._refresh())
self.bt.connect("notify::is-powered", lambda *_: self._refresh())
self._refresh()
def _adapter(self):
return self.bt.get_adapter() if self.bt else None
def _build_header(self) -> Gtk.Widget:
header = Gtk.CenterBox()
header.add_css_class("bt-header")
left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
left.append(Gtk.Label(label="Power"))
self._power = Gtk.Switch(active=bool(self.bt and self.bt.get_is_powered()),
valign=Gtk.Align.CENTER)
self._power.connect("state-set", self._on_power)
left.append(self._power)
header.set_start_widget(left)
if self.full and self.ctx.feature("discovery", True):
self._scan = Gtk.ToggleButton(label=" Scan")
self._scan.add_css_class("quad-action")
self._scan.connect("toggled", self._on_scan)
header.set_end_widget(self._scan)
return header
def _on_power(self, _sw, value: bool) -> bool:
ad = self._adapter()
if value:
# The adapter is frequently rfkill soft-blocked at boot; while blocked,
# AstalBluetooth.set_powered(True) is a silent no-op. Unblock first, then
# power on (bluez usually auto-powers on unblock, so set_powered is a
# belt-and-suspenders follow-up).
run_text(["rfkill", "unblock", "bluetooth"],
lambda *_a: ad.set_powered(True) if ad else None)
elif ad:
ad.set_powered(False)
return False
def _on_scan(self, btn: Gtk.ToggleButton) -> None:
ad = self._adapter()
if not ad:
return
if btn.get_active():
ad.start_discovery()
else:
ad.stop_discovery()
# -- device list -------------------------------------------------------
def _refresh(self) -> None:
child = self._list.get_first_child()
while child:
self._list.remove(child)
child = self._list.get_first_child()
if not self.bt:
self._list.append(Gtk.Label(label="No System Radio hardware"))
return
devices = list(self.bt.get_devices())
devices.sort(key=lambda d: (not d.get_connected(), not d.get_paired(),
(d.get_name() or d.get_address() or "").lower()))
if not self.full:
devices = [d for d in devices if d.get_connected() or d.get_paired()][:4]
for dev in devices:
self._hook_device(dev)
self._list.append(self._device_row(dev))
if self.full and self.ctx.feature("history", True):
self._list.append(self._history_section())
def _hook_device(self, dev) -> None:
# React to a device's own state changes (adapter-level notify::devices doesn't
# fire for per-device connect/disconnect). Hook once each.
key = dev.get_address() or ""
if key in self._hooked:
return
self._hooked.add(key)
for sig in ("notify::connected", "notify::connecting", "notify::paired"):
dev.connect(sig, lambda *_a, d=dev: self._on_device_state(d))
def _on_device_state(self, dev) -> None:
addr = dev.get_address() or ""
if dev.get_connected():
self._pending.discard(addr)
self._failed.discard(addr)
if self.ctx.feature("history", True):
self._maybe_record(dev, force=True)
elif addr in self._pending and not dev.get_connecting():
# bluez stopped trying without establishing a link → the connect failed
self._pending.discard(addr)
self._failed.add(addr)
self._refresh()
def _device_row(self, dev) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
row.add_css_class("bt-row")
addr = dev.get_address() or ""
icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic")
row.append(icon)
name = dev.get_name() or addr or "Unknown"
connected = dev.get_connected()
connecting = dev.get_connecting() or addr in self._pending
failed = addr in self._failed
lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True)
row.append(lbl)
status = ("connected" if connected else "connecting…" if connecting else
"failed" if failed else "paired" if dev.get_paired() else "")
if status:
tag = Gtk.Label(label=status)
tag.add_css_class("bt-status")
if failed:
tag.add_css_class("bt-failed")
row.append(tag)
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
if connecting:
spinner = Gtk.Spinner(valign=Gtk.Align.CENTER)
spinner.start()
actions.append(spinner)
actions.append(self._act("Cancel", lambda: self._cancel(dev)))
else:
if dev.get_paired():
actions.append(self._act("Forget", lambda: self._forget(dev)))
if connected:
actions.append(self._act("Disconnect",
lambda: dev.disconnect_device(None, self._noop)))
else:
actions.append(self._act("Retry" if failed else "Connect",
lambda: self._start_connect(dev)))
row.append(actions)
if connected and self.ctx.feature("history", True):
self._maybe_record(dev)
return row
@staticmethod
def _act(label: str, cb) -> Gtk.Button:
b = Gtk.Button(label=label, valign=Gtk.Align.CENTER)
b.add_css_class("quad-action")
b.connect("clicked", lambda *_a: cb())
return b
# -- connect / cancel / forget ----------------------------------------
def _start_connect(self, dev) -> None:
addr = dev.get_address() or ""
self._failed.discard(addr)
self._pending.add(addr)
def done(d, res):
try:
d.connect_device_finish(res)
except Exception:
a = d.get_address() or ""
if not d.get_connected():
self._pending.discard(a)
self._failed.add(a)
self._refresh()
dev.connect_device(None, done)
# Backstop: some failures never resolve the async call, so time out.
GLib.timeout_add_seconds(25, lambda: self._connect_timeout(addr))
self._refresh()
def _connect_timeout(self, addr: str) -> bool:
if addr in self._pending:
self._pending.discard(addr)
self._failed.add(addr)
self._refresh()
return GLib.SOURCE_REMOVE
def _cancel(self, dev) -> None:
addr = dev.get_address() or ""
self._pending.discard(addr)
dev.disconnect_device(None, self._noop) # abort the in-flight attempt
self._refresh()
def _forget(self, dev) -> None:
ad = self._adapter()
addr = dev.get_address() or ""
self._pending.discard(addr)
self._failed.discard(addr)
self._hooked.discard(addr)
if ad:
ad.remove_device(dev) # fires notify::devices → refresh
self._refresh()
def _maybe_record(self, dev, force: bool = False) -> None:
addr = dev.get_address() or ""
if not addr or (addr in self._recorded and not force):
return
self._recorded.add(addr)
_record_history(addr, dev.get_name() or addr)
@staticmethod
def _noop(obj, res) -> None:
try:
obj.disconnect_device_finish(res)
except Exception:
pass
def _history_section(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
box.add_css_class("bt-history")
box.append(Gtk.Separator())
box.append(Gtk.Label(label="History", xalign=0.0))
for h in _load_history()[:8]:
when = time.strftime("%d.%m %H:%M", time.localtime(h.get("ts", 0)))
box.append(Gtk.Label(label=f"{h.get('name', '?')} · {when}", xalign=0.0))
return box
def build(ctx: ModuleContext) -> ModuleInstance:
# Feature toggles (discovery/history) rebuild the whole card via QuadCard, so
# both views are recreated with the current features — no partial refresh here.
compact = _BluetoothView(ctx, full=False)
expanded = _BluetoothView(ctx, full=True)
return ModuleInstance(compact=compact, expanded=expanded)
SPEC = ModuleSpec(
id="bluetooth",
title="System Radio",
icon="", # nf-fa-bluetooth
build=build,
default_enabled=True,
features=[Feature("discovery", "Device discovery", True),
Feature("history", "Connection history", True)],
)

View File

@ -0,0 +1,185 @@
"""Location quad: a slippy-map image centred on the device's IP-geolocated position,
with a marker. Drag to pan, scroll to zoom, double-click to recentre.
The position comes from the shared LocationService (backend/geolocate.py), which
traceroutes to 1.1.1.1, takes the first public hop (the ISP egress) and resolves it
through a public geolocation API. The "Locate via IP" feature toggle gates that
lookup entirely; when off, this quad shows a placeholder instead. "Satellite view"
switches the tiles between the dark night map and Esri satellite imagery.
We render a static map (backend/staticmap.py) rather than an interactive libshumate
map: Shumate does not paint tiles in this environment (the official shumate-demo
shows the same blank map), while tile downloads themselves work fine so panning is
done by re-rendering at a new centre. The Weather quad consumes the same service.
"""
from __future__ import annotations
import math
import sys
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gdk, GLib, Gtk # noqa: E402
from lib.proc import run_text
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import BACKEND_DIR, CACHE_DIR
_SCRIPT = str(BACKEND_DIR / "staticmap.py")
_TILE = 256
def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
n = 2 ** zoom
y = (1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n
x = (lon + 180.0) / 360.0 * n
return x * _TILE, y * _TILE
def _px_latlon(px: float, py: float, zoom: int) -> tuple[float, float]:
n = 2 ** zoom
lon = px / (_TILE * n) * 360.0 - 180.0
lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * py / (_TILE * n)))))
return lat, lon
class _MapView(Gtk.Box):
def __init__(self, ctx: ModuleContext, zoom: int, size: tuple[int, int],
tag: str, show_info: bool):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("map-view")
# clip the map texture to the rounded .map-view corners (the Picture is a
# texture, so without this its square corners poke out of the card)
self.set_overflow(Gtk.Overflow.HIDDEN)
self.ctx = ctx
self.zoom = zoom
self.size = size
self._out = str(CACHE_DIR / f"map_{tag}.png")
self._loc: tuple[float, float] | None = None # geolocated position (marker)
self._view: tuple[float, float] | None = None # current view centre
self._drag_from: tuple[float, float] | None = None
self._render_pending = False
self.picture = Gtk.Picture(content_fit=Gtk.ContentFit.COVER, vexpand=True)
self.picture.add_css_class("map-picture")
self.append(self.picture)
self._info = None
if show_info:
self._info = Gtk.Label(label="Locating…", xalign=0.0)
self._info.add_css_class("map-info")
self.append(self._info)
self._wire_gestures()
ctx.services.location.subscribe(self._on_location)
def _style(self) -> str:
return "satellite" if self.ctx.feature("satellite", False) else "dark"
# -- data / render -----------------------------------------------------
def _on_location(self, data: dict) -> None:
self._loc = (data["lat"], data["lon"])
self._view = (data["lat"], data["lon"]) # recentre on a fresh fix
self._render()
if self._info is not None:
city = data.get("city") or "Unknown"
country = data.get("country") or ""
self._info.set_text(f"{city}, {country} · {data['lat']:.3f}, {data['lon']:.3f}"
f" · drag to pan · scroll to zoom · double-click to recentre")
def _render(self) -> None:
if self._view is None:
return
vlat, vlon = self._view
mlat, mlon = self._loc or self._view
w, h = self.size
run_text([sys.executable, _SCRIPT, str(vlat), str(vlon), str(self.zoom),
str(w), str(h), self._out, self._style(), str(mlat), str(mlon)],
self._on_rendered)
def _schedule_render(self) -> None:
# coalesce the flood of drag/scroll updates into ~11 renders/sec
if self._render_pending:
return
self._render_pending = True
def go() -> bool:
self._render_pending = False
self._render()
return GLib.SOURCE_REMOVE
GLib.timeout_add(90, go)
def _on_rendered(self, ok: bool, out: str, err: str) -> None:
if not ok:
return
# load a fresh texture; set_filename would ignore an unchanged path on re-pan
try:
self.picture.set_paintable(Gdk.Texture.new_from_filename(self._out))
except GLib.Error:
self.picture.set_filename(self._out)
# -- gestures: drag to pan, scroll to zoom, double-click to recentre ----
def _wire_gestures(self) -> None:
drag = Gtk.GestureDrag()
drag.connect("drag-begin", lambda *_a: setattr(self, "_drag_from", self._view))
drag.connect("drag-update", self._on_drag)
self.picture.add_controller(drag)
scroll = Gtk.EventControllerScroll(
flags=Gtk.EventControllerScrollFlags.VERTICAL)
scroll.connect("scroll", self._on_scroll)
self.picture.add_controller(scroll)
click = Gtk.GestureClick()
click.connect("pressed", self._on_click)
self.picture.add_controller(click)
def _on_drag(self, _gesture, ox: float, oy: float) -> None:
if self._drag_from is None:
return
cx, cy = _center_px(self._drag_from[0], self._drag_from[1], self.zoom)
# drag right → the view centre moves left (map content follows the cursor)
self._view = _px_latlon(cx - ox, cy - oy, self.zoom)
self._schedule_render()
def _on_scroll(self, _controller, _dx: float, dy: float) -> bool:
self.zoom = max(3, min(18, self.zoom + (1 if dy < 0 else -1)))
self._schedule_render()
return True
def _on_click(self, _gesture, n_press: int, _x: float, _y: float) -> None:
if n_press >= 2 and self._loc is not None:
self._view = self._loc
self._schedule_render()
def _placeholder() -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8,
valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
box.add_css_class("map-view")
box.append(Gtk.Label(label="IP location is off"))
return box
def build(ctx: ModuleContext) -> ModuleInstance:
# "Locate via IP" gates geolocation entirely: when off we neither traceroute nor
# hit any geolocation API, and just show a placeholder. Toggling it rebuilds the
# card (via QuadCard), so flipping it back on re-triggers the lookup.
if not ctx.feature("ip_locate", True):
return ModuleInstance(compact=_placeholder(), expanded=None)
ctx.services.location.get() # kick off geolocation (traceroute → API) if not started
compact = _MapView(ctx, zoom=12, size=(620, 150), tag="compact", show_info=False)
expanded = _MapView(ctx, zoom=13, size=(1100, 620), tag="expanded", show_info=True)
return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False)
SPEC = ModuleSpec(
id="location",
title="Location",
icon="", # nf-fa-map_marker
build=build,
features=[Feature("ip_locate", "Locate via IP", True),
Feature("satellite", "Satellite view", False)],
)

View File

@ -0,0 +1,529 @@
"""Network quad.
Compact: Wi-Fi enable + primary connection/IP (AstalNetwork + backend/network.sh).
Expanded: a small NetworkManager control panel over backend/nm.py, as four tabs
Adapters : each device foldout with editable IPv4/IPv6 (DHCP toggle, address,
subnet-mask <-> CIDR, gateway); applied via `nmcli connection modify/up`
Routes : the kernel routing table (read-only; editing kernel routes needs root)
VLAN : list / create / activate / delete VLAN connections
DNS : effective servers per device + a per-connection override table
Every tab is an independently toggle-able feature.
"""
from __future__ import annotations
import ipaddress
import sys
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from lib.proc import run_json
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import BACKEND_DIR
_NET = str(BACKEND_DIR / "network.sh")
_NM = str(BACKEND_DIR / "nm.py")
def _nm(args: list[str], cb) -> None:
"""Run a backend/nm.py subcommand expecting JSON on stdout."""
run_json([sys.executable, _NM, *args], cb)
def _primary_ip(data) -> str:
if not isinstance(data, list):
return ""
for iface in data:
if iface.get("ifname") == "lo":
continue
for a in iface.get("addr_info", []):
if a.get("family") == "inet":
return f"{a['local']}/{a.get('prefixlen', '')} ({iface.get('ifname')})"
return ""
# -- subnet mask <-> CIDR prefix ------------------------------------------
def _mask_to_prefix(mask: str) -> int | None:
try:
return ipaddress.IPv4Network(f"0.0.0.0/{mask.strip()}").prefixlen
except (ipaddress.NetmaskValueError, ValueError):
return None
def _prefix_to_mask(prefix: int) -> str:
return str(ipaddress.IPv4Network(f"0.0.0.0/{prefix}").netmask)
class _Compact(Gtk.Box):
def __init__(self, ctx: ModuleContext):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add_css_class("net-view")
self.ctx = ctx
self.net = ctx.services.network
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self._primary = Gtk.Label(label="", xalign=0.0, hexpand=True)
row.append(Gtk.Label(label="")) # nf wifi
row.append(self._primary)
wifi = self.net.get_wifi() if self.net else None
self._wifi_switch = Gtk.Switch(active=bool(wifi and wifi.get_enabled()),
valign=Gtk.Align.CENTER)
self._wifi_switch.set_tooltip_text("Wi-Fi")
self._wifi_switch.connect("state-set", self._on_wifi_toggle)
row.append(self._wifi_switch)
self.append(row)
self._ip = Gtk.Label(label="", xalign=0.0)
self._ip.add_css_class("net-ip")
self.append(self._ip)
if self.net:
self.net.connect("notify::primary", lambda *_: self.refresh())
if wifi:
wifi.connect("notify::ssid", lambda *_: self.refresh())
self.refresh()
def _on_wifi_toggle(self, _sw, value: bool) -> bool:
wifi = self.net.get_wifi() if self.net else None
if wifi:
wifi.set_enabled(value)
return False
def refresh(self) -> None:
wifi = self.net.get_wifi() if self.net else None
if wifi and wifi.get_active_access_point():
self._primary.set_text(f"{wifi.get_ssid() or '?'} · {wifi.get_strength()}%")
elif self.net and self.net.get_wired() and self.net.get_wired().get_internet():
self._primary.set_text("Wired")
else:
self._primary.set_text("Disconnected")
run_json([_NET, "ip"], lambda ok, d: self._ip.set_text(_primary_ip(d) if ok else ""))
# -- shared little widgets -------------------------------------------------
def _clear(box: Gtk.Box) -> None:
child = box.get_first_child()
while child:
box.remove(child)
child = box.get_first_child()
def _field(placeholder: str, text: str = "") -> Gtk.Entry:
e = Gtk.Entry(placeholder_text=placeholder, text=text, hexpand=True)
e.add_css_class("net-entry")
return e
def _labeled(label: str, widget: Gtk.Widget) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
lbl = Gtk.Label(label=label, xalign=0.0)
lbl.set_size_request(90, -1)
row.append(lbl)
row.append(widget)
return row
def _pill(label: str, cb) -> Gtk.Button:
b = Gtk.Button(label=label)
b.add_css_class("quad-action")
b.connect("clicked", lambda *_: cb())
return b
# -- IPv4 / IPv6 editor for one connection --------------------------------
class _IPSection(Gtk.Box):
"""One address family's editor. IPv4 exposes a linked subnet-mask/CIDR pair;
IPv6 exposes a prefix-length field. Fields are only sensitive in Manual mode."""
def __init__(self, con: str, family: str, fam_data: dict, on_result):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.con = con
self.family = family
self._on_result = on_result
self.add_css_class("net-view")
title = "IPv4" if family == "ipv4" else "IPv6"
head = Gtk.CenterBox()
head.set_start_widget(Gtk.Label(label=title, xalign=0.0))
auto = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
auto.append(Gtk.Label(label="Automatic"))
self._dhcp = Gtk.Switch(active=(fam_data.get("method", "auto") != "manual"),
valign=Gtk.Align.CENTER)
self._dhcp.connect("state-set", self._on_mode)
auto.append(self._dhcp)
head.set_end_widget(auto)
self.append(head)
addr, prefix = self._split(fam_data.get("addresses", []))
self._addr = _field("Address", addr)
self._gw = _field("Gateway", fam_data.get("gateway", ""))
self._fields = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self._fields.append(_labeled("Address", self._addr))
if family == "ipv4":
self._guard = False
self._mask = _field("255.255.255.0", _prefix_to_mask(prefix) if prefix else "")
self._cidr = _field("/24", f"/{prefix}" if prefix else "")
self._cidr.set_size_request(70, -1)
self._mask.connect("changed", self._sync_from_mask)
self._cidr.connect("changed", self._sync_from_cidr)
mask_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
mask_row.append(self._mask)
mask_row.append(self._cidr)
self._fields.append(_labeled("Netmask", mask_row))
else:
self._prefix = _field("64", str(prefix) if prefix else "")
self._fields.append(_labeled("Prefix", self._prefix))
self._fields.append(_labeled("Gateway", self._gw))
apply = Gtk.Button(label=f"Apply {title}")
apply.add_css_class("enable-btn")
apply.connect("clicked", lambda *_: self._apply())
self._fields.append(apply)
self.append(self._fields)
self._set_manual_enabled(not self._dhcp.get_active())
def _set_manual_enabled(self, on: bool) -> None:
# The reset GTK theme doesn't dim insensitive widgets, so fade the manual
# fields explicitly when DHCP/Automatic is active.
self._fields.set_sensitive(on)
self._fields.set_opacity(1.0 if on else 0.45)
@staticmethod
def _split(addresses: list[str]) -> tuple[str, int | None]:
if not addresses:
return "", None
addr, _, prefix = addresses[0].partition("/")
try:
return addr, int(prefix)
except ValueError:
return addr, None
def _on_mode(self, _sw, auto: bool) -> bool:
self._set_manual_enabled(not auto)
return False
# keep the dotted mask and /CIDR fields in lock-step (IPv4 only)
def _sync_from_mask(self, _e) -> None:
if self._guard:
return
p = _mask_to_prefix(self._mask.get_text())
if p is not None:
self._guard = True
self._cidr.set_text(f"/{p}")
self._guard = False
def _sync_from_cidr(self, _e) -> None:
if self._guard:
return
raw = self._cidr.get_text().lstrip("/").strip()
if raw.isdigit() and 0 <= int(raw) <= 32:
self._guard = True
self._mask.set_text(_prefix_to_mask(int(raw)))
self._guard = False
def _prefixlen(self) -> str:
if self.family == "ipv4":
raw = self._cidr.get_text().lstrip("/").strip()
if raw.isdigit():
return raw
p = _mask_to_prefix(self._mask.get_text())
return str(p) if p is not None else ""
return self._prefix.get_text().strip()
def _apply(self) -> None:
if self._dhcp.get_active():
args = ["set-ip", self.con, self.family, "auto"]
else:
addr, plen = self._addr.get_text().strip(), self._prefixlen()
cidr = f"{addr}/{plen}" if addr and plen else addr
args = ["set-ip", self.con, self.family, "manual",
cidr, self._gw.get_text().strip(), ""]
_nm(args, lambda ok, d: self._on_result(
ok and isinstance(d, dict) and d.get("ok"),
(d.get("error") if isinstance(d, dict) else "") or ""))
class _AdaptersPage(Gtk.Box):
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self._status = Gtk.Label(xalign=0.0)
self._status.add_css_class("net-ip")
self._status.set_visible(False)
self.append(self._status)
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(self._list)
self.append(scroller)
self._reload()
def _reload(self) -> None:
_nm(["adapters"], self._populate)
def _populate(self, ok: bool, adapters) -> None:
_clear(self._list)
if not ok or not isinstance(adapters, list) or not adapters:
self._list.append(Gtk.Label(label="No manageable adapters", xalign=0.0))
return
for a in adapters:
self._list.append(self._adapter_row(a))
def _adapter_row(self, a: dict) -> Gtk.Widget:
icon = "" if a.get("type") == "wifi" else "" # nf wifi / ethernet
exp = Gtk.Expander(
label=f"{icon} {a['device']} · {a.get('connection') or ''} [{a.get('state')}]")
exp.add_css_class("net-adapter")
body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
body.set_margin_top(8)
con = a.get("connection")
if not con:
body.append(Gtk.Label(label="No connection profile", xalign=0.0))
else:
loading = Gtk.Label(label="Loading…", xalign=0.0)
body.append(loading)
_nm(["ipconfig", con], lambda ok, d, b=body, l=loading, c=con:
self._fill(ok, d, b, l, c))
exp.set_child(body)
return exp
def _fill(self, ok, data, body, loading, con) -> None:
body.remove(loading)
if not ok or not isinstance(data, dict):
body.append(Gtk.Label(label="Could not read config", xalign=0.0))
return
result = Gtk.Label(xalign=0.0)
result.set_visible(False)
def on_result(good: bool, err: str) -> None:
result.set_text("Applied" if good else f"Failed: {err or 'error'}")
result.set_visible(True)
body.append(_IPSection(con, "ipv4", data.get("ipv4", {}), on_result))
body.append(Gtk.Separator())
body.append(_IPSection(con, "ipv6", data.get("ipv6", {}), on_result))
body.append(result)
class _RoutesPage(Gtk.Box):
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4)
header = Gtk.CenterBox()
header.set_start_widget(Gtk.Label(label="Kernel routing table", xalign=0.0))
header.set_end_widget(_pill(" Refresh", self._reload))
self.append(header)
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(self._list)
self.append(scroller)
self._reload()
def _reload(self) -> None:
_nm(["routes"], self._populate)
def _populate(self, ok: bool, routes) -> None:
_clear(self._list)
if not ok or not isinstance(routes, list) or not routes:
self._list.append(Gtk.Label(label="No routes", xalign=0.0))
return
for r in routes:
self._list.append(Gtk.Label(label=self._fmt(r), xalign=0.0,
selectable=True, wrap=True))
@staticmethod
def _fmt(r: dict) -> str:
dst = r.get("dst", "?")
via = f" via {r['gateway']}" if r.get("gateway") else ""
dev = f" dev {r['dev']}" if r.get("dev") else ""
metric = f" metric {r['metric']}" if r.get("metric") is not None else ""
proto = f" ({r['proto']})" if r.get("proto") else ""
return f"{dst}{via}{dev}{metric}{proto}"
class _VlanPage(Gtk.Box):
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._adapters: list[dict] = []
add = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._parent = Gtk.DropDown.new_from_strings([""])
self._vid = _field("VLAN ID")
self._vid.set_size_request(90, -1)
add.append(self._parent)
add.append(self._vid)
add.append(_pill("Add", self._add))
self.append(_labeled("New VLAN", add))
self._status = Gtk.Label(xalign=0.0)
self._status.add_css_class("net-ip")
self._status.set_visible(False)
self.append(self._status)
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(self._list)
self.append(scroller)
_nm(["adapters"], self._set_adapters)
self._reload()
def _set_adapters(self, ok, adapters) -> None:
self._adapters = adapters if ok and isinstance(adapters, list) else []
names = [a["device"] for a in self._adapters] or ["(no devices)"]
self._parent.set_model(Gtk.StringList.new(names))
def _reload(self) -> None:
_nm(["vlans"], self._populate)
def _populate(self, ok, vlans) -> None:
_clear(self._list)
if not ok or not isinstance(vlans, list) or not vlans:
self._list.append(Gtk.Label(label="No VLANs configured", xalign=0.0))
return
for v in vlans:
self._list.append(self._vlan_row(v))
def _vlan_row(self, v: dict) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.add_css_class("net-row")
state = "active" if v.get("active") else "inactive"
row.append(Gtk.Label(label=f"{v['name']} · {v.get('device') or ''} [{state}]",
xalign=0.0, hexpand=True))
if not v.get("active"):
row.append(_pill("Up", lambda n=v["name"]: self._act("vlan-up", n)))
row.append(_pill("Delete", lambda n=v["name"]: self._act("vlan-del", n)))
return row
def _add(self) -> None:
idx = self._parent.get_selected()
vid = self._vid.get_text().strip()
if idx < 0 or idx >= len(self._adapters) or not vid.isdigit():
self._flash("Pick a parent device and numeric VLAN ID")
return
parent = self._adapters[idx]["device"]
_nm(["vlan-add", parent, vid], self._after_write)
def _act(self, cmd: str, name: str) -> None:
_nm([cmd, name], self._after_write)
def _after_write(self, ok, d) -> None:
good = ok and isinstance(d, dict) and d.get("ok")
self._flash("Done" if good else
f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}")
self._reload()
def _flash(self, msg: str) -> None:
self._status.set_text(msg)
self._status.set_visible(True)
class _DnsPage(Gtk.Box):
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._adapters: list[dict] = []
self.append(Gtk.Label(label="Effective servers", xalign=0.0))
self._effective = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
self.append(self._effective)
self.append(Gtk.Separator())
self.append(Gtk.Label(label="Override", xalign=0.0))
self._conn = Gtk.DropDown.new_from_strings([""])
self.append(_labeled("Connection", self._conn))
self._servers = _field("1.1.1.1 8.8.8.8")
self.append(_labeled("Servers", self._servers))
ignore = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
ignore.append(Gtk.Label(label="Ignore automatic", hexpand=True, xalign=0.0))
self._ignore = Gtk.Switch(active=True, valign=Gtk.Align.CENTER)
ignore.append(self._ignore)
self.append(ignore)
self.append(_pill("Apply DNS", self._apply))
self._status = Gtk.Label(xalign=0.0)
self._status.add_css_class("net-ip")
self._status.set_visible(False)
self.append(self._status)
_nm(["adapters"], self._set_adapters)
_nm(["dns"], self._show_effective)
def _set_adapters(self, ok, adapters) -> None:
self._adapters = [a for a in (adapters or []) if a.get("connection")] \
if ok and isinstance(adapters, list) else []
names = [f"{a['connection']} ({a['device']})" for a in self._adapters] or ["(none)"]
self._conn.set_model(Gtk.StringList.new(names))
def _show_effective(self, ok, data) -> None:
_clear(self._effective)
if not ok or not isinstance(data, list) or not data:
self._effective.append(Gtk.Label(label="", xalign=0.0))
return
for d in data:
self._effective.append(Gtk.Label(
label=f"{d['device']}: {', '.join(d.get('servers', []))}",
xalign=0.0, selectable=True))
def _apply(self) -> None:
idx = self._conn.get_selected()
if idx < 0 or idx >= len(self._adapters):
self._flash("Pick a connection")
return
con = self._adapters[idx]["connection"]
servers = self._servers.get_text().strip()
ign = "yes" if self._ignore.get_active() else "no"
_nm(["set-dns", con, "ipv4", servers, ign], self._after)
def _after(self, ok, d) -> None:
good = ok and isinstance(d, dict) and d.get("ok")
self._flash("Applied" if good else
f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}")
_nm(["dns"], self._show_effective)
def _flash(self, msg: str) -> None:
self._status.set_text(msg)
self._status.set_visible(True)
class _Expanded(Gtk.Box):
def __init__(self, ctx: ModuleContext):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add_css_class("net-view")
self.ctx = ctx
self.stack = Gtk.Stack(vexpand=True)
switcher = Gtk.StackSwitcher(stack=self.stack)
switcher.add_css_class("net-switcher")
self.append(switcher)
self.append(self.stack)
f = ctx.feature
if f("adapters", True):
self.stack.add_titled(_AdaptersPage(), "adapters", "Adapters")
if f("routes", True):
self.stack.add_titled(_RoutesPage(), "routes", "Routes")
if f("vlan", True):
self.stack.add_titled(_VlanPage(), "vlan", "VLAN")
if f("dns", True):
self.stack.add_titled(_DnsPage(), "dns", "DNS")
def build(ctx: ModuleContext) -> ModuleInstance:
# Feature toggles rebuild the whole card via QuadCard, so _Expanded is recreated
# with the enabled tabs — no in-place page rebuild needed here.
compact = _Compact(ctx)
expanded = _Expanded(ctx)
return ModuleInstance(compact=compact, expanded=expanded)
SPEC = ModuleSpec(
id="network",
title="Laser Antenna Uplink",
icon="", # nf-md-lan
build=build,
default_enabled=True,
features=[Feature("adapters", "Adapters", True),
Feature("routes", "Routes", True),
Feature("vlan", "VLAN", True),
Feature("dns", "DNS", True)],
)

View File

@ -0,0 +1,195 @@
"""System-monitor quad ("Ship Systems"): the machine's vitals reskinned as a
starship diagnostic panel. A small backend script (backend/sysmon.sh) reports
usage + temperature for each subsystem, renamed in the ship's language:
CPU -> Thrusters
GPU -> Hyperdrive
RAM -> Life Support Systems
Storage -> Cargo Hold (one entry per mounted drive)
Each is drawn as a bracketed usage meter (-<>-) with a temperature
readout (-( 54°C )-). Rows are laid out in a Gtk.Grid so every column lines up
regardless of the (variable-width) nerd-font icons. The compact cell shows the
four headline subsystems (first drive only); the expanded view lists every drive
with its capacity.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
from lib.proc import run_text
from module_base import ModuleContext, ModuleInstance, ModuleSpec
from paths import BACKEND_DIR
_SCRIPT = str(BACKEND_DIR / "sysmon.sh")
_MONO = "Agave Nerd Font Mono"
# palette (kept in sync by eye with style/_colors.css / the hologram violets)
_MAGENTA = "#EB00A6"
_ACCENT = "#E40046"
_DIM_EMPTY = "#3B2A6E" # unfilled meter cells
_DIM_TEXT = "#9385C9" # detail line
_COOL = "#22D3EE"
_WARM = "#F5A623"
_HOT = "#E40046"
_GOOD = "#2BE08A"
# category -> nerd-font glyph (verified to render in Agave Nerd Font)
_ICONS = {
"cpu": chr(0xf135), # nf-fa-rocket
"gpu": chr(0xf0e7), # nf-fa-bolt
"ram": chr(0xf004), # nf-fa-heart
"disk": chr(0xf0a0), # nf-fa-hdd_o
}
def _usage_color(pct: int) -> str:
if pct >= 85:
return _HOT
if pct >= 60:
return _WARM
return _GOOD
def _temp_color(temp: int) -> str:
if temp >= 85:
return _HOT
if temp >= 65:
return _WARM
return _COOL
def _esc(text: str) -> str:
return GLib.markup_escape_text(text)
class _SystemMonitor(Gtk.Box):
REFRESH_MS = 2500
def __init__(self, ctx: ModuleContext, compact: bool):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.ctx = ctx
self.compact = compact
self.add_css_class("sysmon-view")
self.set_valign(Gtk.Align.CENTER)
self._size = 10 if compact else 12
self._bar_w = 9 if compact else 16
self._timer: int | None = None
self._grid = Gtk.Grid()
self._grid.set_row_spacing(3 if compact else 7)
self._grid.set_column_spacing(12)
self.append(self._grid)
# -- lifecycle ---------------------------------------------------------
def start(self) -> None:
self.refresh()
if self._timer is None:
self._timer = GLib.timeout_add(self.REFRESH_MS, self._tick)
def stop(self) -> None:
if self._timer is not None:
GLib.source_remove(self._timer)
self._timer = None
def _tick(self) -> bool:
self.refresh()
return True
def refresh(self) -> None:
run_text([_SCRIPT], self._on_result)
def _on_result(self, ok: bool, out: str, _err: str) -> None:
if not ok:
return
rows = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) < 5:
continue
rows.append(dict(zip(("cat", "name", "usage", "temp", "detail"), parts)))
if self.compact:
# headline subsystems + only the first (root) drive
trimmed, seen_disk = [], False
for r in rows:
if r["cat"] == "disk":
if seen_disk:
continue
seen_disk = True
trimmed.append(r)
rows = trimmed
self._render(rows)
# -- rendering ---------------------------------------------------------
def _clear(self) -> None:
child = self._grid.get_first_child()
while child is not None:
nxt = child.get_next_sibling()
self._grid.remove(child)
child = nxt
def _span(self, markup: str, xalign: float = 0.0) -> Gtk.Label:
lbl = Gtk.Label(xalign=xalign)
lbl.add_css_class("sysmon-row")
lbl.set_markup(f"<span font_family='{_MONO}' size='{int(self._size * 1024)}'>{markup}</span>")
return lbl
def _render(self, rows: list[dict]) -> None:
self._clear()
for i, r in enumerate(rows):
try:
usage = int(r["usage"])
except ValueError:
usage = 0
fill_col = _usage_color(usage)
filled = max(0, min(self._bar_w, round(usage / 100 * self._bar_w)))
bar = (f"<span foreground='{fill_col}'>{'' * filled}</span>"
f"<span foreground='{_DIM_EMPTY}'>{'' * (self._bar_w - filled)}</span>")
meter = (f"<span foreground='{_ACCENT}'>-&lt;</span>{bar}"
f"<span foreground='{_ACCENT}'>&gt;-</span>")
try:
temp = int(r["temp"])
temp_txt, temp_col = f"{temp}°C", _temp_color(temp)
except ValueError:
temp_txt, temp_col = "--°C", _DIM_TEXT
temp_field = (f"<span foreground='{_ACCENT}'>-(</span> "
f"<span foreground='{temp_col}'>{temp_txt:>5}</span> "
f"<span foreground='{_ACCENT}'>)-</span>")
icon = _ICONS.get(r["cat"], "")
self._grid.attach(self._span(f"<span foreground='{_MAGENTA}'>{icon}</span>"), 0, i, 1, 1)
self._grid.attach(self._span(f"<span foreground='{_MAGENTA}'>{_esc(r['name'])}</span>"), 1, i, 1, 1)
self._grid.attach(self._span(meter), 2, i, 1, 1)
self._grid.attach(self._span(f"<span foreground='{fill_col}'>{usage:>3}%</span>", 1.0), 3, i, 1, 1)
self._grid.attach(self._span(temp_field), 4, i, 1, 1)
if not self.compact:
self._grid.attach(
self._span(f"<span foreground='{_DIM_TEXT}'>{_esc(r['detail'])}</span>"), 5, i, 1, 1)
def build(ctx: ModuleContext) -> ModuleInstance:
compact = _SystemMonitor(ctx, compact=True)
expanded = _SystemMonitor(ctx, compact=False)
def on_show() -> None:
compact.start()
expanded.start()
def on_hide() -> None:
compact.stop()
expanded.stop()
return ModuleInstance(compact=compact, expanded=expanded,
scroll_expanded=True, on_show=on_show, on_hide=on_hide)
SPEC = ModuleSpec(
id="sysmon",
title="Ship Systems",
icon=chr(0xf085), # nf-fa-cogs
build=build,
)

View File

@ -0,0 +1,78 @@
"""Weather quad: wttr.in rendered with its original ANSI/CLI art (via AnsiRenderer).
Reuses the shared LocationService for the city; if none is known yet, wttr.in
geolocates the caller's IP itself, so the widget still works standalone. The
'ascii_art' feature toggle swaps the art for a compact one-line text summary.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Pango", "1.0")
from gi.repository import Gtk, Pango # noqa: E402
from lib.ansi import AnsiRenderer
from lib.proc import run_text
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import BACKEND_DIR
_SCRIPT = str(BACKEND_DIR / "weather.sh")
class _WeatherView(Gtk.Box):
def __init__(self, ctx: ModuleContext, opts: str):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("weather-view")
self.ctx = ctx
self.opts = opts
self._loc = ""
headline = Gtk.Label(label="Planetary Environment Report", xalign=0.0)
headline.add_css_class("weather-headline")
headline.set_ellipsize(Pango.EllipsizeMode.END)
self.append(headline)
self.renderer = AnsiRenderer()
self.append(self.renderer.view)
self._status = Gtk.Label(label="Loading weather…")
self._status.add_css_class("weather-status")
self.append(self._status)
ctx.services.location.subscribe(self._on_location)
self.refresh()
def _on_location(self, data: dict) -> None:
self._loc = data.get("city") or ""
self.refresh()
def refresh(self) -> None:
art = self.ctx.feature("ascii_art", True)
opts = self.opts if art else "format=%l:+%c+%t,+%w"
run_text([_SCRIPT, self._loc, opts], self._on_result)
def _on_result(self, ok: bool, out: str, err: str) -> None:
if ok and out.strip() and "<html" not in out.lower():
self.renderer.set_text(out.rstrip("\n"))
self._status.set_visible(False)
else:
self._status.set_text("Weather unavailable")
self._status.set_visible(True)
def build(ctx: ModuleContext) -> ModuleInstance:
# The ascii_art toggle rebuilds the whole card via QuadCard, so both views are
# recreated and re-fetch with the current format — no partial refresh here.
compact = _WeatherView(ctx, opts="0") # current conditions only
expanded = _WeatherView(ctx, opts="") # full 3-day forecast
return ModuleInstance(compact=compact, expanded=expanded)
SPEC = ModuleSpec(
id="weather",
title="Weather",
icon="", # nf-weather
build=build,
features=[Feature("ascii_art", "CLI art", True)],
)

View File

@ -0,0 +1,28 @@
"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
from __future__ import annotations
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
BACKEND_DIR = BASE_DIR / "backend"
STYLE_DIR = BASE_DIR / "style"
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "astro-menu"
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu"
# User settings live under XDG_STATE_HOME, NOT CONFIG_DIR: the config-updater does
# `rm -rf ~/.config/astro-menu` on every deploy, which would wipe pinned favourites
# and quad toggles. The state dir is never touched by config sync.
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "astro-menu"
SETTINGS_FILE = STATE_DIR / "settings.json"
CONFIG_FILE = STATE_DIR / "config.json"
APP_ID = "eu.abdelbaki.astromenu"
def ensure_dirs() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CACHE_DIR.mkdir(parents=True, exist_ok=True)
STATE_DIR.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,29 @@
"""The module registry — the single place that lists available quads in order.
To add a new module: create modules/<name>.py exposing a top-level `SPEC`
(ModuleSpec), then import it and append its SPEC here. The first four *enabled*
specs fill the 2x2 grid; extra specs are kept for future paginated layouts.
"""
from __future__ import annotations
from modules import bluetooth, network, sysmon, weather
from module_base import ModuleSpec
ALL_SPECS: list[ModuleSpec] = [
sysmon.SPEC,
weather.SPEC,
bluetooth.SPEC,
network.SPEC,
]
def ordered_specs(settings) -> list[ModuleSpec]:
"""Return specs in the user's saved order, unknown/new ones appended."""
order = settings.order()
if not order:
return list(ALL_SPECS)
by_id = {s.id: s for s in ALL_SPECS}
result = [by_id[i] for i in order if i in by_id]
result += [s for s in ALL_SPECS if s.id not in order]
return result

View File

@ -0,0 +1,55 @@
"""Shared geolocation singleton.
Runs backend/geolocate.py (IP geolocation, cached) exactly once and notifies
subscribers. Both the Location map and the Weather widget subscribe, so there is a
single source of truth for "where am I" and only one network lookup.
"""
from __future__ import annotations
import sys
from typing import Callable, Optional
from lib.proc import run_json
from paths import BACKEND_DIR
class LocationService:
def __init__(self) -> None:
self.data: Optional[dict] = None
self._subs: list[Callable[[dict], None]] = []
self._inflight = False
def subscribe(self, cb: Callable[[dict], None]) -> None:
self._subs.append(cb)
if self.data is not None:
cb(self.data)
def get(self) -> Optional[dict]:
if self.data is None and not self._inflight:
self.refresh()
return self.data
def refresh(self) -> None:
if self._inflight:
return
self._inflight = True
argv = [sys.executable, str(BACKEND_DIR / "geolocate.py")]
run_json(argv, self._on_result)
def _on_result(self, ok: bool, data) -> None:
self._inflight = False
if ok and isinstance(data, dict) and "lat" in data:
self.data = data
for cb in list(self._subs):
cb(data)
_instance: LocationService | None = None
def get_location_service() -> LocationService:
global _instance
if _instance is None:
_instance = LocationService()
return _instance

View File

@ -0,0 +1,83 @@
"""Persisted user settings: which quads are enabled, per-module feature toggles,
and quad ordering. Single JSON file, read at startup, written on every change.
Modules never import this directly for their own feature flags; they receive a
scoped view via ctx.feature(...) so the persistence format stays centralised.
"""
from __future__ import annotations
import json
from typing import Callable
from paths import CONFIG_DIR, SETTINGS_FILE, ensure_dirs
class Settings:
def __init__(self) -> None:
self._data: dict = {"quads": {}, "features": {}, "order": None, "favorites": []}
self._listeners: list[Callable[[], None]] = []
self.load()
# -- persistence -------------------------------------------------------
def load(self) -> None:
path = SETTINGS_FILE
if not path.exists():
# one-time migration from the old CONFIG_DIR location (pre state-dir)
legacy = CONFIG_DIR / "settings.json"
if legacy.exists():
path = legacy
try:
self._data.update(json.loads(path.read_text()))
except (FileNotFoundError, json.JSONDecodeError):
pass
def save(self) -> None:
ensure_dirs()
SETTINGS_FILE.write_text(json.dumps(self._data, indent=2))
for cb in list(self._listeners):
cb()
def subscribe(self, cb: Callable[[], None]) -> None:
self._listeners.append(cb)
# -- quad enable/disable ----------------------------------------------
def quad_enabled(self, module_id: str, default: bool = True) -> bool:
return bool(self._data["quads"].get(module_id, default))
def set_quad_enabled(self, module_id: str, value: bool) -> None:
self._data["quads"][module_id] = bool(value)
self.save()
# -- per-module feature toggles ---------------------------------------
def feature(self, module_id: str, feature_id: str, default: bool = True) -> bool:
return bool(self._data["features"].get(module_id, {}).get(feature_id, default))
def set_feature(self, module_id: str, feature_id: str, value: bool) -> None:
self._data["features"].setdefault(module_id, {})[feature_id] = bool(value)
self.save()
# -- favorites ---------------------------------------------------------
def favorites(self) -> list[str]:
return list(self._data.get("favorites", []))
def is_favorite(self, entry: str) -> bool:
return entry in self._data.get("favorites", [])
def toggle_favorite(self, entry: str) -> None:
if not entry:
return
favs = self._data.setdefault("favorites", [])
if entry in favs:
favs.remove(entry)
else:
favs.append(entry)
self.save()
# -- ordering ----------------------------------------------------------
def order(self) -> list[str] | None:
return self._data.get("order")
def set_order(self, order: list[str]) -> None:
self._data["order"] = order
self.save()

View File

@ -0,0 +1,8 @@
/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh do not hand-edit the
* hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the
* sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */
@define-color text #D6ABAB;
@define-color bg #1A1A1A;
@define-color accent #E40046;
@define-color violet #5018DD;
@define-color danger #F50505;

View File

@ -0,0 +1,368 @@
/* astro-menu "hologram info display" theme. Colours come from _colors.css
* (@text/@bg/@accent/@violet/@danger). Mirrors the existing bar idiom: Agave
* Nerd Font Mono, 3px borders, ~25px pill radii. Touch-friendly hit targets
* throughout. Module card borders get a Cairo-drawn glow (lib/border.py);
* this file adds matching CSS box-shadow glow + smooth transitions on every
* interactive element, and a scanline/sweep/noise hologram overlay (see
* .astro-hologram, painted by window.py) so the whole panel reads as one
* continuous piece with orbit-menu and horizon-dock. */
/* Text colour override: bright magenta reads far better than the default dusty
* @text (#D6ABAB) now that the panel is transparent + blurred glass. @accent
* (red) stays the accent/alt colour. This @define-color is added after
* _colors.css by theme.py (same USER+1 priority, later wins) and only affects
* this process's widgets, so other Cosmonaut Shell apps keep their @text. */
@define-color text #EB00A6;
* {
font-family: "Agave Nerd Font Mono", monospace;
font-size: 13pt;
}
/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }`, which
* otherwise paints EVERY node (every Gtk.Box, label, centerbox, ) an opaque
* dark slab behind the floating modules. Enumerating node types by name is
* fragile (it missed plain `box`), so blank them all here this provider sits
* at USER+1, above the theme. Interactive elements re-assert their own fills
* via the higher-specificity .class rules below; module cards are Cairo-drawn
* (lib/border.py, fill_bg=True), not CSS, so they are unaffected. */
* {
background-color: transparent;
}
/* ---- window / letterbox --------------------------------------------- */
/* The CyberQueer GTK theme paints `* { background-color: #1a1a1a }` on EVERY node,
* which fills the gaps between modules with a solid slab. Each module carries its
* own drawn (Cairo) card background via bordered(fill_bg=True), so blank the
* structural container nodes here to let the desktop show between the floating
* modules. `drawingarea` is the bordered() ring canvas transparent so only its
* Cairo-drawn rounded fill shows, not a full-bleed square. */
window,
window.background,
.menu-window,
#menu-window,
.panel,
#panel-root,
overlay,
revealer,
grid,
scrolledwindow,
viewport,
drawingarea {
background: transparent;
background-color: transparent;
}
#backdrop {
background: alpha(black, 0.25);
}
/* Letterbox margins are set in code (window.py) as a fraction of the monitor so
* the inset scales per display; this box just carries the panel background. */
/* ---- quad grid + cards ---------------------------------------------- */
.quad-grid.dimmed {
opacity: 0.15;
}
/* bouncy pop when a quad expands (added in ui/quadgrid.py on expand) */
@keyframes quad-pop {
0% { transform: scale(0.82); opacity: 0.4; }
55% { transform: scale(1.05); opacity: 1; }
78% { transform: scale(0.985); }
100% { transform: scale(1.0); }
}
.quad-pop { animation: quad-pop 300ms ease-out; }
/* bouncy pop-expand for the app drawer (added in ui/appdrawer.py on expand) */
@keyframes drawer-pop {
0% { transform: scale(0.90); opacity: 0.45; }
60% { transform: scale(1.025); opacity: 1; }
82% { transform: scale(0.995); }
100% { transform: scale(1.0); }
}
.drawer-pop { animation: drawer-pop 280ms ease-out; }
.quad-card { min-height: 100px; }
.section-title { color: @text; font-weight: bold; opacity: 0.85; }
/* The single module border + background is drawn with Cairo by bordered(fill_bg=True)
* (see lib/border.py); the CSS border/background here would render a second,
* concentric ring now that the app stylesheet sits above the theme, so only keep
* the inner content padding. */
.quad-card,
.quad-expanded,
.appdrawer,
.taskbar,
.favorites {
padding: 8px 12px;
}
.quad-header,
.appdrawer-header,
.expanded-header {
margin-bottom: 8px;
}
.quad-title { color: @text; font-weight: bold; }
.quad-icon { color: @accent; font-size: 15pt; }
/* minimalist system-stats line (top of the panel) */
.statsbar { padding: 19px 8px; }
.statsbar .stat-icon { color: @accent; font-size: 11pt; }
.statsbar .stat-value { color: @text; font-size: 10pt; }
.quad-body { color: @text; }
/* weather "Planetary Environment Report" headline */
.weather-headline {
color: @text;
font-weight: 700;
font-size: 12pt;
letter-spacing: 1px;
margin: 0 0 6px 2px;
}
/* Ship Systems (system monitor): the row labels carry their own inline Pango
* markup (monospace, colour-coded meters), so this only spaces them out. */
.sysmon-view { margin: 4px 6px; }
.sysmon-row { margin: 1px 0; }
/* pill action buttons */
.quad-action,
.enable-btn {
color: @text;
background: alpha(@violet, 0.4);
border: 3px solid @violet;
border-radius: 25px;
padding: 1px 14px; /* tight vertically so pills don't waste vertical space */
min-height: 22px;
min-width: 22px;
transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease;
box-shadow: 0 0 0 0 alpha(@accent, 0);
}
.quad-action:hover,
.enable-btn:hover {
border-color: @accent;
color: @accent;
box-shadow: 0 0 12px 1px alpha(@accent, 0.55);
}
.quad-action:active,
button:checked.quad-action { background: @accent; color: @bg; border-color: @accent; }
/* Columns count stepper value (between the / + pills) */
.stepper-value {
min-width: 34px;
font-weight: bold;
color: @accent;
font-feature-settings: "tnum";
}
/* A MenuButton (the settings cog) wraps an inner `button` node that would render a
* second ring inside the outer .quad-action pill flatten it so only one border
* shows. */
.quad-action > button {
border: none;
background: transparent;
padding: 0;
min-width: 0;
min-height: 0;
}
.quad-disabled { color: @text; opacity: 0.7; }
.enable-btn { border-color: @accent; }
/* settings popover */
.quad-settings { background: alpha(@violet, 0.4); border: 3px solid @violet; border-radius: 16px; padding: 10px; }
.switch-row { min-height: 36px; }
.expanded-header .quad-title { font-size: 14pt; }
/* ---- appdrawer ------------------------------------------------------ */
.appdrawer-search {
border: 3px solid @violet;
border-radius: 25px;
padding: 8px 14px;
margin-bottom: 8px;
color: @text;
background: alpha(@violet, 0.4);
transition: border-color 180ms ease, box-shadow 220ms ease;
box-shadow: 0 0 0 0 alpha(@accent, 0);
}
.appdrawer-search:focus-within {
border-color: @accent;
box-shadow: 0 0 14px 1px alpha(@accent, 0.45);
}
.app-tile {
background: transparent;
border: 2px solid transparent;
border-radius: 16px;
padding: 10px 6px;
min-width: 92px;
transition: border-color 180ms ease, background 180ms ease, box-shadow 220ms ease;
box-shadow: 0 0 0 0 alpha(@accent, 0);
}
.app-tile:hover {
border-color: @accent;
background: alpha(@violet, 0.18);
box-shadow: 0 0 14px 1px alpha(@accent, 0.4);
}
.app-tile label { color: @text; font-size: 10pt; }
.fav-star { color: @accent; font-size: 13pt; margin: 2px 4px; }
/* ---- favourites row (top of drawer) --------------------------------- */
.favorites-row { padding: 4px 0; }
.fav-tile {
background: alpha(@violet, 0.4);
border: 2px solid @violet;
border-radius: 16px;
padding: 6px 12px;
min-height: 40px;
transition: border-color 180ms ease, box-shadow 220ms ease;
box-shadow: 0 0 0 0 alpha(@violet, 0);
}
.fav-tile:hover {
border-color: @accent;
box-shadow: 0 0 12px 1px alpha(@accent, 0.5);
}
.fav-tile label { color: @text; font-size: 11pt; }
/* ---- taskbar (open windows) ----------------------------------------- */
.taskbar-row { padding: 4px 0; }
.task-tile {
background: transparent;
border: 2px solid transparent;
border-radius: 14px;
padding: 6px 10px;
min-height: 44px;
min-width: 44px;
transition: border-color 180ms ease, background 180ms ease, box-shadow 220ms ease;
box-shadow: 0 0 0 0 alpha(@accent, 0);
}
.task-tile:hover {
border-color: @accent;
background: alpha(@violet, 0.18);
box-shadow: 0 0 14px 1px alpha(@accent, 0.4);
}
.task-badge {
color: @bg; background: @accent;
border-radius: 10px; padding: 0 6px; font-size: 9pt;
}
.task-popover { background: alpha(@violet, 0.4); border: 3px solid @violet; border-radius: 14px; padding: 6px; }
.task-window { color: @text; background: transparent; border-radius: 10px; padding: 6px 12px; }
.task-window:hover { color: @accent; }
/* pop-open panel: layout controls + per-window rows */
.task-panel { padding: 4px 2px; }
.layout-page { padding: 6px 4px 2px 4px; }
.task-row { padding: 2px 4px; border-radius: 10px; min-height: 30px; }
.task-row:hover { background: alpha(@violet, 0.12); }
.task-name { background: transparent; border: none; padding: 2px 6px; min-height: 26px; }
.task-name:hover { color: @accent; }
.task-name label { color: @text; }
/* ---- location map --------------------------------------------------- */
.map-view { border-radius: 14px; }
.map-info { color: @text; padding: 6px 2px 0 2px; font-size: 11pt; }
.map-marker { color: @accent; }
/* ---- weather -------------------------------------------------------- */
/* Transparent, not a translucent slab: its own alpha(@violet) fill stacked on
* top of the module's Cairo card fill, reading as a brighter square with hard
* corners. Let the card fill show through instead. */
.ansi-view,
.ansi-view text {
background: transparent;
background-color: transparent;
color: @text;
font-family: "Agave Nerd Font Mono", monospace;
font-size: 11pt;
padding: 4px;
}
.weather-status { color: @text; opacity: 0.7; }
/* ---- bluetooth / network shared rows -------------------------------- */
.bt-row,
.net-row {
padding: 4px 6px;
border-radius: 12px;
min-height: 30px;
transition: background 180ms ease;
}
.bt-row:hover,
.net-row:hover { background: alpha(@violet, 0.15); }
.bt-status { color: @accent; font-size: 10pt; }
.bt-status.bt-failed { color: @danger; }
.bt-history label,
.net-ip { color: @text; opacity: 0.85; font-size: 11pt; }
.net-switcher { margin-bottom: 8px; }
.net-adapter { padding: 4px 0; }
.net-adapter > box { padding-left: 6px; }
.net-entry {
border: 2px solid @violet;
border-radius: 12px;
padding: 6px 10px;
color: @text;
background: alpha(@violet, 0.4);
transition: border-color 180ms ease, box-shadow 220ms ease;
box-shadow: 0 0 0 0 alpha(@accent, 0);
}
.net-entry:focus-within {
border-color: @accent;
box-shadow: 0 0 12px 1px alpha(@accent, 0.45);
}
.pubip { color: @accent; font-size: 15pt; }
/* switches: fixed pill with a bordered round knob (the reset theme leaves them
* shapeless otherwise) */
switch {
min-width: 48px;
min-height: 26px;
border: 2px solid @violet;
border-radius: 15px;
background: alpha(@violet, 0.4);
padding: 0;
transition: background 180ms ease, border-color 180ms ease, box-shadow 220ms ease;
}
switch:checked {
background: @accent;
border-color: @accent;
box-shadow: 0 0 10px 1px alpha(@accent, 0.5);
}
switch > slider {
min-width: 18px;
min-height: 18px;
margin: 2px;
border-radius: 50%;
border: 1px solid @violet;
background: @text;
}
switch:checked > slider { border-color: @accent; }
scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; }
scrollbar slider:hover { background: @accent; }
/* module frames — Gtk.Frame paints borders reliably where boxes do not */
.module-frame { border: 3px solid @accent; border-radius: 16px; background: alpha(@violet, 0.4); }
.module-frame > box { padding: 10px 12px; }
/* floating panel + close button */
.panel { padding: 2px; }
.close-btn {
color: @text; background: alpha(@violet, 0.4);
border: none; border-radius: 20px;
min-width: 34px; min-height: 34px;
margin: 16px 20px; /* keep it clear of the module's drawn border */
transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease;
}
.close-btn:hover {
background: @accent;
color: @bg;
box-shadow: 0 0 14px 2px alpha(@accent, 0.55);
}
/* ---- holographic overlay (see window.py's Gtk.Overlay + _draw_hologram) --- */
.astro-hologram {
background: transparent;
}

View File

@ -0,0 +1,35 @@
"""Load the two stylesheets as ordered CSS providers.
_colors.css (generated from ~/Dotfiles/colors.conf by apply-theme.sh) defines the
five CyberQueer @define-color names; style.css consumes them. They are loaded as
two separate providers rather than via @import, because GTK4 resolves @import
paths unreliably.
Priority is USER+1, not APPLICATION: the CyberQueer GTK theme is installed as
~/.config/gtk-4.0/gtk.css (a symlink), which GTK4 loads at PRIORITY_USER (800)
above APPLICATION (600). Its aggressive `* { background-color: #1a1a1a }` would
otherwise beat our rules (e.g. the transparent structural containers that let the
modules float), so we must sit just above the user-level theme.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402
from paths import STYLE_DIR
def load_css() -> None:
display = Gdk.Display.get_default()
for name in ("_colors.css", "style.css"):
path = STYLE_DIR / name
if not path.exists():
continue
provider = Gtk.CssProvider()
provider.load_from_path(str(path))
Gtk.StyleContext.add_provider_for_display(
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1
)

View File

@ -0,0 +1,175 @@
"""Section 5: the full-width application drawer that replaces nwg-drawer.
Top to bottom: a favourites row (full-width module), then search, then a FlowBox of
all apps. Collapsed it is a bottom strip; expanded it fills down to the bottom.
Right-click / long-press an app tile to pin or unpin it from favourites.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps, GLib, Gtk # noqa: E402
from ui.favorites import Favorites, add_pin_gestures
_STRIP_HEIGHT = 150 # collapsed grid height (logical px)
class AppDrawer(Gtk.Box):
def __init__(self, settings, on_launch, on_toggle_expand):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add_css_class("appdrawer")
self.settings = settings
self._on_launch = on_launch
self._on_toggle_expand = on_toggle_expand
self._apps = AstalApps.Apps()
self._expanded = False
self.favorites = Favorites(settings, on_launch)
self.append(self.favorites)
self.append(Gtk.Separator())
self.append(self._build_header())
self._search = Gtk.SearchEntry(placeholder_text="Search applications")
self._search.add_css_class("appdrawer-search")
self._search.connect("search-changed", self._on_search)
self.append(self._search)
self._flow = Gtk.FlowBox(
selection_mode=Gtk.SelectionMode.NONE, homogeneous=True,
min_children_per_line=4, max_children_per_line=12,
row_spacing=8, column_spacing=8,
valign=Gtk.Align.START) # keep rows their natural height (no stretch)
self._flow.add_css_class("appdrawer-flow")
self._flow.connect("child-activated", self._on_child_activated)
self._scroll = Gtk.ScrolledWindow(
hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True)
self._scroll.set_propagate_natural_height(False)
self._scroll.set_child(self._flow)
self.append(self._scroll)
self._apply_mode()
self._populate(self._sorted_all())
# re-render tiles when favourites change so the pinned star stays in sync
settings.subscribe(self._on_settings_changed)
def _on_settings_changed(self) -> None:
# Deferred so we never rebuild the FlowBox from inside a tile's own gesture.
GLib.idle_add(lambda: (self._on_search(self._search), False)[1])
def _build_header(self) -> Gtk.Widget:
header = Gtk.CenterBox()
header.add_css_class("appdrawer-header")
title = Gtk.Label(label="Applications", xalign=0.0)
title.add_css_class("section-title")
header.set_start_widget(title)
self._expand_btn = Gtk.Button(label="") # nf-fa-expand (uniform with quad expand)
self._expand_btn.add_css_class("quad-action")
self._expand_btn.set_tooltip_text("Expand")
self._expand_btn.connect("clicked", lambda *_: self._toggle_expand())
header.set_end_widget(self._expand_btn)
return header
# -- expansion ---------------------------------------------------------
def _apply_mode(self) -> None:
if self._expanded:
self.set_vexpand(True)
self._scroll.set_min_content_height(_STRIP_HEIGHT)
self._scroll.set_max_content_height(100000)
self._expand_btn.set_label("") # nf-fa-compress
else:
self.set_vexpand(False)
self._scroll.set_min_content_height(_STRIP_HEIGHT)
self._scroll.set_max_content_height(_STRIP_HEIGHT)
self._expand_btn.set_label("") # nf-fa-expand
def _pop(self) -> None:
# bouncy scale pop on the app grid when the drawer expands; cleared after the
# animation so a re-layout (tiles loading) can't restart it into a loop.
self._scroll.add_css_class("drawer-pop")
GLib.timeout_add(320, self._clear_pop)
def _clear_pop(self) -> bool:
self._scroll.remove_css_class("drawer-pop")
return GLib.SOURCE_REMOVE
def _toggle_expand(self) -> None:
self._expanded = not self._expanded
self._apply_mode()
if self._expanded:
self._pop()
self._on_toggle_expand(self._expanded)
def set_expanded(self, value: bool) -> None:
if value != self._expanded:
self._toggle_expand()
# -- data --------------------------------------------------------------
def _sorted_all(self) -> list:
apps = list(self._apps.get_list())
apps.sort(key=lambda a: (-a.get_frequency(), a.get_name().lower()))
return apps
def _on_search(self, entry: Gtk.SearchEntry) -> None:
text = entry.get_text().strip()
results = self._apps.fuzzy_query(text) if text else self._sorted_all()
self._populate(results)
def _populate(self, apps: list) -> None:
child = self._flow.get_first_child()
while child:
self._flow.remove(child)
child = self._flow.get_first_child()
for app in apps:
self._flow.append(self._app_button(app))
def _app_button(self, app) -> Gtk.Widget:
btn = Gtk.Button(valign=Gtk.Align.START)
btn.add_css_class("app-tile")
btn.app = app
overlay = Gtk.Overlay()
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable")
icon.set_pixel_size(48)
label = Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=12,
justify=Gtk.Justification.CENTER)
content.append(icon)
content.append(label)
overlay.set_child(content)
# a star marks pinned apps (visible only when favourited)
star = Gtk.Label(label="")
star.add_css_class("fav-star")
star.set_halign(Gtk.Align.END)
star.set_valign(Gtk.Align.START)
star.set_visible(self.settings.is_favorite(app.get_entry()))
overlay.add_overlay(star)
btn.set_child(overlay)
btn.connect("clicked", lambda *_: self._launch(app))
add_pin_gestures(btn, lambda: self._toggle_fav(app))
return btn
def _toggle_fav(self, app) -> None:
self.settings.toggle_favorite(app.get_entry())
def _on_child_activated(self, _flow, child) -> None:
btn = child.get_child()
if btn and getattr(btn, "app", None):
self._launch(btn.app)
def _launch(self, app) -> None:
try:
app.launch()
except Exception:
pass
self._on_launch()
def on_show(self) -> None:
self._search.set_text("")
self._populate(self._sorted_all())
self.favorites.refresh()
self._search.grab_focus()

View File

@ -0,0 +1,106 @@
"""Full-width favourites row at the top of the app drawer.
Shows pinned apps (settings["favorites"], a list of .desktop entry ids). If nothing
is pinned yet it falls back to the most-frequently-launched apps. Pin/unpin from the
drawer grid via right-click / long-press.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps, Gtk # noqa: E402
def add_pin_gestures(widget: Gtk.Widget, on_toggle) -> None:
"""Wire right-click and long-press on `widget` to pin/unpin.
Uses the CAPTURE phase and claims the sequence so the gesture fires reliably on a
Gtk.Button (whose own primary-click gesture would otherwise swallow it) and does
not also trigger the button's launch action."""
def fire(gesture, *_a) -> None:
on_toggle()
gesture.set_state(Gtk.EventSequenceState.CLAIMED)
rclick = Gtk.GestureClick(button=3)
rclick.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
rclick.connect("pressed", fire)
widget.add_controller(rclick)
longpress = Gtk.GestureLongPress()
longpress.set_touch_only(False)
longpress.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
longpress.connect("pressed", fire)
widget.add_controller(longpress)
class Favorites(Gtk.Box):
def __init__(self, settings, on_launch):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("favorites")
self.settings = settings
self._on_launch = on_launch
self._apps = AstalApps.Apps()
title = Gtk.Label(label="Favorites", xalign=0.0)
title.add_css_class("section-title")
self.append(title)
self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self._row.add_css_class("favorites-row")
scroller = Gtk.ScrolledWindow(
vscrollbar_policy=Gtk.PolicyType.NEVER,
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
scroller.set_child(self._row)
self.append(scroller)
settings.subscribe(self.refresh)
self.refresh()
def _by_entry(self) -> dict:
return {a.get_entry(): a for a in self._apps.get_list() if a.get_entry()}
def _resolve(self) -> list:
by_entry = self._by_entry()
entries = self.settings.favorites()
if entries:
return [by_entry[e] for e in entries if e in by_entry]
# fallback: most-used apps
apps = sorted(self._apps.get_list(), key=lambda a: -a.get_frequency())
return [a for a in apps if a.get_frequency() > 0][:8]
def refresh(self) -> None:
child = self._row.get_first_child()
while child:
self._row.remove(child)
child = self._row.get_first_child()
apps = self._resolve()
if not apps:
self._row.append(Gtk.Label(label="Right-click an app below to pin it",
xalign=0.0))
return
for app in apps:
self._row.append(self._tile(app))
def _tile(self, app) -> Gtk.Widget:
btn = Gtk.Button()
btn.add_css_class("fav-tile")
content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable")
icon.set_pixel_size(28)
content.append(icon)
content.append(Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=14))
btn.set_child(content)
btn.connect("clicked", lambda *_a: self._launch(app))
# right-click / long-press unpins
add_pin_gestures(btn, lambda: self.settings.toggle_favorite(app.get_entry()))
return btn
def _launch(self, app) -> None:
try:
app.launch()
except Exception:
pass
self._on_launch()

View File

@ -0,0 +1,158 @@
"""Generic chrome around any module: a header (icon, title, expand, settings) and
a body that is either the module's compact widget or an 'enable me' placeholder.
A disabled quad never calls the module's build(), so a disabled Bluetooth/Network
quad spawns no backend work at all.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from module_base import ModuleContext, ModuleInstance, ModuleSpec
class QuadCard(Gtk.Box):
def __init__(self, spec: ModuleSpec, settings, services,
request_expand, request_collapse):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("quad-card")
self.spec = spec
self.settings = settings
self.services = services
self.ctx = ModuleContext(spec, settings, services, request_expand, request_collapse)
self.instance: ModuleInstance | None = None
self._header = self._build_header()
self.append(self._header)
self._body_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._body_holder.add_css_class("quad-body")
self._body_holder.set_vexpand(True)
self.append(self._body_holder)
self._features: dict[str, bool] = {}
self._rebuild_body()
# -- header ------------------------------------------------------------
def _build_header(self) -> Gtk.Widget:
header = Gtk.CenterBox()
header.add_css_class("quad-header")
title = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
icon = Gtk.Label(label=self.spec.icon)
icon.add_css_class("quad-icon")
name = Gtk.Label(label=self.spec.title, xalign=0.0)
name.add_css_class("quad-title")
title.append(icon)
title.append(name)
header.set_start_widget(title)
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
self._expand_btn = Gtk.Button(label="") # nf-fa-expand
self._expand_btn.add_css_class("quad-action")
self._expand_btn.set_tooltip_text("Expand")
self._expand_btn.connect("clicked", lambda *_: self.ctx.expand())
actions.append(self._build_settings_button())
actions.append(self._expand_btn)
header.set_end_widget(actions)
return header
def _build_settings_button(self) -> Gtk.Widget:
btn = Gtk.MenuButton(label="") # nf-fa-cog
btn.add_css_class("quad-action")
btn.set_tooltip_text("Settings")
pop = Gtk.Popover()
pop.add_css_class("quad-settings")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
box.append(self._switch_row("Enabled", self.settings.quad_enabled(
self.spec.id, self.spec.default_enabled),
lambda v: self.settings.set_quad_enabled(self.spec.id, v)))
for feat in self.spec.features:
box.append(Gtk.Separator())
box.append(self._switch_row(
feat.label, self.settings.feature(self.spec.id, feat.id, feat.default),
lambda v, fid=feat.id: self.settings.set_feature(self.spec.id, fid, v)))
pop.set_child(box)
btn.set_popover(pop)
return btn
@staticmethod
def _switch_row(label: str, value: bool, on_change) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
row.add_css_class("switch-row")
lbl = Gtk.Label(label=label, xalign=0.0, hexpand=True)
sw = Gtk.Switch(active=value, valign=Gtk.Align.CENTER)
sw.connect("state-set", lambda _sw, v: (on_change(v), False)[1])
row.append(lbl)
row.append(sw)
return row
# -- body --------------------------------------------------------------
def _clear_body(self) -> None:
if self.instance and self.instance.destroy:
self.instance.destroy()
self.instance = None
child = self._body_holder.get_first_child()
while child:
self._body_holder.remove(child)
child = self._body_holder.get_first_child()
def _rebuild_body(self) -> None:
self._clear_body()
self._features = self._feature_snapshot()
enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled)
if enabled:
inst = self.spec.build(self.ctx)
self.instance = inst
self._body_holder.append(inst.compact)
self._expand_btn.set_sensitive(inst.expanded is not None)
else:
self._expand_btn.set_sensitive(False)
self._body_holder.append(self._disabled_placeholder())
def _feature_snapshot(self) -> dict[str, bool]:
return {f.id: self.settings.feature(self.spec.id, f.id, f.default)
for f in self.spec.features}
def _disabled_placeholder(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10,
valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
box.add_css_class("quad-disabled")
box.append(Gtk.Label(label=f"{self.spec.title} is off"))
btn = Gtk.Button(label="Enable")
btn.add_css_class("enable-btn")
btn.connect("clicked",
lambda *_: self.settings.set_quad_enabled(self.spec.id, True))
box.append(btn)
return box
# -- lifecycle ---------------------------------------------------------
def on_settings_changed(self) -> None:
"""Called by the grid when settings.json changes; rebuild if this quad's
enablement flipped or any of its feature toggles changed, so every switch in
the settings popover takes effect live (the popover is only reachable while
the grid is collapsed, so no expanded view is ever reparented mid-rebuild)."""
enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled)
has_module = self.instance is not None
features_changed = enabled and self._feature_snapshot() != self._features
if enabled != has_module or features_changed:
self._rebuild_body()
@property
def expanded_widget(self) -> Gtk.Widget | None:
return self.instance.expanded if self.instance else None
def on_show(self) -> None:
if self.instance and self.instance.on_show:
self.instance.on_show()
def on_hide(self) -> None:
if self.instance and self.instance.on_hide:
self.instance.on_hide()

View File

@ -0,0 +1,191 @@
"""The 2x2 quad region and its expand-over-the-others behaviour.
A Gtk.Overlay stacks two things in the same space:
* base : a 2x2 Gtk.Grid of QuadCards
* overlay: a Revealer that, when a quad expands, fills the whole region (the full
content width, covering all four cells) with that module's expanded
view wrapped in a small header carrying a collapse button.
Because the overlay fills the region exactly, the expanded quad is as wide as the
appdrawer below it, and the outer letterbox margins (applied further up the tree)
are untouched so letterboxing stays identical in every state.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
from lib.border import bordered
from module_base import ModuleSpec
from ui.quadcard import QuadCard
class QuadGrid(Gtk.Overlay):
def __init__(self, specs: list[ModuleSpec], settings, services):
super().__init__()
self.add_css_class("quad-region")
self.set_vexpand(True)
self.settings = settings
self._expanded_id: str | None = None
self.grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=True,
column_spacing=10, row_spacing=10)
self.grid.add_css_class("quad-grid")
self.set_child(self.grid)
self.cards: dict[str, QuadCard] = {}
for index, spec in enumerate(specs[:4]):
card = QuadCard(spec, settings, services,
self.request_expand, self.request_collapse)
self.cards[spec.id] = card
self.grid.attach(bordered(card, radius=16, fill_bg=True),
index % 2, index // 2, 1, 1)
# overlay used for the expanded quad
self._expand_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.CROSSFADE,
transition_duration=180, reveal_child=False)
self._expand_reveal.set_halign(Gtk.Align.FILL)
self._expand_reveal.set_valign(Gtk.Align.FILL)
self._expand_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._expand_holder.add_css_class("quad-expanded")
self._expand_card = bordered(self._expand_holder, radius=16, fill_bg=True)
self._expand_reveal.set_child(self._expand_card)
self.add_overlay(self._expand_reveal)
# The overlay's holder is opaque; keep the whole overlay hidden unless a quad
# is actually expanded, otherwise it paints over the 2x2 grid.
self._expand_reveal.set_visible(False)
# A second overlay: an external "takeover" widget (the taskbar's workspace/
# window panel) that, when shown, covers the whole quad region.
self._takeover_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.CROSSFADE,
transition_duration=180, reveal_child=False)
self._takeover_reveal.set_halign(Gtk.Align.FILL)
self._takeover_reveal.set_valign(Gtk.Align.FILL)
self._takeover_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._takeover_holder.add_css_class("quad-expanded")
self._takeover_card = bordered(self._takeover_holder, radius=16, fill_bg=True)
self._takeover_reveal.set_child(self._takeover_card)
self.add_overlay(self._takeover_reveal)
self._takeover_reveal.set_visible(False)
settings.subscribe(self._on_settings_changed)
# -- external takeover (taskbar panel) ---------------------------------
def set_takeover_widget(self, widget: Gtk.Widget, on_back) -> None:
"""Mount an external widget (once) that will cover the quad region when shown.
A Back row is prepended so the covered quads can be restored."""
header = Gtk.CenterBox()
header.add_css_class("expanded-header")
back = Gtk.Button(label="") # nf-fa-compress (unfullscreen)
back.add_css_class("quad-action")
back.set_tooltip_text("Collapse")
back.connect("clicked", lambda *_: on_back())
header.set_start_widget(back)
self._takeover_holder.append(header)
widget.set_vexpand(True)
self._takeover_holder.append(widget)
def show_takeover(self) -> None:
self._takeover_reveal.set_visible(True)
self._takeover_card.add_css_class("quad-pop") # same bouncy scale as a quad
GLib.timeout_add(340, lambda: (self._takeover_card.remove_css_class("quad-pop"),
GLib.SOURCE_REMOVE)[1])
self._takeover_reveal.set_reveal_child(True)
self.grid.add_css_class("dimmed")
def hide_takeover(self) -> None:
self._takeover_reveal.set_reveal_child(False)
self._takeover_reveal.set_visible(False)
self.grid.remove_css_class("dimmed")
# -- expansion ---------------------------------------------------------
def request_expand(self, module_id: str) -> None:
card = self.cards.get(module_id)
if not card or card.instance is None or card.instance.expanded is None:
return
# A module opts into expansion by supplying a distinct expanded widget
# (a separate instance), so no reparenting of the compact cell is needed.
content = card.instance.expanded
# reparent content into the expanded holder
self._clear_expand_holder()
self._expand_reveal.set_visible(True)
self._expand_holder.append(self._expanded_header(card.spec.title))
if card.instance.scroll_expanded:
wrap = Gtk.ScrolledWindow(vexpand=True,
hscrollbar_policy=Gtk.PolicyType.NEVER)
wrap.set_child(content)
self._expand_holder.append(wrap)
else:
content.set_vexpand(True)
self._expand_holder.append(content)
self._expanded_id = module_id
self._expand_reveal.set_reveal_child(True)
# bouncy scale pop (CSS @keyframes). Keep the class only for the animation's
# duration, then drop it: while it's applied, any re-layout of the card (module
# content streaming in, the grid updating underneath) restarts the transform
# animation, which made the pop loop forever.
self._expand_card.add_css_class("quad-pop")
GLib.timeout_add(340, self._clear_pop)
self.grid.add_css_class("dimmed")
card.on_show()
def _clear_pop(self) -> bool:
self._expand_card.remove_css_class("quad-pop")
return GLib.SOURCE_REMOVE
def request_collapse(self) -> None:
self._expand_reveal.set_reveal_child(False)
self._expand_reveal.set_visible(False)
self._expand_card.remove_css_class("quad-pop") # reset so the pop replays
self._expanded_id = None
self.grid.remove_css_class("dimmed")
# Drop the reference to the reparented widget so the card can reuse it.
self._clear_expand_holder()
def _clear_expand_holder(self) -> None:
child = self._expand_holder.get_first_child()
while child:
# detach any ScrolledWindow's child so it survives for the card
if isinstance(child, Gtk.ScrolledWindow):
inner = child.get_child()
if inner:
child.set_child(None)
self._expand_holder.remove(child)
child = self._expand_holder.get_first_child()
def _expanded_header(self, title: str) -> Gtk.Widget:
header = Gtk.CenterBox()
header.add_css_class("expanded-header")
back = Gtk.Button(label="") # nf-fa-compress (unfullscreen)
back.add_css_class("quad-action")
back.set_tooltip_text("Collapse")
back.connect("clicked", lambda *_: self.request_collapse())
header.set_start_widget(back)
lbl = Gtk.Label(label=title)
lbl.add_css_class("quad-title")
header.set_center_widget(lbl)
return header
@property
def is_expanded(self) -> bool:
return self._expanded_id is not None
# -- lifecycle ---------------------------------------------------------
def _on_settings_changed(self) -> None:
for card in self.cards.values():
card.on_settings_changed()
def on_show(self) -> None:
for card in self.cards.values():
card.on_show()
def on_hide(self) -> None:
if self.is_expanded:
self.request_collapse()
for card in self.cards.values():
card.on_hide()

View File

@ -0,0 +1,133 @@
"""A minimalist system-stats line for the top of the panel.
CPU / RAM / GPU / disk utilisation plus network up/down rate, refreshed on a timer.
Everything is read straight from /proc and /sys on the main thread these are local
file reads that take microseconds, so no subprocess or worker thread is needed.
"""
from __future__ import annotations
import glob
import os
import time
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
_GPU_FILES = glob.glob("/sys/class/drm/card*/device/gpu_busy_percent")
_NET_SKIP = ("lo", "docker", "veth", "br-", "virbr", "tun", "tap")
def _human(rate: float) -> str:
for unit in ("B", "K", "M", "G"):
if rate < 1024 or unit == "G":
return f"{rate:4.1f}{unit}"
rate /= 1024
return f"{rate:4.1f}G"
class Statsbar(Gtk.Box):
def __init__(self):
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=18,
halign=Gtk.Align.CENTER)
self.add_css_class("statsbar")
# short text labels rather than nerd glyphs (some don't exist in the font and
# render as tofu); keeps the line legible on any theme.
self._cpu = self._cell("CPU")
self._ram = self._cell("MEM")
self._gpu = self._cell("GPU")
self._disk = self._cell("DISK")
self._down = self._cell("")
self._up = self._cell("")
self._prev_cpu: tuple[int, int] | None = None
self._prev_net: tuple[int, int] | None = None
self._prev_t: float | None = None
self._refresh()
self._source = GLib.timeout_add_seconds(2, self._tick)
def _cell(self, icon: str) -> Gtk.Label:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
ic = Gtk.Label(label=icon)
ic.add_css_class("stat-icon")
val = Gtk.Label(label="")
val.add_css_class("stat-value")
box.append(ic)
box.append(val)
self.append(box)
return val
def _tick(self) -> bool:
self._refresh()
return GLib.SOURCE_CONTINUE
# -- readers -----------------------------------------------------------
@staticmethod
def _cpu_pct(prev) -> tuple[float, tuple[int, int]]:
parts = [int(x) for x in open("/proc/stat").readline().split()[1:]]
idle = parts[3] + parts[4] # idle + iowait
total = sum(parts)
if prev is None:
return 0.0, (total, idle)
dt, di = total - prev[0], idle - prev[1]
pct = (1 - di / dt) * 100 if dt > 0 else 0.0
return max(0.0, min(100.0, pct)), (total, idle)
@staticmethod
def _ram_pct() -> float:
mem = {}
for line in open("/proc/meminfo"):
k, _, v = line.partition(":")
mem[k] = int(v.split()[0])
if len(mem) > 4 and "MemAvailable" in mem:
break
total, avail = mem.get("MemTotal", 1), mem.get("MemAvailable", 0)
return (1 - avail / total) * 100
@staticmethod
def _gpu_pct() -> float:
best = 0
for f in _GPU_FILES:
try:
best = max(best, int(open(f).read().strip()))
except (OSError, ValueError):
pass
return float(best)
@staticmethod
def _disk_pct() -> float:
s = os.statvfs("/")
return (1 - s.f_bfree / s.f_blocks) * 100 if s.f_blocks else 0.0
@staticmethod
def _net_bytes() -> tuple[int, int]:
rx = tx = 0
with open("/proc/net/dev") as fh:
for line in fh.readlines()[2:]:
name, _, rest = line.partition(":")
name = name.strip()
if name.startswith(_NET_SKIP):
continue
cols = rest.split()
rx += int(cols[0])
tx += int(cols[8])
return rx, tx
def _refresh(self) -> None:
cpu, self._prev_cpu = self._cpu_pct(self._prev_cpu)
self._cpu.set_text(f"{cpu:2.0f}%")
self._ram.set_text(f"{self._ram_pct():2.0f}%")
self._gpu.set_text(f"{self._gpu_pct():2.0f}%")
self._disk.set_text(f"{self._disk_pct():2.0f}%")
now = time.monotonic()
rx, tx = self._net_bytes()
if self._prev_net is not None and self._prev_t is not None and now > self._prev_t:
dt = now - self._prev_t
self._down.set_text(_human((rx - self._prev_net[0]) / dt) + "/s")
self._up.set_text(_human((tx - self._prev_net[1]) / dt) + "/s")
self._prev_net, self._prev_t = (rx, tx), now

View File

@ -0,0 +1,477 @@
"""Full-width taskbar strip: jump to any open window, plus an expand-over panel.
Compact: app-grouped icons; click focuses the window (single) or opens a pop-out of
instances (grouped). The toggle collapses this compact strip and hands its panel
(exposed as `panel_widget`) to the menu window, which mounts it over the quad region
so it *covers* the 2x2 quads rather than pushing anything down. The panel holds:
* workspace/layout controls pick the current workspace's layout (scrolling /
dwindle / master / monocle, enumerated from ~/.cache/astro-menu/layouts.json,
written by hypr/layouts) and, for directional layouts, its direction. Applied
live via `hyprctl eval 'layouts.set(ws, name, dir)'`.
* a per-window row list: [icon + title focus/jump] [ pull to this workspace].
"""
from __future__ import annotations
import json
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps, Gtk # noqa: E402
from lib.proc import run_json, run_text
from paths import CACHE_DIR
_LAYOUTS_MANIFEST = CACHE_DIR / "layouts.json"
_COLUMNS_STATE = CACHE_DIR / "columns-state.json"
_LAYOUTS_STATE = CACHE_DIR / "layouts-state.json"
def _eval(lua: str) -> None:
run_text(["hyprctl", "eval", lua], lambda *_a: None)
def _dispatch(lua: str) -> None:
# In hyprlua, `hyprctl dispatch` evaluates its argument as Lua (the hl.dsp.* API).
run_text(["hyprctl", "dispatch", lua], lambda *_a: None)
class Taskbar(Gtk.Box):
def __init__(self, on_activate, on_toggle_panel=None):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.add_css_class("taskbar")
self._on_activate = on_activate
self._on_toggle_panel = on_toggle_panel # window mounts the panel over the quads
self._apps = AstalApps.Apps()
self._wm_index = self._build_wm_index()
self._layouts = self._load_layouts()
self._active_ws = None
self._clients: list = []
self._dir_dds = {}
self._fit_sws = {}
# The workspace/window panel. It is NOT appended here: the menu window mounts
# `panel_widget` into the quad region so that expanding COLLAPSES this strip's
# body (below) and the panel COVERS the 2x2 quads instead of pushing anything.
self._panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._panel.add_css_class("task-panel")
self._panel_scroll = Gtk.ScrolledWindow(
hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True)
self._panel_scroll.set_child(self._panel)
header = Gtk.CenterBox()
title = Gtk.Label(label="Open windows", xalign=0.0)
title.add_css_class("section-title")
header.set_start_widget(title)
self._expand_btn = Gtk.ToggleButton(label="") # nf-fa-expand
self._expand_btn.add_css_class("quad-action")
self._expand_btn.set_tooltip_text("Workspace & window controls")
self._expand_btn.connect("toggled", lambda b: self._toggle_panel(b.get_active()))
header.set_end_widget(self._expand_btn)
self.append(header)
# compact icon strip (hidden while the panel is expanded)
self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self._row.add_css_class("taskbar-row")
self._strip = Gtk.ScrolledWindow(
vscrollbar_policy=Gtk.PolicyType.NEVER,
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
self._strip.set_child(self._row)
self.append(self._strip)
# -- expand-over-the-quads panel --------------------------------------
@property
def panel_widget(self) -> Gtk.Widget:
"""The workspace/window panel body; the window mounts this over the quads."""
return self._panel_scroll
def _toggle_panel(self, active: bool) -> None:
# Collapse this strip's compact body; the window reveals/hides the panel that
# it has mounted over the quad region (via on_toggle_panel).
self._strip.set_visible(not active)
if self._on_toggle_panel:
self._on_toggle_panel(active)
if active:
self.refresh()
def collapse_panel(self) -> None:
"""Return to the compact strip (called on menu hide / from the panel's Back)."""
if self._expand_btn.get_active():
self._expand_btn.set_active(False) # fires toggled -> _toggle_panel(False)
else:
self._strip.set_visible(True)
# -- setup helpers -----------------------------------------------------
def _build_wm_index(self) -> dict:
idx = {}
for app in self._apps.get_list():
for key in (app.get_wm_class(), app.get_executable(), app.get_name()):
if key:
idx.setdefault(key.lower(), app)
return idx
def _icon_for(self, cls: str) -> str:
app = self._wm_index.get((cls or "").lower())
if app and app.get_icon_name():
return app.get_icon_name()
return (cls or "application-x-executable").lower()
@staticmethod
def _load_layouts() -> list:
try:
return json.loads(_LAYOUTS_MANIFEST.read_text())
except (FileNotFoundError, json.JSONDecodeError):
# fallback if hypr/layouts hasn't written the manifest yet
return [{"name": "scrolling", "label": "Scrolling", "directional": True,
"dirs": ["down", "up", "right", "left"], "default_dir": "down",
"fit_method": True},
{"name": "columns", "label": "Columns", "directional": True,
"dirs": ["right", "down"], "dir_labels": ["Left / Right", "Up / Down"],
"default_dir": "right", "fit_method": True},
{"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []},
{"name": "master", "label": "Master", "directional": False, "dirs": []},
{"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}]
# -- populate ----------------------------------------------------------
def refresh(self) -> None:
run_json(["hyprctl", "clients", "-j"], self._on_clients)
run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws)
def _on_ws(self, ok: bool, data) -> None:
if ok and isinstance(data, dict):
self._active_ws = data.get("id")
self._rebuild_panel()
def _on_clients(self, ok: bool, data) -> None:
self._clients = data if ok and isinstance(data, list) else []
self._rebuild_strip()
self._rebuild_panel()
# -- compact strip -----------------------------------------------------
def _rebuild_strip(self) -> None:
self._clear(self._row)
groups: dict[str, list] = {}
for w in self._clients:
if not w.get("mapped", True) or not w.get("class"):
continue
groups.setdefault(w["class"], []).append(w)
if not groups:
self._row.append(Gtk.Label(label="No open windows"))
return
for cls, wins in sorted(groups.items()):
self._row.append(self._group_button(cls, wins))
def _group_button(self, cls: str, wins: list) -> Gtk.Widget:
icon = Gtk.Image.new_from_icon_name(self._icon_for(cls))
icon.set_pixel_size(32)
if len(wins) == 1:
btn = Gtk.Button()
btn.add_css_class("task-tile")
btn.set_child(icon)
btn.set_tooltip_text(wins[0].get("title") or cls)
btn.connect("clicked", lambda *_a, w=wins[0]: self._focus(w))
return btn
btn = Gtk.MenuButton()
btn.add_css_class("task-tile")
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
box.append(icon)
badge = Gtk.Label(label=str(len(wins)))
badge.add_css_class("task-badge")
box.append(badge)
btn.set_child(box)
btn.set_tooltip_text(f"{cls} ({len(wins)})")
pop = Gtk.Popover()
pop.add_css_class("task-popover")
plist = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
for w in wins:
item = Gtk.Button(label=w.get("title") or cls)
item.add_css_class("task-window")
item.connect("clicked", lambda *_a, ww=w: (pop.popdown(), self._focus(ww)))
plist.append(item)
pop.set_child(plist)
btn.set_popover(pop)
return btn
# -- pop-open panel: layout controls + window list ---------------------
def _rebuild_panel(self) -> None:
self._clear(self._panel)
# tabbed layout selector for the active workspace: a tab per layout, and a
# per-layout options page underneath that switches with the tab. Selecting a
# tab applies that layout to the currently focused workspace.
ws = self._active_ws
hdr = Gtk.Label(label=f"Workspace {ws if ws is not None else '?'} layout", xalign=0.0)
hdr.add_css_class("section-title")
self._panel.append(hdr)
self._dir_dds = {} # layout name -> (Gtk.DropDown, [dir values])
self._fit_sws = {} # layout name -> Gtk.Switch
self._layout_stack = Gtk.Stack()
for ly in self._layouts:
self._layout_stack.add_titled(self._layout_page(ly), ly["name"], ly["label"])
switcher = Gtk.StackSwitcher(stack=self._layout_stack)
switcher.add_css_class("net-switcher") # reuse the tab pill styling
self._panel.append(switcher)
self._panel.append(self._layout_stack)
self._layout_stack.connect("notify::visible-child-name", self._on_tab_switch)
self._sync_layout_controls()
self._panel.append(Gtk.Separator())
# per-window rows: [focus/jump] [pull here]
wins = [w for w in self._clients if w.get("mapped", True) and w.get("class")]
wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0),
(w.get("title") or w.get("class") or "").lower()))
if not wins:
self._panel.append(Gtk.Label(label="No open windows", xalign=0.0))
return
listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(listing)
for w in wins:
listing.append(self._window_row(w))
self._panel.append(scroller)
def _window_row(self, w) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.add_css_class("task-row")
icon = Gtk.Image.new_from_icon_name(self._icon_for(w.get("class", "")))
icon.set_pixel_size(22)
wsid = w.get("workspace", {}).get("id")
title = (w.get("title") or w.get("class") or "?")
name = Gtk.Button(hexpand=True)
name.add_css_class("task-name")
lbl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
lbl.append(icon)
wl = Gtk.Label(label=f"{title}", xalign=0.0, hexpand=True, ellipsize=3, max_width_chars=32)
lbl.append(wl)
lbl.append(Gtk.Label(label=f"ws {wsid}", xalign=1.0))
name.set_child(lbl)
name.set_tooltip_text("Jump to window")
name.connect("clicked", lambda *_a: self._focus(w))
row.append(name)
pull = Gtk.Button(label="⇤ here")
pull.add_css_class("quad-action")
pull.set_tooltip_text("Pull this window to the current workspace")
pull.connect("clicked", lambda *_a: self._pull(w))
row.append(pull)
return row
# -- per-layout options page -------------------------------------------
def _layout_page(self, ly: dict) -> Gtk.Widget:
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
page.add_css_class("layout-page")
if ly.get("dirs"):
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="direction", xalign=0.0, hexpand=True))
# dir_labels (optional) are friendly labels shown in place of the raw
# scrolling:direction values (e.g. "Left / Right" for "right").
labels = ly.get("dir_labels") or ly["dirs"]
dd = Gtk.DropDown.new_from_strings(labels)
dd.connect("notify::selected", self._on_opt_change)
row.append(dd)
page.append(row)
self._dir_dds[ly["name"]] = (dd, list(ly["dirs"]))
if ly.get("fit_method"):
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="center-focused", xalign=0.0, hexpand=True))
sw = Gtk.Switch(valign=Gtk.Align.CENTER)
if ly["name"] == "columns":
sw.set_tooltip_text("Keep the focused window centred in its column as "
"it scrolls, instead of the minimal-movement default")
else:
sw.set_tooltip_text("Keep the focused column centred so the prev/next "
"columns stay on-screen and tappable")
sw.connect("state-set", lambda s, state, name=ly["name"]: self._on_fit_toggle(s, state, name))
row.append(sw)
page.append(row)
self._fit_sws[ly["name"]] = sw
if ly.get("stepper"): # Columns: (-)[N](+)
page.append(self._cols_stepper())
if not ly.get("dirs") and not ly.get("fit_method") and not ly.get("stepper"):
page.append(Gtk.Label(label="No adjustable options", xalign=0.0,
css_classes=["net-ip"]))
return page
# -- columns count stepper --------------------------------------------
def _cols_stepper(self) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="Columns", xalign=0.0, hexpand=True))
minus = Gtk.Button(label="")
minus.add_css_class("quad-action")
self._cols_lbl = Gtk.Label(label=str(self._read_cols()))
self._cols_lbl.add_css_class("stepper-value")
plus = Gtk.Button(label="+")
plus.add_css_class("quad-action")
minus.connect("clicked", lambda *_a: self._step_cols(-1))
plus.connect("clicked", lambda *_a: self._step_cols(1))
row.append(minus)
row.append(self._cols_lbl)
row.append(plus)
return row
def _read_cols(self) -> int:
try:
data = json.loads(_COLUMNS_STATE.read_text())
return int(data.get(str(self._active_ws), {}).get("cols", 2))
except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError):
return 2
def _step_cols(self, delta: int) -> None:
try:
cur = int(self._cols_lbl.get_label())
except ValueError:
cur = self._read_cols()
new = max(1, cur + delta)
self._cols_lbl.set_label(str(new))
_eval(f'hl.dispatch(hl.dsp.layout("cols {"+1" if delta > 0 else "-1"}"))')
def _read_center(self) -> bool:
# columns.lua's own "center-focused" switch — global, published in the same
# cache file as the columns stepper's count (see columns.lua's publish()).
try:
data = json.loads(_COLUMNS_STATE.read_text())
return bool(data.get("_center", False))
except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError):
return False
# -- sync + handlers ---------------------------------------------------
def _sync_layout_controls(self) -> None:
# Reflect the current per-ws layout / direction / focus_fit in the tabs+options.
#
# This fires 3 concurrent hyprctl subprocess calls with no guaranteed resolution
# order, and refresh() can trigger _rebuild_panel() (hence this) twice in a row
# (once from the clients query, once from the activeworkspace one) — each
# rebuilding a fresh _layout_stack/_dir_dds/_fit_sws. Two failure modes existed:
# 1. The 3 callbacks used to each toggle the shared `self._syncing` flag
# independently (True at start, False at end). Whichever resolved *first*
# dropped the guard while the others — in particular the tab-selection one,
# which programmatically flips the visible stack child — were still in
# flight, letting `_on_tab_switch`/`_on_opt_change` misfire as if the user
# had clicked and re-apply stale data via `layouts.set(...)`.
# 2. A second `_sync_layout_controls()` call (from the redundant rebuild) could
# land while the first's callbacks were still pending, so a stale callback
# from sync #1 could decrement sync #2's pending-count or apply sync #1's
# (possibly wrong-workspace) data into sync #2's freshly built widgets.
# A generation token invalidates any sync superseded by a newer one outright —
# if the user switches workspaces (or a redundant rebuild fires) mid-flight, only
# the latest call's callbacks are allowed to touch anything, and self._syncing
# only lifts once *that* generation's 3 calls have all resolved.
self._sync_generation = getattr(self, "_sync_generation", 0) + 1
gen = self._sync_generation
ws = self._active_ws
self._syncing = True
pending = [3]
def _done() -> None:
if gen != self._sync_generation:
return # superseded — a newer sync owns self._syncing now
pending[0] -= 1
if pending[0] <= 0:
self._syncing = False
def _tab_cb(ok, data) -> None:
if gen == self._sync_generation:
self._apply_tab_sel(ws, ok, data)
_done()
def _dir_cb(ok, data) -> None:
if gen == self._sync_generation:
self._apply_dir_sel(ok, data)
_done()
def _fit_cb(ok, data) -> None:
if gen == self._sync_generation:
self._apply_fit_sel(ok, data)
_done()
run_json(["hyprctl", "getoption", "general:layout", "-j"], _tab_cb)
run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], _dir_cb)
run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], _fit_cb)
# columns' own center-focused state lives in columns-state.json, not a hyprctl
# option — read it synchronously rather than round-tripping a subprocess.
columns_sw = self._fit_sws.get("columns")
if columns_sw is not None:
columns_sw.set_active(self._read_center())
def _apply_tab_sel(self, ws, ok, data) -> None:
cur = data.get("str") if ok and isinstance(data, dict) else None
# prefer the per-workspace layout recorded by hypr/layouts (layouts.set)
try:
state = json.loads(_LAYOUTS_STATE.read_text())
cur = state.get(str(ws), cur)
except (FileNotFoundError, json.JSONDecodeError):
pass
if cur and any(ly["name"] == cur for ly in self._layouts):
self._layout_stack.set_visible_child_name(cur)
def _apply_dir_sel(self, ok, data) -> None:
# scrolling:direction is global; reflect it in every directional layout's
# dropdown whose value set contains it.
cur = data.get("str") if ok and isinstance(data, dict) else None
for dd, values in self._dir_dds.values():
if cur in values:
dd.set_selected(values.index(cur))
def _apply_fit_sel(self, ok, data) -> None:
# scrolling:focus_fit_method only describes the scrolling layout's own switch;
# columns' switch is synced separately from columns-state.json (see
# _sync_layout_controls) since it isn't backed by a hyprctl option at all.
val = data.get("int") if ok and isinstance(data, dict) else 0
sw = self._fit_sws.get("scrolling")
if sw is not None:
sw.set_active(val == 1)
def _cur_dir(self) -> str:
entry = self._dir_dds.get(self._layout_stack.get_visible_child_name())
if not entry:
return ""
dd, values = entry
i = dd.get_selected()
return values[i] if 0 <= i < len(values) else ""
def _on_tab_switch(self, *_a) -> None:
if getattr(self, "_syncing", False) or self._active_ws is None:
return
name = self._layout_stack.get_visible_child_name()
if name:
_eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
def _on_opt_change(self, *_a) -> None:
if getattr(self, "_syncing", False) or self._active_ws is None:
return
name = self._layout_stack.get_visible_child_name()
if name:
_eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
def _on_fit_toggle(self, _sw, state, name) -> bool:
if not getattr(self, "_syncing", False):
if name == "columns":
_eval(f'hl.dispatch(hl.dsp.layout("center {"on" if state else "off"}"))')
else:
_eval(f"layouts.set_fit({1 if state else 0})")
return False
# -- window actions ----------------------------------------------------
def _focus(self, w) -> None:
addr = w.get("address")
if addr:
_dispatch(f'hl.dsp.focus({{ window = "address:{addr}" }})')
self._on_activate()
def _pull(self, w) -> None:
addr = w.get("address")
if addr and self._active_ws is not None:
_dispatch(f'hl.dsp.window.move({{ window = "address:{addr}", '
f'workspace = "{self._active_ws}" }})')
run_json(["hyprctl", "clients", "-j"], self._on_clients) # reflect the move
@staticmethod
def _clear(box: Gtk.Box) -> None:
child = box.get_first_child()
while child:
box.remove(child)
child = box.get_first_child()

View File

@ -0,0 +1,243 @@
"""The popup: a content-sized floating layer-shell panel anchored top-centre.
Earlier this was a full-monitor overlay with a dim backdrop, but that blocks the
whole screen the invisible full-screen surface intercepts every click. Instead the
window now sizes to its own content and only occupies that area, leaving the rest of
the screen usable. It is dismissed with the launcher toggle, Esc, or the button
(there is no click-outside-to-close, since that would require a blocking full-screen
surface).
Gtk.Window (layer TOP, anchored TOP horizontally centred, height = content)
Gtk.Overlay
main : #panel-root (Taskbar / QuadGrid / AppDrawer, each drawn-bordered)
over : close button (top-right)
Expanding the app drawer additionally anchors the BOTTOM edge so the panel stretches
down and the drawer fills to the bottom; collapsing removes that anchor.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gdk, GLib, Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
import config
from appservices import Services
from lib.border import bordered
from lib.hologram import HologramOverlay
from registry import ordered_specs
from ui.appdrawer import AppDrawer
from ui.quadgrid import QuadGrid
from ui.statsbar import Statsbar
from ui.taskbar import Taskbar
PANEL_WIDTH_FRACTION = 0.5 # of the monitor width...
MAX_PANEL_WIDTH = 1100 # ...clamped to this
EDGE_MARGIN = 28 # gap from the anchored edge
BOTTOM_MARGIN = 34 # gap from the far edge when the drawer is expanded
SIDES = ("top", "bottom", "left", "right")
class MenuWindow(Gtk.ApplicationWindow):
def __init__(self, app, settings, services: Services):
super().__init__(application=app)
self.set_name("menu-window")
self.add_css_class("menu-window")
self.settings = settings
self.services = services
self._drawer_expanded = False
self._side = "top"
self._init_layer_shell()
self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.root.set_name("panel-root")
self.root.add_css_class("panel")
# minimalist system-stats line (full-width, very top)
self.statsbar = Statsbar()
self.statsbar_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN,
transition_duration=160, reveal_child=True)
self.statsbar_reveal.set_child(bordered(self.statsbar, border=2, radius=12, fill_bg=True))
self.root.append(self.statsbar_reveal)
# taskbar (full-width, top)
self.taskbar = Taskbar(on_activate=self.hide_menu,
on_toggle_panel=self._on_taskbar_panel)
self.taskbar_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN,
transition_duration=160, reveal_child=True)
self.taskbar_reveal.set_child(bordered(self.taskbar, fill_bg=True))
self.root.append(self.taskbar_reveal)
# quads
specs = list(ordered_specs(settings))
self.grid = QuadGrid(specs, settings, services)
# The taskbar's workspace/window panel is mounted over the quad region so that
# expanding it collapses the taskbar strip and covers the 2x2 quads.
self.grid.set_takeover_widget(self.taskbar.panel_widget,
on_back=self.taskbar.collapse_panel)
self.quad_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.SLIDE_UP,
transition_duration=200, reveal_child=True)
self.quad_reveal.set_child(self.grid)
self.root.append(self.quad_reveal)
# appdrawer
self.appdrawer = AppDrawer(settings, on_launch=self.hide_menu,
on_toggle_expand=self._on_appdrawer_expand)
self.appdrawer_wrap = bordered(self.appdrawer, fill_bg=True)
self.appdrawer_wrap.set_valign(Gtk.Align.FILL)
self.root.append(self.appdrawer_wrap)
overlay = Gtk.Overlay()
overlay.set_child(self.root)
self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root)
overlay.add_overlay(self._hologram.widget)
close = Gtk.Button(label="")
close.add_css_class("close-btn")
close.set_halign(Gtk.Align.END)
close.set_valign(Gtk.Align.START)
close.connect("clicked", lambda *_: self.hide_menu())
overlay.add_overlay(close)
self.set_child(overlay)
key = Gtk.EventControllerKey()
key.connect("key-pressed", self._on_key)
self.add_controller(key)
self._last_tick: float | None = None
self._tick_id: int | None = None
self.connect("map", lambda *_: self._apply_size())
self.set_visible(False)
# -- layer shell / size -----------------------------------------------
def _init_layer_shell(self) -> None:
LayerShell.init_for_window(self)
LayerShell.set_layer(self, LayerShell.Layer.TOP)
LayerShell.set_namespace(self, "astro-menu")
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND)
self._apply_anchor()
def set_side(self, side: str) -> None:
"""Anchor the panel to the given monitor edge ('top'|'bottom'|'left'|'right').
The compositor's layer 'slide' animation then slides it in from that edge, and
the panel sits flush against it. Set before showing so the map animates."""
if side in SIDES:
self._side = side
self._apply_anchor()
def _apply_anchor(self) -> None:
"""Pin the panel to its side edge; while the drawer is expanded, also pin the
perpendicular edges so it fills the screen height."""
E = LayerShell.Edge
for edge in (E.TOP, E.BOTTOM, E.LEFT, E.RIGHT):
LayerShell.set_anchor(self, edge, False)
LayerShell.set_margin(self, edge, 0)
primary = {"top": E.TOP, "bottom": E.BOTTOM, "left": E.LEFT, "right": E.RIGHT}[self._side]
LayerShell.set_anchor(self, primary, True)
LayerShell.set_margin(self, primary, EDGE_MARGIN)
if self._drawer_expanded:
# fill vertically so the expanded drawer reaches top and bottom
for edge in (E.TOP, E.BOTTOM):
LayerShell.set_anchor(self, edge, True)
if edge != primary:
LayerShell.set_margin(self, edge, BOTTOM_MARGIN)
def _monitor_width(self) -> int:
display = Gdk.Display.get_default()
surface = self.get_surface()
mon = display.get_monitor_at_surface(surface) if surface is not None else None
if mon is None:
monitors = display.get_monitors()
mon = monitors.get_item(0) if monitors.get_n_items() else None
return mon.get_geometry().width if mon is not None else 1920
def _apply_size(self) -> None:
width = min(int(self._monitor_width() * PANEL_WIDTH_FRACTION), MAX_PANEL_WIDTH)
self.root.set_size_request(width, -1)
# -- taskbar panel expansion ------------------------------------------
def _on_taskbar_panel(self, expanded: bool) -> None:
# Cover the quads with the taskbar's workspace/window panel. Fully collapse the
# taskbar strip (header included) so the expanded panel reads as ONE module
# rather than a thin strip on top plus a detached card. Collapse any quad first.
if expanded and self.grid.is_expanded:
self.grid.request_collapse()
self.taskbar_reveal.set_reveal_child(not expanded)
self.taskbar_reveal.set_visible(not expanded)
self.grid.show_takeover() if expanded else self.grid.hide_takeover()
# -- appdrawer expansion ----------------------------------------------
def _on_appdrawer_expand(self, expanded: bool) -> None:
self._drawer_expanded = expanded
self._apply_anchor()
# Hide (not just un-reveal) the stats/taskbar/quads so they reserve zero space
# and the drawer fills the whole panel.
for rev in (self.statsbar_reveal, self.quad_reveal, self.taskbar_reveal):
rev.set_reveal_child(not expanded)
rev.set_visible(not expanded)
self.appdrawer_wrap.set_vexpand(expanded)
# -- visibility --------------------------------------------------------
def show_menu(self, focus_appdrawer: bool = False) -> None:
self.appdrawer.set_expanded(False)
self.taskbar.refresh()
self.grid.on_show()
self.appdrawer.on_show()
self.set_visible(True)
self.present()
GLib.timeout_add(30, lambda: (self._apply_size(), False)[1])
if focus_appdrawer:
self.appdrawer.set_expanded(True)
if self._hologram.enabled and self._tick_id is None:
self._tick_id = self.add_tick_callback(self._on_tick)
self._hologram.start_intro()
def hide_menu(self) -> None:
self.grid.on_hide()
self.taskbar.collapse_panel()
self.grid.hide_takeover()
self.appdrawer.set_expanded(False)
# Play the closing dissolve first (content fades back into static), then
# actually hide via _finish_hide. If the hologram is off, hide at once.
if self._hologram.enabled and self._tick_id is not None:
self._hologram.start_outro(self._finish_hide)
else:
self._finish_hide()
def _finish_hide(self) -> None:
self.set_visible(False)
if self._tick_id is not None:
self.remove_tick_callback(self._tick_id)
self._tick_id = None
self._last_tick = None
def toggle(self, focus_appdrawer: bool = False) -> None:
if self.get_visible():
self.hide_menu()
else:
self.show_menu(focus_appdrawer)
# -- hologram animation loop --------------------------------------------
def _on_tick(self, _widget, frame_clock) -> bool:
now = frame_clock.get_frame_time() / 1_000_000
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
self._last_tick = now
self._hologram.tick(dt)
return True # keep ticking every frame while the menu is visible
def _on_key(self, _c, keyval, _kc, _state) -> bool:
if keyval == Gdk.KEY_Escape:
self.hide_menu()
return True
return False

View File

@ -0,0 +1,12 @@
[D-BUS Service]
# Route D-Bus activation of the freedesktop notification name to beacon instead
# of dunst. The dunst package ships /usr/share/dbus-1/services/org.knopwob.dunst.service
# which also claims Name=org.freedesktop.Notifications, so without this file D-Bus
# auto-activates dunst the moment any app posts a notification and dunst grabs the
# name before beacon can (beacon then exits on name-lost — see beacon/main.py).
# A service file in ~/.local/share/dbus-1/services/ takes precedence over /usr/share,
# so this wins the name resolution for org.freedesktop.Notifications.
#
# Deployed to ~/.local/share/dbus-1/services/ at install time (see updater.conf).
Name=org.freedesktop.Notifications
Exec=/home/themiro/Dotfiles/desktopenvs/hyprdrive/scripts/beacon-start.sh

View File

@ -0,0 +1,278 @@
"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as
the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu /
horizon-dock lib/hologram.py), reused here so notification cards read as one more
orbit of the same look: scanline grid + a slow vertical sweep + drifting noise
specks, and a 'materialise out of static' intro when a card first appears.
Each notification card owns its own overlay (see notification.py); the stack
window (window.py) runs a single frame-clock tick and feeds every visible card's
overlay via .tick(dt).
"""
from __future__ import annotations
import math
import random
import cairo
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram.
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
_MAGENTA = (0.92, 0.0, 0.65)
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
class HologramOverlay:
SCANLINE_GAP = 4.0
SCANLINE_ALPHA = 0.18 # a touch stronger than the bar's — cards are the focus
SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass
SWEEP_HALF_HEIGHT = 34.0 # card-sized panel, taller than the thin bar strip
NOISE_COUNT = 26
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 34.0 # smooth vertical fade-out
INTRO_DURATION = 0.9 # brisk 'materialise out of static' when a card pops in
INTRO_STATIC = 900 # static specks at the very start of the intro
OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on dismiss
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
intro_duration: float | None = None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the card shape
# widget whose opacity is ramped 0->1 during the intro so the card content
# genuinely fades in, rather than a solid haze block popping on
self._fade_widget = fade_widget
if intro_duration is not None:
self.INTRO_DURATION = intro_duration
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None
self._outro_t: float | None = None
self._outro_done = None
# Wall-clock safety net: the frame-clock tick only advances while the
# compositor sends frame callbacks; if those stall the intro could freeze
# with content stuck at opacity 0. This timeout force-resolves it anyway.
self._intro_deadline_id: int | None = None
self._mask_cache: tuple | None = None
self.widget = Gtk.DrawingArea()
self.widget.set_can_target(False) # never steals clicks from the card underneath
self.widget.add_css_class("beacon-hologram")
self.widget.set_hexpand(True)
self.widget.set_vexpand(True)
self.widget.set_halign(Gtk.Align.FILL)
self.widget.set_valign(Gtk.Align.FILL)
self.widget.set_draw_func(self._draw_frame)
def tick(self, dt: float) -> None:
if not self.enabled:
return
self._sat_time += dt
if self._intro_t is not None:
self._intro_t += dt
if self._intro_t >= self.INTRO_DURATION:
self._finish_intro()
elif self._fade_widget is not None:
p = self._intro_t / self.INTRO_DURATION
self._fade_widget.set_opacity(p * p * (3 - 2 * p))
if self._outro_t is not None:
self._outro_t += dt
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po))
if self._outro_t >= self.OUTRO_DURATION:
done = self._outro_done
self._outro_t = None
self._outro_done = None
if done is not None:
done()
self.widget.queue_draw()
def _finish_intro(self) -> None:
self._intro_t = None
if self._intro_deadline_id is not None:
GLib.source_remove(self._intro_deadline_id)
self._intro_deadline_id = None
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0)
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'card materialising out of static' opening effect."""
if self.enabled:
self._outro_t = None
self._outro_done = None
self._intro_t = 0.0
if self._fade_widget is not None:
self._fade_widget.set_opacity(0.0)
if self._intro_deadline_id is not None:
GLib.source_remove(self._intro_deadline_id)
self._intro_deadline_id = GLib.timeout_add(
int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline)
def _on_intro_deadline(self) -> bool:
self._intro_deadline_id = None
if self._intro_t is not None:
self._finish_intro()
return False # one-shot
def start_outro(self, on_done) -> None:
"""Reverse of the intro (card dissolving back into static), then on_done."""
if not self.enabled:
on_done()
return
self._intro_t = None
if self._intro_deadline_id is not None:
GLib.source_remove(self._intro_deadline_id)
self._intro_deadline_id = None
self._outro_t = 0.0
self._outro_done = on_done
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the card's rounded shape
cr.clip()
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
elif self._outro_t is not None:
self._draw_outro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT)
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (strength ** 0.5))
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_outro(self, cr, width: float, height: float) -> None:
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (po ** 0.5))
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = (1.0 - po) * height
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
r, g, b = _VIOLET
cr.save()
cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA)
cr.set_line_width(1.0)
y = 0.0
while y < height:
cr.move_to(0, y)
cr.line_to(width, y)
y += self.SCANLINE_GAP
cr.stroke()
cr.restore()
phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD
sweep_y = phase * height
hh = self.SWEEP_HALF_HEIGHT
grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh)
grad.add_color_stop_rgba(0.0, r, g, b, 0.0)
grad.add_color_stop_rgba(0.5, r, g, b, 0.09)
grad.add_color_stop_rgba(1.0, r, g, b, 0.0)
cr.set_source(grad)
cr.rectangle(0, sweep_y - hh, width, hh * 2)
cr.fill()
flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0)
cr.set_source_rgba(r, g, b, max(0.0, flicker))
cr.paint()
self._draw_noise(cr, width, height)
def _draw_noise(self, cr, width: float, height: float) -> None:
now = self._sat_time
self._particles = [p for p in self._particles if now - p["birth"] < p["life"]]
while len(self._particles) < self.NOISE_COUNT:
self._particles.append({
"x": random.uniform(0, width),
"y": random.uniform(0, height),
"w": random.uniform(1.0, 2.6),
"h": random.uniform(1.0, 2.0),
"color": random.choice(self.NOISE_COLORS),
"peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE),
"birth": now,
"life": random.uniform(*self.NOISE_LIFETIME),
})
for p in self._particles:
t = (now - p["birth"]) / p["life"]
if t < self.NOISE_FADE_IN:
envelope = t / self.NOISE_FADE_IN
elif t > 1.0 - self.NOISE_FADE_OUT:
envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT)
else:
envelope = 1.0
r, g, b = p["color"]
cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope)
cr.rectangle(p["x"], p["y"], p["w"], p["h"])
cr.fill()

View File

@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""beacon — the Cosmonaut Shell notification daemon for hyprdrive.
Replaces dunst: owns org.freedesktop.Notifications and renders each notification
as a holographic card (scanline/sweep/noise overlay, emitted-magenta text, violet
holo-glass, radio-squiggle divider) in a top-centre layer-shell stack, so
notifications read as one more orbit of the astro-menu / orbit-menu / station-bar
look instead of a plain popup.
main.py run the resident daemon (owns the notification bus name, stays hidden
until a notification arrives)
"""
from __future__ import annotations
import os
import signal
import sys
from pathlib import Path
# beacon-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement ahead
# of libwayland-client). Drop it once resident so it isn't inherited by anything
# this process launches (same rationale as the rest of the suite's main.py).
os.environ.pop("LD_PRELOAD", None)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import gi # noqa: E402
gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib, Gtk # noqa: E402
import theme # noqa: E402
from paths import APP_ID, FDN_NAME # noqa: E402
from server import NotificationServer # noqa: E402
from window import BeaconWindow # noqa: E402
class BeaconApp(Gtk.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID,
flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
self.window: BeaconWindow | None = None
self._server: NotificationServer | None = None
self._name_id = 0
def do_startup(self) -> None:
Gtk.Application.do_startup(self)
theme.load_css()
self.window = BeaconWindow(self, on_closed=self._on_closed)
# Own the freedesktop notification name; the server is created once the bus
# connection is in hand.
self._name_id = Gio.bus_own_name(
Gio.BusType.SESSION, FDN_NAME, Gio.BusNameOwnerFlags.NONE,
self._on_bus_acquired, None, self._on_name_lost)
# SIGUSR1 = close every visible card (the `Super+Ctrl+C` keybind, which
# used to run `dunstctl close-all`).
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1,
self._on_sigusr1)
self.hold() # stay alive with no visible window
def _on_sigusr1(self) -> bool:
if self.window is not None:
self.window.close_all()
return GLib.SOURCE_CONTINUE
def _on_bus_acquired(self, connection: Gio.DBusConnection, _name: str) -> None:
assert self.window is not None
self._server = NotificationServer(connection, self.window)
def _on_closed(self, nid: int, reason: int) -> None:
if self._server is not None:
self._server.emit_closed(nid, reason)
def _on_name_lost(self, _connection, _name: str) -> None:
# Another notification daemon (e.g. a still-running dunst) already owns it.
sys.stderr.write(
"beacon: could not acquire org.freedesktop.Notifications "
"(another notification daemon is running); exiting.\n")
self.quit()
def do_activate(self) -> None:
pass # resident daemon: nothing to do on activate
def main() -> int:
GLib.set_prgname("beacon")
return BeaconApp().run(sys.argv)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,225 @@
"""A single holographic notification card.
Layout: [app icon] [ summary (emitted-magenta, letter-spaced) / radio-squiggle
divider / body ] with an optional row of action-pill buttons, all under a
scanline/sweep/noise HologramOverlay (lib/hologram.py) clipped to the card's
rounded rectangle. The card materialises out of static (holo intro) when it
appears and dissolves back into static when dismissed.
"""
from __future__ import annotations
import math
from typing import Callable, Optional
import cairo
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
from lib.hologram import HologramOverlay
_CARD_RADIUS = 16.0
# Divider "radio wave" colour (Cairo, not CSS): a magenta wave line on a
# transparent background. Critical keeps an accent wave to match its frame.
_MAGENTA = (0xEB / 255, 0x00 / 255, 0xA6 / 255) # foreground wave (normal)
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) # foreground wave (critical)
_SQUIGGLE_H = 14 # divider row height
_SQUIGGLE_AMP = 3.0 # wave amplitude (px)
_SQUIGGLE_CYCLES = 0.045 # cycles per px of width
_SQUIGGLE_SPEED = 2.4 # phase advance (rad/s) — a slowly travelling signal
def _rounded_path(cr, w: float, h: float, r: float = _CARD_RADIUS) -> None:
r = min(r, w / 2, h / 2)
cr.new_sub_path()
cr.arc(w - r, r, r, -math.pi / 2, 0)
cr.arc(w - r, h - r, r, 0, math.pi / 2)
cr.arc(r, h - r, r, math.pi / 2, math.pi)
cr.arc(r, r, r, math.pi, 3 * math.pi / 2)
cr.close_path()
class NotificationCard:
def __init__(self, nid: int, summary: str, body: str, urgency: int,
icon: str, image_data, actions: list[str],
on_action: Callable[[int, str], None],
on_dismiss: Callable[[int], None]) -> None:
self.nid = nid
self._on_action = on_action
self._on_dismiss = on_dismiss
self._dismissing = False
critical = urgency >= 2
# -- content -----------------------------------------------------------
content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
content.add_css_class("beacon-content")
img = self._build_icon(icon, image_data)
if img is not None:
content.append(img)
textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
textcol.set_hexpand(True)
textcol.set_valign(Gtk.Align.CENTER)
if summary:
lbl = Gtk.Label(label=summary, xalign=0.0)
lbl.add_css_class("beacon-summary")
lbl.set_wrap(True)
lbl.set_wrap_mode(2) # WORD_CHAR
lbl.set_max_width_chars(30)
textcol.append(lbl)
self._squiggle_color = _ACCENT if critical else _MAGENTA
self._squiggle_phase = 0.0
self._squiggle = Gtk.DrawingArea()
self._squiggle.add_css_class("beacon-squiggle")
self._squiggle.set_content_height(_SQUIGGLE_H)
self._squiggle.set_hexpand(True)
self._squiggle.set_draw_func(self._draw_squiggle)
textcol.append(self._squiggle)
if body:
blbl = Gtk.Label(xalign=0.0)
blbl.add_css_class("beacon-body")
blbl.set_wrap(True)
blbl.set_wrap_mode(2)
blbl.set_max_width_chars(34)
# bodies may carry a small Pango-markup subset (<b>/<i>/<u>/<a>…);
# fall back to plain text if the sender's markup won't parse.
try:
blbl.set_markup(body)
except GLib.GError:
blbl.set_text(body)
textcol.append(blbl)
content.append(textcol)
if actions:
row = self._build_actions(actions)
if row is not None:
textcol.append(row)
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
card.add_css_class("beacon-card")
if critical:
card.add_css_class("critical")
card.append(content)
card.set_size_request(340, -1)
# -- hologram overlay --------------------------------------------------
self._holo = HologramOverlay(enabled=True, clip_func=_rounded_path,
fade_widget=content)
overlay = Gtk.Overlay()
overlay.set_child(card)
overlay.add_overlay(self._holo.widget)
overlay.set_measure_overlay(self._holo.widget, False)
# left-click anywhere on the card = invoke default action if present, else
# dismiss; right-click always dismisses.
self._default_action = "default" if "default" in actions else None
left = Gtk.GestureClick(button=1)
left.connect("released", self._on_left_click)
overlay.add_controller(left)
right = Gtk.GestureClick(button=3)
right.connect("released", lambda *_a: self.dismiss())
overlay.add_controller(right)
self.widget = overlay
self._holo.start_intro()
# -- public ---------------------------------------------------------------
def tick(self, dt: float) -> None:
self._holo.tick(dt)
self._squiggle_phase += dt * _SQUIGGLE_SPEED
self._squiggle.queue_draw() # travel the wave along like a live signal
def _draw_squiggle(self, _area, cr, width: int, height: int) -> None:
if width <= 0:
return
# transparent background — just the magenta (accent for critical) radio wave
mid = height / 2.0
amp = min(_SQUIGGLE_AMP, mid - 2.0)
k = _SQUIGGLE_CYCLES * 2.0 * math.pi # angular freq per px
steps = max(2, int(width))
cr.set_line_cap(cairo.LINE_CAP_ROUND)
cr.set_line_join(cairo.LINE_JOIN_ROUND)
# two passes: a soft wide glow, then a bright thin core — reads as emitted
# light over the purple bar, matching the card's holographic frame.
for line_w, alpha in ((3.0, 0.30), (1.4, 1.0)):
cr.set_line_width(line_w)
cr.set_source_rgba(*self._squiggle_color, alpha)
for i in range(steps + 1):
x = width * i / steps
y = mid + amp * math.sin(x * k + self._squiggle_phase)
cr.line_to(x, y) if i else cr.move_to(x, y)
cr.stroke()
def dismiss(self) -> None:
"""Play the dissolve, then hand the id back to the window for removal."""
if self._dismissing:
return
self._dismissing = True
self._holo.start_outro(lambda: self._on_dismiss(self.nid))
# -- internals ------------------------------------------------------------
def _on_left_click(self, *_a) -> None:
if self._default_action is not None:
self._on_action(self.nid, self._default_action)
else:
self.dismiss()
def _build_actions(self, actions: list[str]) -> Optional[Gtk.Box]:
# actions is a flat [key, label, key, label, …] list; "default" is the
# implicit click action and isn't shown as a button.
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
row.add_css_class("beacon-actions")
row.set_margin_top(6)
shown = 0
for i in range(0, len(actions) - 1, 2):
key, label = actions[i], actions[i + 1]
if key == "default":
continue
btn = Gtk.Button(label=label or key)
btn.add_css_class("beacon-action")
btn.connect("clicked", lambda _b, k=key: self._on_action(self.nid, k))
row.append(btn)
shown += 1
return row if shown else None
def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]:
img: Optional[Gtk.Image] = None
if image_data is not None:
pb = self._pixbuf_from_hint(image_data)
if pb is not None:
img = Gtk.Image.new_from_paintable(Gdk.Texture.new_for_pixbuf(pb))
if img is None and icon:
if icon.startswith("file://"):
icon = icon[len("file://"):]
if icon.startswith("/"):
img = Gtk.Image.new_from_file(icon)
else:
img = Gtk.Image.new_from_icon_name(icon)
if img is None:
return None
img.add_css_class("beacon-icon")
img.set_pixel_size(44)
img.set_valign(Gtk.Align.START)
return img
@staticmethod
def _pixbuf_from_hint(data) -> Optional[GdkPixbuf.Pixbuf]:
# Spec "image-data": (width, height, rowstride, has_alpha, bits, channels, bytes)
try:
w, h, rowstride, has_alpha, bits, channels, raw = data
return GdkPixbuf.Pixbuf.new_from_bytes(
GLib.Bytes.new(bytes(raw)), GdkPixbuf.Colorspace.RGB,
has_alpha, bits, w, h, rowstride)
except (ValueError, TypeError):
return None

View File

@ -0,0 +1,17 @@
"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
from __future__ import annotations
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
STYLE_DIR = BASE_DIR / "style"
# GApplication single-instance id (our own process). NOT the freedesktop
# notification name — that well-known name (org.freedesktop.Notifications) is
# owned separately in main.py, the same one dunst used to hold.
APP_ID = "eu.abdelbaki.beacon"
FDN_NAME = "org.freedesktop.Notifications"
FDN_PATH = "/org/freedesktop/Notifications"
FDN_IFACE = "org.freedesktop.Notifications"

View File

@ -0,0 +1,146 @@
"""org.freedesktop.Notifications D-Bus service — the well-known name dunst used
to own. Parses each Notify call and hands it to the stack window (window.py) to
render as a holographic card; owns per-notification expiry timers and emits the
spec's NotificationClosed / ActionInvoked signals.
Deliberately small: the visible/interactive spec subset (body markup, actions,
icons, urgency, replaces_id, timeouts, persistence) no sound/markup-hint
gymnastics.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib # noqa: E402
from paths import FDN_IFACE, FDN_PATH
# reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined
_INTROSPECTION_XML = """
<node>
<interface name="org.freedesktop.Notifications">
<method name="GetCapabilities">
<arg type="as" name="capabilities" direction="out"/>
</method>
<method name="Notify">
<arg type="s" name="app_name" direction="in"/>
<arg type="u" name="replaces_id" direction="in"/>
<arg type="s" name="app_icon" direction="in"/>
<arg type="s" name="summary" direction="in"/>
<arg type="s" name="body" direction="in"/>
<arg type="as" name="actions" direction="in"/>
<arg type="a{sv}" name="hints" direction="in"/>
<arg type="i" name="expire_timeout" direction="in"/>
<arg type="u" name="id" direction="out"/>
</method>
<method name="CloseNotification">
<arg type="u" name="id" direction="in"/>
</method>
<method name="GetServerInformation">
<arg type="s" name="name" direction="out"/>
<arg type="s" name="vendor" direction="out"/>
<arg type="s" name="version" direction="out"/>
<arg type="s" name="spec_version" direction="out"/>
</method>
<signal name="NotificationClosed">
<arg type="u" name="id"/>
<arg type="u" name="reason"/>
</signal>
<signal name="ActionInvoked">
<arg type="u" name="id"/>
<arg type="s" name="action_key"/>
</signal>
</interface>
</node>
"""
# server default timeouts (ms) by urgency when the client passes -1
_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never)
class NotificationServer:
def __init__(self, connection: Gio.DBusConnection, window) -> None:
self._conn = connection
self._window = window
self._next_id = 1
self._timers: dict[int, int] = {}
node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML)
self._iface = node.interfaces[0]
connection.register_object(
FDN_PATH, self._iface, self._on_method_call, None, None)
# -- D-Bus dispatch -------------------------------------------------------
def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation):
if method == "Notify":
invocation.return_value(GLib.Variant("(u)", (self._notify(params),)))
elif method == "CloseNotification":
(nid,) = params.unpack()
self._window.close_notification(int(nid), reason=3)
self._cancel_timer(int(nid))
invocation.return_value(None)
elif method == "GetCapabilities":
invocation.return_value(GLib.Variant("(as)", (
["body", "body-markup", "icon-static", "actions", "persistence"],)))
elif method == "GetServerInformation":
invocation.return_value(GLib.Variant("(ssss)", (
"beacon", "abdelbaki.eu", "1.0", "1.2")))
else:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method)
def _notify(self, params) -> int:
(app_name, replaces_id, app_icon, summary, body,
actions, hints, expire_timeout) = params.unpack()
nid = int(replaces_id) if replaces_id else self._next_id
if not replaces_id:
self._next_id += 1
urgency = int(hints.get("urgency", 1))
image_data = (hints.get("image-data") or hints.get("image_data")
or hints.get("icon_data"))
image_path = hints.get("image-path") or hints.get("image_path")
icon = image_path or app_icon or ""
self._window.show_notification(
nid, summary, body, urgency, icon, image_data, list(actions),
self._emit_action)
self._arm_timer(nid, int(expire_timeout), urgency)
return nid
# -- expiry timers --------------------------------------------------------
def _arm_timer(self, nid: int, expire_timeout: int, urgency: int) -> None:
self._cancel_timer(nid)
if expire_timeout < 0:
ms = _DEFAULT_TIMEOUT.get(urgency, 8000)
else:
ms = expire_timeout
if ms <= 0:
return # 0 = never expire (or critical default)
self._timers[nid] = GLib.timeout_add(ms, self._on_expire, nid)
def _on_expire(self, nid: int) -> bool:
self._timers.pop(nid, None)
self._window.close_notification(nid, reason=1) # 1 = expired
return False
def _cancel_timer(self, nid: int) -> None:
tid = self._timers.pop(nid, None)
if tid is not None:
GLib.source_remove(tid)
# -- signals (also the window's on_closed / on_action callbacks) -----------
def emit_closed(self, nid: int, reason: int) -> None:
self._cancel_timer(nid)
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed",
GLib.Variant("(uu)", (nid, reason)))
def _emit_action(self, nid: int, key: str) -> None:
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked",
GLib.Variant("(us)", (nid, key)))
# dismiss the card once its action fired, matching typical daemon behaviour
self._window.close_notification(nid, reason=2)

View File

@ -0,0 +1,8 @@
/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh do not hand-edit the
* hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the
* sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */
@define-color text #D6ABAB;
@define-color bg #1A1A1A;
@define-color accent #E40046;
@define-color violet #5018DD;
@define-color danger #F50505;

View File

@ -0,0 +1,93 @@
/* beacon holographic notification cards. Matches the astro-menu idiom
* (astro-menu/style/style.css): emitted-magenta text, violet holo-glass fills,
* glow-violet / accent frames, Agave Nerd Font Mono, rounded cards. The compositor
* blurs behind the translucent card fills (see the `beacon` layer-rule in
* hypr/usr/windowrules.lua); the scanline/sweep/noise depth is Cairo-drawn on top
* by lib/hologram.py. */
/* Emitted magenta reads as projected light on the blurred glass, exactly as the
* astro panel overrides its @text. */
@define-color text #EB00A6;
* {
font-family: "Agave Nerd Font Mono", monospace;
font-size: 12pt;
}
/* The CyberQueer GTK theme paints `* { background-color:#1a1a1a }` on every node,
* which would fill the surface and the gaps between cards with an opaque slab.
* Blank the structural nodes; the card asserts its own glass fill below. */
window,
window.background,
.beacon-window,
.beacon-stack,
.beacon-content,
box,
overlay,
label,
image,
drawingarea {
background: transparent;
background-color: transparent;
}
/* the holo-glass card */
.beacon-card {
background-color: alpha(@violet, 0.40);
border: 2px solid #8A5CFF; /* glow_violet — emitted-light frame */
border-radius: 16px;
padding: 12px 14px;
box-shadow: 0 0 16px 1px alpha(#8A5CFF, 0.35);
}
.beacon-card.critical {
border-color: @accent;
background-color: alpha(@accent, 0.14);
box-shadow: 0 0 18px 1px alpha(@accent, 0.40);
}
/* summary = emitted magenta, letter-spaced like the astro HUD headlines */
.beacon-summary {
color: @text;
font-weight: bold;
font-size: 12pt;
letter-spacing: 1px;
}
.beacon-card.critical .beacon-summary { color: @accent; }
/* radio-wave divider a Cairo sine path drawn in notification.py (colour handled
* there, per urgency); this only spaces it from the title/body. The explicit
* transparent bg beats the CyberQueer theme's `* { background-color:#1a1a1a }`
* (a class selector out-specifies its universal one), so no dark pill shows. */
.beacon-squiggle {
margin: 2px 0;
background: none;
background-color: transparent;
border: none;
box-shadow: none;
}
.beacon-body {
color: @text;
font-size: 11pt;
opacity: 0.95;
}
.beacon-icon { margin-right: 2px; }
/* action pills — astro-menu's .quad-action idiom */
.beacon-action {
color: @text;
background-color: alpha(@violet, 0.4);
border: 2px solid #8A5CFF;
border-radius: 20px;
padding: 2px 12px;
min-height: 22px;
transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease;
}
.beacon-action:hover {
border-color: @accent;
color: @accent;
box-shadow: 0 0 12px 1px alpha(@accent, 0.55);
}
.beacon-hologram { background: transparent; }

View File

@ -0,0 +1,30 @@
"""Load the two stylesheets as ordered CSS providers — same scheme as the rest
of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu, station-bar).
_colors.css defines the CyberQueer @define-color names; style.css consumes them.
Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme
at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION
(600), and would beat our transparent structural containers otherwise.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402
from paths import STYLE_DIR
def load_css() -> None:
display = Gdk.Display.get_default()
for name in ("_colors.css", "style.css"):
path = STYLE_DIR / name
if not path.exists():
continue
provider = Gtk.CssProvider()
provider.load_from_path(str(path))
Gtk.StyleContext.add_provider_for_display(
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1
)

View File

@ -0,0 +1,135 @@
"""The notification stack: a top-centre layer-shell surface that holds the live
notification cards and drives one frame-clock tick for all their holograms.
Sized to its content (anchored TOP only, so it floats centred like dunst did),
on the OVERLAY layer above normal windows. Newest card on top. The surface is
hidden whenever no cards are showing, so it never eats clicks on an empty screen.
"""
from __future__ import annotations
from typing import Callable, Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
from notification import NotificationCard
MARGIN_TOP = 46
MAX_VISIBLE = 6
class BeaconWindow(Gtk.ApplicationWindow):
def __init__(self, app: Gtk.Application,
on_closed: Callable[[int, int], None]) -> None:
super().__init__(application=app)
self._on_closed = on_closed # (id, reason) -> emit NotificationClosed
self._cards: dict[int, NotificationCard] = {}
self._order: list[int] = [] # newest first
self._tick_id: Optional[int] = None
self._last_tick: Optional[float] = None
self.set_decorated(False)
self.add_css_class("beacon-window")
self._stack = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self._stack.add_css_class("beacon-stack")
self._stack.set_halign(Gtk.Align.CENTER)
self._stack.set_valign(Gtk.Align.START)
self.set_child(self._stack)
self._init_layer_shell()
self.set_visible(False)
def _init_layer_shell(self) -> None:
LayerShell.init_for_window(self)
LayerShell.set_layer(self, LayerShell.Layer.OVERLAY)
LayerShell.set_namespace(self, "beacon")
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE)
LayerShell.set_anchor(self, LayerShell.Edge.TOP, True)
LayerShell.set_margin(self, LayerShell.Edge.TOP, MARGIN_TOP)
LayerShell.set_exclusive_zone(self, 0)
# -- public: driven by the D-Bus server -----------------------------------
def show_notification(self, nid: int, summary: str, body: str, urgency: int,
icon: str, image_data, actions: list[str],
on_action: Callable[[int, str], None]) -> None:
if nid in self._cards: # replaces_id: swap in place
self._drop_card(nid)
card = NotificationCard(nid, summary, body, urgency, icon, image_data,
actions, on_action, self._card_dismissed)
self._cards[nid] = card
self._order.insert(0, nid)
self._stack.prepend(card.widget)
# cap the stack: quietly expire the oldest beyond the limit
while len(self._order) > MAX_VISIBLE:
old = self._order[-1]
self._card_dismissed(old, reason=4) # 4 = undefined/expired-by-limit
self.set_visible(True)
self._ensure_tick()
def close_notification(self, nid: int, reason: int = 3) -> bool:
"""Server-/user-requested close. reason 3 = closed by CloseNotification."""
if nid not in self._cards:
return False
card = self._cards[nid]
# play the dissolve; removal + the NotificationClosed signal follow
card.dismiss()
self._pending_reason[nid] = reason
return True
def close_all(self, reason: int = 2) -> None:
"""Dismiss every visible card (the old `dunstctl close-all` keybind).
reason 2 = dismissed by user. Each card plays its dissolve; removal and
the NotificationClosed signals follow as the outros finish."""
for nid in list(self._order):
self.close_notification(nid, reason=reason)
# -- card lifecycle -------------------------------------------------------
_pending_reason: dict[int, int] = {}
def _card_dismissed(self, nid: int, reason: int = 2) -> None:
"""Called after a card's dissolve finishes (reason 2 = dismissed by user),
or directly for cap-expiry. Removes it and signals the closure."""
if nid not in self._cards:
return
reason = self._pending_reason.pop(nid, reason)
self._drop_card(nid)
self._on_closed(nid, reason)
if not self._cards:
self.set_visible(False)
self._stop_tick()
def _drop_card(self, nid: int) -> None:
card = self._cards.pop(nid, None)
if card is not None:
self._stack.remove(card.widget)
if nid in self._order:
self._order.remove(nid)
# -- animation tick -------------------------------------------------------
def _on_tick(self, _widget, frame_clock) -> bool:
now = frame_clock.get_frame_time() / 1_000_000
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
self._last_tick = now
for card in list(self._cards.values()):
card.tick(dt)
return True
def _ensure_tick(self) -> None:
if self._tick_id is None:
self._last_tick = None
self._tick_id = self.add_tick_callback(self._on_tick)
def _stop_tick(self) -> None:
if self._tick_id is not None:
self.remove_tick_callback(self._tick_id)
self._tick_id = None
self._last_tick = None

View File

@ -0,0 +1,272 @@
#? Config file for btop v.1.4.6
#* Name of a btop++/bpytop/bashtop formatted ".theme" file, "Default" and "TTY" for builtin themes.
#* Themes should be placed in "../share/btop/themes" relative to binary or "$HOME/.config/btop/themes"
color_theme = "/usr/share/btop/themes/cyberqueer.theme"
#* If the theme set background should be shown, set to False if you want terminal background transparency.
theme_background = true
#* Sets if 24-bit truecolor should be used, will convert 24-bit colors to 256 color (6x6x6 color cube) if false.
truecolor = true
#* Set to true to force tty mode regardless if a real tty has been detected or not.
#* Will force 16-color mode and TTY theme, set all graph symbols to "tty" and swap out other non tty friendly symbols.
force_tty = false
#* Define presets for the layout of the boxes. Preset 0 is always all boxes shown with default settings. Max 9 presets.
#* Format: "box_name:P:G,box_name:P:G" P=(0 or 1) for alternate positions, G=graph symbol to use for box.
#* Use whitespace " " as separator between different presets.
#* Example: "cpu:0:default,mem:0:tty,proc:1:default cpu:0:braille,proc:0:tty"
presets = "cpu:1:default,proc:0:default cpu:0:default,mem:0:default,net:0:default cpu:0:block,net:0:tty"
#* Set to True to enable "h,j,k,l,g,G" keys for directional control in lists.
#* Conflicting keys for h:"help" and k:"kill" is accessible while holding shift.
vim_keys = false
#* Rounded corners on boxes, is ignored if TTY mode is ON.
rounded_corners = true
#* Use terminal synchronized output sequences to reduce flickering on supported terminals.
terminal_sync = true
#* Default symbols to use for graph creation, "braille", "block" or "tty".
#* "braille" offers the highest resolution but might not be included in all fonts.
#* "block" has half the resolution of braille but uses more common characters.
#* "tty" uses only 3 different symbols but will work with most fonts and should work in a real TTY.
#* Note that "tty" only has half the horizontal resolution of the other two, so will show a shorter historical view.
graph_symbol = "braille"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_cpu = "default"
# Graph symbol to use for graphs in gpu box, "default", "braille", "block" or "tty".
graph_symbol_gpu = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_mem = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_net = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_proc = "default"
#* Manually set which boxes to show. Available values are "cpu mem net proc" and "gpu0" through "gpu5", separate values with whitespace.
shown_boxes = "cpu mem net proc"
#* Update time in milliseconds, recommended 2000 ms or above for better sample times for graphs.
update_ms = 100
#* Processes sorting, "pid" "program" "arguments" "threads" "user" "memory" "cpu lazy" "cpu direct",
#* "cpu lazy" sorts top process over time (easier to follow), "cpu direct" updates top process directly.
proc_sorting = "cpu lazy"
#* Reverse sorting order, True or False.
proc_reversed = false
#* Show processes as a tree.
proc_tree = false
#* Use the cpu graph colors in the process list.
proc_colors = true
#* Use a darkening gradient in the process list.
proc_gradient = true
#* If process cpu usage should be of the core it's running on or usage of the total available cpu power.
proc_per_core = false
#* Show process memory as bytes instead of percent.
proc_mem_bytes = true
#* Show cpu graph for each process.
proc_cpu_graphs = true
#* Use /proc/[pid]/smaps for memory information in the process info box (very slow but more accurate)
proc_info_smaps = false
#* Show proc box on left side of screen instead of right.
proc_left = false
#* (Linux) Filter processes tied to the Linux kernel(similar behavior to htop).
proc_filter_kernel = false
#* In tree-view, always accumulate child process resources in the parent process.
proc_aggregate = false
#* Should cpu and memory usage display be preserved for dead processes when paused.
keep_dead_proc_usage = false
#* Sets the CPU stat shown in upper half of the CPU graph, "total" is always available.
#* Select from a list of detected attributes from the options menu.
cpu_graph_upper = "Auto"
#* Sets the CPU stat shown in lower half of the CPU graph, "total" is always available.
#* Select from a list of detected attributes from the options menu.
cpu_graph_lower = "Auto"
#* If gpu info should be shown in the cpu box. Available values = "Auto", "On" and "Off".
show_gpu_info = "Auto"
#* Toggles if the lower CPU graph should be inverted.
cpu_invert_lower = true
#* Set to True to completely disable the lower CPU graph.
cpu_single_graph = false
#* Show cpu box at bottom of screen instead of top.
cpu_bottom = false
#* Shows the system uptime in the CPU box.
show_uptime = true
#* Shows the CPU package current power consumption in watts. Requires running `make setcap` or `make setuid` or running with sudo.
show_cpu_watts = true
#* Show cpu temperature.
check_temp = true
#* Which sensor to use for cpu temperature, use options menu to select from list of available sensors.
cpu_sensor = "Auto"
#* Show temperatures for cpu cores also if check_temp is True and sensors has been found.
show_coretemp = true
#* Set a custom mapping between core and coretemp, can be needed on certain cpus to get correct temperature for correct core.
#* Use lm-sensors or similar to see which cores are reporting temperatures on your machine.
#* Format "x:y" x=core with wrong temp, y=core with correct temp, use space as separator between multiple entries.
#* Example: "4:0 5:1 6:3"
cpu_core_map = ""
#* Which temperature scale to use, available values: "celsius", "fahrenheit", "kelvin" and "rankine".
temp_scale = "celsius"
#* Use base 10 for bits/bytes sizes, KB = 1000 instead of KiB = 1024.
base_10_sizes = false
#* Show CPU frequency.
show_cpu_freq = true
#* How to calculate CPU frequency, available values: "first", "range", "lowest", "highest" and "average".
freq_mode = "first"
#* Draw a clock at top of screen, formatting according to strftime, empty string to disable.
#* Special formatting: /host = hostname | /user = username | /uptime = system uptime
clock_format = "%X"
#* Update main ui in background when menus are showing, set this to false if the menus is flickering too much for comfort.
background_update = true
#* Custom cpu model name, empty string to disable.
custom_cpu_name = ""
#* Optional filter for shown disks, should be full path of a mountpoint, separate multiple values with whitespace " ".
#* Only disks matching the filter will be shown. Prepend exclude= to only show disks not matching the filter. Examples: disk_filter="/boot /home/user", disks_filter="exclude=/boot /home/user"
disks_filter = ""
#* Show graphs instead of meters for memory values.
mem_graphs = true
#* Show mem box below net box instead of above.
mem_below_net = false
#* Count ZFS ARC in cached and available memory.
zfs_arc_cached = true
#* If swap memory should be shown in memory box.
show_swap = true
#* Show swap as a disk, ignores show_swap value above, inserts itself after first disk.
swap_disk = true
#* If mem box should be split to also show disks info.
show_disks = true
#* Filter out non physical disks. Set this to False to include network disks, RAM disks and similar.
only_physical = true
#* Read disks list from /etc/fstab. This also disables only_physical.
use_fstab = true
#* Setting this to True will hide all datasets, and only show ZFS pools. (IO stats will be calculated per-pool)
zfs_hide_datasets = false
#* Set to true to show available disk space for privileged users.
disk_free_priv = false
#* Toggles if io activity % (disk busy time) should be shown in regular disk usage view.
show_io_stat = true
#* Toggles io mode for disks, showing big graphs for disk read/write speeds.
io_mode = false
#* Set to True to show combined read/write io graphs in io mode.
io_graph_combined = false
#* Set the top speed for the io graphs in MiB/s (100 by default), use format "mountpoint:speed" separate disks with whitespace " ".
#* Example: "/mnt/media:100 /:20 /boot:1".
io_graph_speeds = ""
#* Set fixed values for network graphs in Mebibits. Is only used if net_auto is also set to False.
net_download = 100
net_upload = 100
#* Use network graphs auto rescaling mode, ignores any values set above and rescales down to 10 Kibibytes at the lowest.
net_auto = true
#* Sync the auto scaling for download and upload to whichever currently has the highest scale.
net_sync = true
#* Starts with the Network Interface specified here.
net_iface = ""
#* "True" shows bitrates in base 10 (Kbps, Mbps). "False" shows bitrates in binary sizes (Kibps, Mibps, etc.). "Auto" uses base_10_sizes.
base_10_bitrate = "Auto"
#* Show battery stats in top right if battery is present.
show_battery = true
#* Which battery to use if multiple are present. "Auto" for auto detection.
selected_battery = "Auto"
#* Show power stats of battery next to charge indicator.
show_battery_watts = true
#* Set loglevel for "~/.local/state/btop.log" levels are: "ERROR" "WARNING" "INFO" "DEBUG".
#* The level set includes all lower levels, i.e. "DEBUG" will show all logging info.
log_level = "WARNING"
#* Automatically save current settings to config file on exit.
save_config_on_exit = true
#* Measure PCIe throughput on NVIDIA cards, may impact performance on certain cards.
nvml_measure_pcie_speeds = true
#* Measure PCIe throughput on AMD cards, may impact performance on certain cards.
rsmi_measure_pcie_speeds = true
#* Horizontally mirror the GPU graph.
gpu_mirror_graph = true
#* Set which GPU vendors to show. Available values are "nvidia amd intel"
shown_gpus = "nvidia amd intel"
#* Custom gpu0 model name, empty string to disable.
custom_gpu_name0 = ""
#* Custom gpu1 model name, empty string to disable.
custom_gpu_name1 = ""
#* Custom gpu2 model name, empty string to disable.
custom_gpu_name2 = ""
#* Custom gpu3 model name, empty string to disable.
custom_gpu_name3 = ""
#* Custom gpu4 model name, empty string to disable.
custom_gpu_name4 = ""
#* Custom gpu5 model name, empty string to disable.
custom_gpu_name5 = ""

View File

@ -0,0 +1,92 @@
#Bashtop monokai theme
#by aristocratos
# Colors should be in 6 or 2 character hexadecimal or single spaced rgb decimal: "#RRGGBB", "#BW" or "0-255 0-255 0-255"
# example for white: "#FFFFFF", "#ff" or "255 255 255".
# All graphs and meters can be gradients
# For single color graphs leave "mid" and "end" variable empty.
# Use "start" and "end" variables for two color gradient
# Use "start", "mid" and "end" for three color gradient
# Main background, empty for terminal default, need to be empty if you want transparent background
theme[main_bg]="#1a1a1a"
# Main text color
theme[main_fg]="#E40046"
# Title color for boxes
theme[title]="#d6abab"
# Highlight color for keyboard shortcuts
theme[hi_fg]="#f50505"
# Background color of selected item in processes box
theme[selected_bg]="#7a1137"
# Foreground color of selected item in processes box
theme[selected_fg]="#F8F8F2"
# Color of inactive/disabled text
theme[inactive_fg]="#5018dd"
# Color of text appearing on top of graphs, i.e uptime and current network graph scaling
theme[graph_text]="#797667"
# Misc colors for processes box including mini cpu graphs, details memory graph and details status text
theme[proc_misc]="#A6E22E"
# Cpu box outline color
theme[cpu_box]="#5018dd"
# Memory/disks box outline color
theme[mem_box]="#5018dd"
# Net up/down box outline color
theme[net_box]="#5018dd"
# Processes box outline color
theme[proc_box]="#5018dd"
# Box divider line and small boxes line color
theme[div_line]="#5018dd"
# Temperature graph colors
theme[temp_start]="#5018dd"
theme[temp_mid]="#E40046"
theme[temp_end]="#f50505"
# CPU graph colors
theme[cpu_start]="#5018dd"
theme[cpu_mid]="#E40046"
theme[cpu_end]="#f50505"
# Mem/Disk free meter
theme[free_start]="#5018dd"
theme[free_mid]="#E40046"
theme[free_end]="#f50505"
# Mem/Disk cached meter
theme[cached_start]="#5018dd"
theme[cached_mid]="#E40046"
theme[cached_end]="#f50505"
# Mem/Disk available meter
theme[available_start]="#5018dd"
theme[available_mid]="#E40046"
theme[available_end]="#f50505"
# Mem/Disk used meter
theme[used_start]="#5018dd"
theme[used_mid]="#E40046"
theme[used_end]="#f50505"
# Download graph colors
theme[download_start]="#5018dd"
theme[download_mid]="#E40046"
theme[download_end]="#f50505"
# Upload graph colors
theme[upload_start]="#5018dd"
theme[upload_mid]="#E40046"
theme[upload_end]="#f50505"

View File

@ -0,0 +1,165 @@
#!/usr/bin/env bash
# update-configs — sync dotfiles configs into ~/.config
#
# Config: ~/.config/config-updater/updater.conf
# Manifest: ~/.config/config-updater/manifest
#
# Syntax in updater.conf:
# config <name> [except <sub1> <sub2> ...]
# copy SOURCE_BASE/<name> to ~/.config/<name>, optionally skipping subdirs
# flat <name>
# copy contents of SOURCE_BASE/<name> directly into ~/.config/
# ignore <name>
# present in source but intentionally not managed here
set -euo pipefail
CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/config-updater"
CONF_FILE="${CONF_DIR}/updater.conf"
MANIFEST="${CONF_DIR}/manifest"
TARGET="${XDG_CONFIG_HOME:-$HOME/.config}"
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; DIM='\033[2m'; RST='\033[0m'
err() { printf "${RED}✖ %s${RST}\n" "$*" >&2; }
warn() { printf "${YEL}⚠ %s${RST}\n" "$*"; }
ok() { printf "${GRN}✔ %s${RST}\n" "$*"; }
note() { printf "${DIM} %s${RST}\n" "$*"; }
die() { err "$*"; exit 1; }
[[ -f "$CONF_FILE" ]] || die "Config not found: $CONF_FILE"
# ── parse updater.conf ────────────────────────────────────────────────────────
SOURCE_BASE=""
declare -A ENTRY_TYPE # name → config | flat | ignore
declare -A ENTRY_EXCL # name → space-separated list of excluded subdirs
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[! ]*}"}"
line="${line%"${line##*[! ]}"}"
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^SOURCE_BASE[[:space:]]*=[[:space:]]*(.+)$ ]]; then
src="${BASH_REMATCH[1]%"${BASH_REMATCH[1]##*[! ]}"}"
SOURCE_BASE="${src/#\~/$HOME}"
continue
fi
read -r type name rest <<< "$line"
[[ -z "${name:-}" ]] && continue
case "$type" in
config|flat|ignore)
ENTRY_TYPE["$name"]="$type"
# Parse optional "except sub1 sub2 ..."
if [[ "${rest:-}" =~ ^except[[:space:]]+(.+)$ ]]; then
ENTRY_EXCL["$name"]="${BASH_REMATCH[1]}"
else
ENTRY_EXCL["$name"]=""
fi
;;
*) warn "Unknown type '$type' for '$name' — skipping" ;;
esac
done < "$CONF_FILE"
[[ -n "$SOURCE_BASE" ]] || die "SOURCE_BASE not defined in $CONF_FILE"
[[ -d "$SOURCE_BASE" ]] || die "SOURCE_BASE not found: $SOURCE_BASE"
# ── load previous manifest ────────────────────────────────────────────────────
declare -A PREV_MANIFEST
if [[ -f "$MANIFEST" ]]; then
while IFS=: read -r mtype mname; do
[[ -n "${mtype:-}" && -n "${mname:-}" ]] && PREV_MANIFEST["$mname"]="$mtype"
done < "$MANIFEST"
fi
warned=0
# ── warn: manifest entries no longer in updater.conf ─────────────────────────
for mname in "${!PREV_MANIFEST[@]}"; do
[[ -n "${ENTRY_TYPE[$mname]:-}" ]] && continue
warn "'$mname' was previously managed but is no longer in updater.conf"
note "Remove ~/.config/$mname manually if it is no longer needed"
warned=1
done
# ── warn: source items not covered by updater.conf ───────────────────────────
while IFS= read -r -d '' item; do
name="$(basename "$item")"
[[ "$name" == .* ]] && continue
[[ -n "${ENTRY_TYPE[$name]:-}" ]] && continue
warn "Untracked source item: '$name' — add to updater.conf (config / flat / ignore)"
warned=1
done < <(find "$SOURCE_BASE" -maxdepth 1 -mindepth 1 -print0 | sort -z)
(( warned )) && printf '\n'
# ── apply ─────────────────────────────────────────────────────────────────────
errors=0
new_manifest=()
for name in "${!ENTRY_TYPE[@]}"; do
type="${ENTRY_TYPE[$name]}"
[[ "$type" == ignore ]] && continue
src="${SOURCE_BASE}/${name}"
if [[ ! -e "$src" ]]; then
err "Source missing: $src"
(( errors++ )) || true
continue
fi
excl="${ENTRY_EXCL[$name]:-}"
case "$type" in
config)
if [[ -z "$excl" ]]; then
rm -rf "${TARGET:?}/${name}"
cp -r "$src" "$TARGET/$name"
ok "config $name"
else
# Copy all top-level items of $src except excluded subdirs
mkdir -p "${TARGET}/${name}"
item_errors=0
while IFS= read -r -d '' item; do
item_name="$(basename "$item")"
skip_item=false
for e in $excl; do
[[ "$item_name" == "$e" ]] && skip_item=true && break
done
[[ "$skip_item" == true ]] && continue
rm -rf "${TARGET:?}/${name}/${item_name}"
cp -r "$item" "${TARGET}/${name}/" || (( item_errors++ )) || true
done < <(find "$src" -maxdepth 1 -mindepth 1 -print0 | sort -z)
if (( item_errors > 0 )); then
err "config $name (${item_errors} error(s))"
(( errors++ )) || true
else
ok "config $name (excl: $excl)"
fi
fi
;;
flat)
[[ -d "$src" ]] || {
err "flat entry '$name' must be a directory: $src"
(( errors++ )) || true
continue
}
cp -r "${src}/." "$TARGET/"
ok "flat $name → (contents into ~/.config/)"
;;
esac
new_manifest+=("${type}:${name}")
done
# ── write manifest ────────────────────────────────────────────────────────────
printf '%s\n' "${new_manifest[@]}" | sort > "$MANIFEST"
printf '\n'
if (( errors > 0 )); then
err "$errors error(s) — manifest may be incomplete"
exit 1
else
ok "Done — manifest updated at $MANIFEST"
fi

View File

@ -0,0 +1,47 @@
# Config updater — defines which dotfiles configs are deployed to ~/.config
#
# SOURCE_BASE base directory containing all configs in the dotfiles
#
# config <name> copy SOURCE_BASE/<name> to ~/.config/<name>
# flat <name> copy contents of SOURCE_BASE/<name> directly into ~/.config/
# (for installation-specific files that live at the ~/.config root)
# ignore <name> present in source but intentionally not managed here
SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprdrive
# ── deployed as ~/.config/<name> ─────────────────────────────────────────────
config alacritty
config astro-menu
config beacon
config btop
config ghostty
config gtk-3.0
config hypr except usr
config horizon-dock
config kitty
config mimeapps.list
config orbit-menu
config scripts
config station-bar
config vicinae
config xfce4
# ── intentionally not managed here ───────────────────────────────────────────
ignore config-updater # the updater itself
ignore dunst # superseded by beacon (the Cosmonaut Shell notif daemon)
# NOTE: the dunst package still ships a D-Bus activation file
# (org.knopwob.dunst.service, Name=org.freedesktop.Notifications).
# Shadow it so D-Bus activates beacon, not dunst, by deploying
# beacon/dbus/ at install time:
# mkdir -p ~/.local/share/dbus-1/services
# cp ~/Dotfiles/desktopenvs/hyprdrive/beacon/dbus/*.service \
# ~/.local/share/dbus-1/services/
ignore CRT # referenced from dotfiles path directly in binds.conf
ignore greetd-tuigreet # deployed to /etc/greetd/ at install time
ignore spicetify # managed separately (spicetify handles its own config)
ignore Vencord # managed separately
ignore wallpaper # deployed to ~/Pictures/ at install time, not ~/.config
# hypr/usr/ contains device-specific lua files (monitors, binds, input, etc.).
# They are excluded from automated syncs to preserve per-device customisations.
# Deploy manually on a new device:
# cp -r ~/Dotfiles/desktopenvs/hyprdrive/hypr/usr/ ~/.config/hypr/

View File

@ -0,0 +1 @@
/home/themiro/Dotfiles/desktopenvs/hyprland/dunst/

View File

@ -0,0 +1,511 @@
# See dunst(5) for all configuration options
[global]
### Display ###
# Which monitor should the notifications be displayed on.
monitor = 0
# Display notification on focused monitor. Possible modes are:
# mouse: follow mouse pointer
# keyboard: follow window with keyboard focus
# none: don't follow anything
#
# "keyboard" needs a window manager that exports the
# _NET_ACTIVE_WINDOW property.
# This should be the case for almost all modern window managers.
#
# If this option is set to mouse or keyboard, the monitor option
# will be ignored.
follow = none
### Geometry ###
# The width of the window, excluding the frame.
# dynamic width from 0 to 300
# width = (0, 300)
# constant width of 300
width = 300
# The height of a single notification, excluding the frame.
# dynamic height from 0 to 300
height = (0, 300)
# constant height of 300
# height = 300
# Position the notification in the top right corner
origin = top-center
# Offset from the origin
offset = (10, 50)
# Scale factor. It is auto-detected if value is 0.
scale = 0
# Maximum number of notification (0 means no limit)
notification_limit = 20
### Progress bar ###
# Turn on the progress bar. It appears when a progress hint is passed with
# for example dunstify -h int:value:12
progress_bar = true
# Set the progress bar height. This includes the frame, so make sure
# it's at least twice as big as the frame width.
progress_bar_height = 10
# Set the frame width of the progress bar
progress_bar_frame_width = 1
# Set the minimum width for the progress bar
progress_bar_min_width = 150
# Set the maximum width for the progress bar
progress_bar_max_width = 300
# Corner radius for the progress bar. 0 disables rounded corners.
progress_bar_corner_radius = 0
# Define which corners to round when drawing the progress bar. If progress_bar_corner_radius
# is set to 0 this option will be ignored.
progress_bar_corners = all
# Corner radius for the image.
icon_corner_radius = 23
# Define which corners to round when drawing the icon image. If icon_corner_radius
# is set to 0 this option will be ignored.
icon_corners = all
# Show how many messages are currently hidden (because of
# notification_limit).
indicate_hidden = yes
# The transparency of the window. Range: [0; 100].
# This option will only work if a compositing window manager is
# present (e.g. xcompmgr, compiz, etc.). (X11 only)
transparency = 0
# Draw a line of "separator_height" pixel height between two
# notifications.
# Set to 0 to disable.
# If gap_size is greater than 0, this setting will be ignored.
separator_height = 2
# Padding between text and separator.
padding = 4
# Horizontal padding.
horizontal_padding = 8
# Padding between text and icon.
text_icon_padding = 0
# Defines width in pixels of frame around the notification window.
# Set to 0 to disable.
frame_width = 3
# Defines color of the frame around the notification window.
# Bright "glow" violet (astro-menu's glow_violet) rather than the raw @violet —
# reads as emitted light over the blurred glass, since dunst can't Cairo-glow.
frame_color = "#8A5CFF"
# Progress-bar fill: violet -> accent gradient, same sweep as the menus.
highlight = "#8A5CFF, #EB00A6"
# Size of gap to display between notifications - requires a compositor.
# If value is greater than 0, separator_height will be ignored and a border
# of size frame_width will be drawn around each notification instead.
# Click events on gaps do not currently propagate to applications below.
gap_size = 5
# Define a color for the separator.
# possible values are:
# * auto: dunst tries to find a color fitting to the background;
# * foreground: use the same color as the foreground;
# * frame: use the same color as the frame;
# * anything else will be interpreted as a X color.
separator_color = frame
# Sort type.
# possible values are:
# * id: sort by id
# * urgency_ascending: sort by urgency (low then normal then critical)
# * urgency_descending: sort by urgency (critical then normal then low)
# * update: sort by update (most recent always at the top)
sort = yes
# Don't remove messages, if the user is idle (no mouse or keyboard input)
# for longer than idle_threshold seconds.
# Set to 0 to disable.
# A client can set the 'transient' hint to bypass this. See the rules
# section for how to disable this if necessary
# idle_threshold = 120
### Text ###
font = Agave Nerd Font Mono 11
# The spacing between lines. If the height is smaller than the
# font height, it will get raised to the font height.
line_height = 0
# Possible values are:
# full: Allow a small subset of html markup in notifications:
# <b>bold</b>
# <i>italic</i>
# <s>strikethrough</s>
# <u>underline</u>
#
# For a complete reference see
# <https://docs.gtk.org/Pango/pango_markup.html>.
#
# strip: This setting is provided for compatibility with some broken
# clients that send markup even though it's not enabled on the
# server. Dunst will try to strip the markup but the parsing is
# simplistic so using this option outside of matching rules for
# specific applications *IS GREATLY DISCOURAGED*.
#
# no: Disable markup parsing, incoming notifications will be treated as
# plain text. Dunst will not advertise that it has the body-markup
# capability if this is set as a global setting.
#
# It's important to note that markup inside the format option will be parsed
# regardless of what this is set to.
markup = full
# The format of the message. Possible variables are:
# %a appname
# %s summary
# %b body
# %i iconname (including its path)
# %I iconname (without its path)
# %p progress value if set ([ 0%] to [100%]) or nothing
# %n progress value if set without any extra characters
# %% Literal %
# Markup is allowed.
# Cosmonaut Shell / astro-menu hologram idiom: the title as emitted magenta
# light (letter-spaced, like the astro HUD headlines), then a glow-violet
# "radio squiggle" divider (a sine-wave line, like a tuned signal), then the
# body. urgency_critical overrides this to shift title+squiggle to the accent.
format = "<b><span letter_spacing='1536'>%s</span></b>\n<span foreground='#8A5CFF' size='small' rise='2000'>∿∿∿∿∿∿∿∿∿∿∿∿∿∿</span>\n%b"
# Alignment of message text.
# Possible values are "left", "center" and "right".
alignment = center
# Vertical alignment of message text and icon.
# Possible values are "top", "center" and "bottom".
vertical_alignment = center
# Show age of message if message is older than show_age_threshold
# seconds.
# Set to -1 to disable.
show_age_threshold = 60
# Specify where to make an ellipsis in long lines.
# Possible values are "start", "middle" and "end".
ellipsize = middle
# Ignore newlines '\n' in notifications.
ignore_newline = no
# Stack together notifications with the same content
stack_duplicates = true
# Hide the count of stacked notifications with the same content
hide_duplicate_count = false
# Display indicators for URLs (U) and actions (A).
show_indicators = yes
### Icons ###
# Recursive icon lookup. You can set a single theme, instead of having to
# define all lookup paths.
enable_recursive_icon_lookup = true
# Set icon theme (only used for recursive icon lookup)
icon_theme = "Papirus-Dark, Adwaita"
# You can also set multiple icon themes, with the leftmost one being used first.
# icon_theme = "Adwaita, breeze"
# Align icons left/right/top/off
icon_position = top
# Scale small icons up to this size, set to 0 to disable. Helpful
# for e.g. small files or high-dpi screens. In case of conflict,
# max_icon_size takes precedence over this.
min_icon_size = 32
# Scale larger icons down to this size, set to 0 to disable
max_icon_size = 128
# Paths to default icons (only necessary when not using recursive icon lookup)
icon_path = /usr/share/icons/gnome/16x16/status/:/usr/share/icons/gnome/16x16/devices/
### History ###
# Should a notification popped up from history be sticky or timeout
# as if it would normally do.
sticky_history = yes
# Maximum amount of notifications kept in history
history_length = 20
### Misc/Advanced ###
# dmenu path.
dmenu = /usr/bin/dmenu -p dunst:
# Browser for opening urls in context menu.
browser = /usr/bin/xdg-open
# Always run rule-defined scripts, even if the notification is suppressed
always_run_script = true
# Define the title of the windows spawned by dunst (X11 only)
title = Dunst
# Define the class of the windows spawned by dunst (X11 only)
class = Dunst
# Define the corner radius of the notification window
# in pixel size. If the radius is 0, you have no rounded
# corners.
# The radius will be automatically lowered if it exceeds half of the
# notification height to avoid clipping text and/or icons.
corner_radius = 20
# Define which corners to round when drawing the window. If the corner radius
# is set to 0 this option will be ignored.
#
# Comma-separated list of the corners. The accepted corner values are bottom-right,
# bottom-left, top-right, top-left, top, bottom, left, right or all.
corners = all
# Ignore the dbus closeNotification message.
# Useful to enforce the timeout set by dunst configuration. Without this
# parameter, an application may close the notification sent before the
# user defined timeout.
ignore_dbusclose = false
### Wayland ###
# These settings are Wayland-specific. They have no effect when using X11
# Uncomment this if you want to let notifications appear under fullscreen
# applications (default: overlay)
# layer = top
# Set this to true to use X11 output on Wayland.
force_xwayland = false
### Legacy
# Use the Xinerama extension instead of RandR for multi-monitor support.
# This setting is provided for compatibility with older nVidia drivers that
# do not support RandR and using it on systems that support RandR is highly
# discouraged.
#
# By enabling this setting dunst will not be able to detect when a monitor
# is connected or disconnected which might break follow mode if the screen
# layout changes.
force_xinerama = false
### mouse
# Defines list of actions for each mouse event
# Possible values are:
# * none: Don't do anything.
# * do_action: Invoke the action determined by the action_name rule. If there is no
# such action, open the context menu.
# * open_url: If the notification has exactly one url, open it. If there are multiple
# ones, open the context menu.
# * close_current: Close current notification.
# * close_all: Close all notifications.
# * context: Open context menu for the notification.
# * context_all: Open context menu for all notifications.
# These values can be strung together for each mouse event, and
# will be executed in sequence.
mouse_left_click = close_current
mouse_middle_click = do_action, close_current
mouse_right_click = close_all
# Experimental features that may or may not work correctly. Do not expect them
# to have a consistent behaviour across releases.
[experimental]
# Calculate the dpi to use on a per-monitor basis.
# If this setting is enabled the Xft.dpi value will be ignored and instead
# dunst will attempt to calculate an appropriate dpi value for each monitor
# using the resolution and physical size. This might be useful in setups
# where there are multiple screens with very different dpi values.
per_monitor_dpi = false
# astro-menu hologram idiom (see astro-menu/style/style.css):
# emitted text = magenta #EB00A6 (glow_magenta), glass = violet-tinted + very
# transparent so the compositor blur reads as projected glass, glow frame =
# #8A5CFF (glow_violet), accent = #E40046. The astro panel gets its depth from
# Cairo scanlines/glow dunst can't draw, so we lean on colour + blur instead.
[urgency_low]
# IMPORTANT: colors have to be defined in quotation marks.
# Otherwise the "#" and following would be interpreted as a comment.
# Violet-tinted glass, ~63% opaque (…A0) so the blur reads as strong holo glass.
background = "#1C1442A0"
foreground = "#EB00A6"
frame_color = "#8A5CFF"
timeout = 10
# Icon for notifications with low urgency
default_icon = dialog-information
[urgency_normal]
background = "#1C1442A0"
foreground = "#EB00A6"
frame_color = "#8A5CFF"
timeout = 10
override_pause_level = 30
# Icon for notifications with normal urgency
default_icon = dialog-information
[urgency_critical]
# Accent-tinted violet glass + accent glow frame; emitted magenta text stays.
background = "#2E1030A0"
foreground = "#EB00A6"
frame_color = "#E40046"
# Shift the title + squiggle to the accent so they match the critical frame.
format = "<b><span letter_spacing='1536' foreground='#E40046'>%s</span></b>\n<span foreground='#E40046' size='small' rise='2000'>∿∿∿∿∿∿∿∿∿∿∿∿∿∿</span>\n%b"
timeout = 0
override_pause_level = 60
# Icon for notifications with critical urgency
default_icon = dialog-warning
# Every section that isn't one of the above is interpreted as a rules to
# override settings for certain messages.
#
# Messages can be matched by
# appname (discouraged, see desktop_entry)
# body
# category
# desktop_entry
# icon
# match_transient
# msg_urgency
# stack_tag
# summary
#
# and you can override the
# background
# foreground
# format
# frame_color
# fullscreen
# new_icon
# set_stack_tag
# set_transient
# set_category
# timeout
# urgency
# icon_position
# skip_display
# history_ignore
# action_name
# word_wrap
# ellipsize
# alignment
# hide_text
# override_pause_level
#
# Shell-like globbing will get expanded.
#
# Instead of the appname filter, it's recommended to use the desktop_entry filter.
# GLib based applications export their desktop-entry name. In comparison to the appname,
# the desktop-entry won't get localized.
#
# You can also allow a notification to appear even when paused. Notification will appear whenever notification's override_pause_level >= dunst's paused level.
# This can be used to set partial pause modes, where more urgent notifications get through, but less urgent stay paused. To do that, you can override the following in the rules:
# override_pause_level = X
# SCRIPTING
# You can specify a script that gets run when the rule matches by
# setting the "script" option.
# The script will be called as follows:
# script appname summary body icon urgency
# where urgency can be "LOW", "NORMAL" or "CRITICAL".
#
# NOTE: It might be helpful to run dunst -print in a terminal in order
# to find fitting options for rules.
# Disable the transient hint so that idle_threshold cannot be bypassed from the
# client
#[transient_disable]
# match_transient = yes
# set_transient = no
#
# Make the handling of transient notifications more strict by making them not
# be placed in history.
#[transient_history_ignore]
# match_transient = yes
# history_ignore = yes
# fullscreen values
# show: show the notifications, regardless if there is a fullscreen window opened
# delay: displays the new notification, if there is no fullscreen window active
# If the notification is already drawn, it won't get undrawn.
# pushback: same as delay, but when switching into fullscreen, the notification will get
# withdrawn from screen again and will get delayed like a new notification
#[fullscreen_delay_everything]
# fullscreen = delay
#[fullscreen_show_critical]
# msg_urgency = critical
# fullscreen = show
#[espeak]
# summary = "*"
# script = dunst_espeak.sh
#[script-test]
# summary = "*script*"
# script = dunst_test.sh
#[ignore]
# # This notification will not be displayed
# summary = "foobar"
# skip_display = true
#[history-ignore]
# # This notification will not be saved in history
# summary = "foobar"
# history_ignore = yes
#[skip-display]
# # This notification will not be displayed, but will be included in the history
# summary = "foobar"
# skip_display = yes
#[signed_on]
# appname = Pidgin
# summary = "*signed on*"
# urgency = low
#
#[signed_off]
# appname = Pidgin
# summary = *signed off*
# urgency = low
#
#[says]
# appname = Pidgin
# summary = *says*
# urgency = critical
#
#[twitter]
# appname = Pidgin
# summary = *twitter.com*
# urgency = normal
#
#[stack-volumes]
# appname = "some_volume_notifiers"
# set_stack_tag = "volume"
#
# vim: ft=cfg

View File

@ -0,0 +1,64 @@
# CyberQueer — Ghostty config
# Ported from ../kitty/{kitty.conf,themes/cyberqueer.conf}. Palette matches
# ~/Dotfiles/colors.conf (COLOR_TEXT/BG/HIGHLIGHT/DARK/RED) plus the same
# neon green/yellow/magenta/cyan tints used in the kitty theme so ANSI
# output looks identical across both terminals. Kitty could only fake a
# glow with a static background image (../kitty/scanlines.png); here it's
# a real per-frame bloom shader — see shaders/cyberqueer-glow.glsl.
#
# NOT wired up as the active terminal yet: no keybinds or $TERMINAL changes
# point at Ghostty. This file just needs `ghostty --config-file=<this path>`
# (or deploying to ~/.config/ghostty/config) to try it.
font-family = Agave Nerd Font
font-size = 13
cursor-style = bar
cursor-style-blink = false
shell-integration-features = no-cursor
background-opacity = 0.4
background-blur = true
window-padding-x = 4
window-padding-y = 4
scrollback-limit = 10485760
confirm-close-surface = false
# No titlebar, no tab bar — Hyprland handles window placement/borders itself
# (matches kitty's borderless look under this compositor). Note: an old
# Ghostty bug (ghostty-org/ghostty#3178, fixed in 1.0.1) made this exact
# Arch+Hyprland combo render as a single flat color with gtk-titlebar=false;
# using window-decoration=none instead of gtk-titlebar avoids that specific
# combo, but if the "flat pane, no text" symptom ever comes back, this is
# the first place to check by reverting.
window-decoration = none
window-show-tab-bar = never
custom-shader = shaders/cyberqueer-glow.glsl
custom-shader-animation = always
foreground = D6ABAB
background = 1A1A1A
selection-foreground = E40046
selection-background = 5018DD
cursor-color = E40046
cursor-text = 5018DD
split-divider-color = 5018DD
palette = 0=1A1A1A
palette = 1=E40046
palette = 2=33E6A0
palette = 3=E8B23D
palette = 4=5018DD
palette = 5=A6178C
palette = 6=34C6C6
palette = 7=D6ABAB
palette = 8=4B3A55
palette = 9=F50505
palette = 10=5CFFC2
palette = 11=FFD873
palette = 12=8C6FFF
palette = 13=FF5FC0
palette = 14=6FF5F0
palette = 15=F2DEDE

View File

@ -0,0 +1,202 @@
// CyberQueer glow shader for Ghostty's custom-shader hook.
// Real per-pixel bloom (unlike kitty, which can only fake this with a static
// background image — see ../../kitty/scanlines.png). Deliberately
// color-preserving: it blooms whatever color a glyph already is, so the
// suite's violet/crimson/neon ANSI palette blooms in its own hues instead of
// a single hardcoded tint.
//
// Only one custom-shader reliably renders on Linux/OpenGL (stacking multiple
// silently drops all but the last), so bloom + scanlines + grain + glitch +
// the boot intro all live here as a single pass rather than several files.
//
// Four effects, composited in order:
// 1. occasional glitch/roll burst (analog-signal tear + vertical roll +
// chromatic aberration), ported from the technique in
// github.com/0xhckr/ghostty-shaders/blob/main/glitchy.glsl
// 2. bloom (existing, sampled post-glitch-warp)
// 3. scanlines + ambient grain (existing)
// 4. a one-time hologram boot intro: the picture resolves out of colored
// static via a downward sweep over the first ~1.8s of iTime.
// ---------- shared noise helpers (glitch §1 + intro §4) ----------
// SS: a windowed pulse — 0 outside [a,b], rising then falling inside it.
#define SS(a, b, x) (smoothstep(a, b, x) * smoothstep(b, a, x))
#define UI0 1597334673u
#define UI1 3812015801u
#define UI2 uvec2(UI0, UI1)
#define UI3 uvec3(UI0, UI1, 2798796415u)
#define UIF (1.0 / float(0xffffffffu))
// Hash by David Hoskins — reused from glitchy.glsl for the glitch burst's
// analog distortion and the intro's static field.
vec3 hash33(vec3 p) {
uvec3 q = uvec3(ivec3(p)) * UI3;
q = (q.x ^ q.y ^ q.z) * UI3;
return -1.0 + 2.0 * vec3(q) * UIF;
}
// Gradient noise by iq — continuous in space and time, so animating it
// reads as organic shimmer rather than a hard per-frame strobe (deliberate:
// this drives a full-window effect, so avoiding flicker matters).
float gnoise(vec3 x) {
vec3 p = floor(x);
vec3 w = fract(x);
vec3 u = w * w * w * (w * (w * 6.0 - 15.0) + 10.0);
vec3 ga = hash33(p + vec3(0.0, 0.0, 0.0));
vec3 gb = hash33(p + vec3(1.0, 0.0, 0.0));
vec3 gc = hash33(p + vec3(0.0, 1.0, 0.0));
vec3 gd = hash33(p + vec3(1.0, 1.0, 0.0));
vec3 ge = hash33(p + vec3(0.0, 0.0, 1.0));
vec3 gf = hash33(p + vec3(1.0, 0.0, 1.0));
vec3 gg = hash33(p + vec3(0.0, 1.0, 1.0));
vec3 gh = hash33(p + vec3(1.0, 1.0, 1.0));
float va = dot(ga, w - vec3(0.0, 0.0, 0.0));
float vb = dot(gb, w - vec3(1.0, 0.0, 0.0));
float vc = dot(gc, w - vec3(0.0, 1.0, 0.0));
float vd = dot(gd, w - vec3(1.0, 1.0, 0.0));
float ve = dot(ge, w - vec3(0.0, 0.0, 1.0));
float vf = dot(gf, w - vec3(1.0, 0.0, 1.0));
float vg = dot(gg, w - vec3(0.0, 1.0, 1.0));
float vh = dot(gh, w - vec3(1.0, 1.0, 1.0));
return 2.0 * (va + u.x * (vb - va) +
u.y * (vc - va) +
u.z * (ve - va) +
u.x * u.y * (va - vb - vc + vd) +
u.y * u.z * (va - vc - ve + vg) +
u.z * u.x * (va - vb - ve + vf) +
u.x * u.y * u.z * (-va + vb + vc - vd + ve - vf - vg + vh));
}
float gnoise01(vec3 x) {
return 0.5 + 0.5 * gnoise(x);
}
// Golden-spiral sample offsets + inverse-distance weights for the bloom,
// reused verbatim from the community bloom kernel (0xhckr/ghostty-shaders).
const vec3[24] samples = vec3[24](
vec3(0.1693761725038636, 0.9855514761735895, 1.0),
vec3(-1.333070830962943, 0.4721463328627773, 0.7071067811865475),
vec3(-0.8464394909806497, -1.51113870578065, 0.5773502691896258),
vec3(1.554155680728463, -1.2588090085709776, 0.5),
vec3(1.681364377589461, 1.4741145918052656, 0.4472135954999579),
vec3(-1.2795157692199817, 2.088741103228784, 0.4082482904638631),
vec3(-2.4575847530631187, -0.9799373355024756, 0.3779644730092272),
vec3(0.5874641440200847, -2.7667464429345077, 0.35355339059327373),
vec3(2.997715703369726, 0.11704939884745152, 0.3333333333333333),
vec3(0.41360842451688395, 3.1351121305574803, 0.31622776601683794),
vec3(-3.167149933769243, 0.9844599011770256, 0.30151134457776363),
vec3(-1.5736713846521535, -3.0860263079123245, 0.2886751345948129),
vec3(2.888202648340422, -2.1583061557896213, 0.2773500981126146),
vec3(2.7150778983300325, 2.5745586041105715, 0.2672612419124244),
vec3(-2.1504069972377464, 3.2211410627650165, 0.2581988897471611),
vec3(-3.6548858794907493, -1.6253643308191343, 0.25),
vec3(1.0130775986052671, -3.9967078676335834, 0.24253562503633297),
vec3(4.229723673607257, 0.33081361055181563, 0.23570226039551587),
vec3(0.40107790291173834, 4.340407413572593, 0.22941573387056174),
vec3(-4.319124570236028, 1.159811599693438, 0.22360679774997896),
vec3(-1.9209044802827355, -4.160543952132907, 0.2182178902359924),
vec3(3.8639122286635708, -2.6589814382925123, 0.21320071635561041),
vec3(3.3486228404946234, 3.4331800232609, 0.20851441405707477),
vec3(-2.8769733643574344, 3.9652268864187157, 0.20412414523193154)
);
float lum(vec4 c) {
return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
}
// Cheap 2D hash for the ambient grain speckles — no texture lookup needed.
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
// How often (seconds) an occasional glitch/roll burst fires, and what
// fraction of that cycle it's actually active for — "occasional" means
// rare and brief, not a constant tic.
#define GLITCH_DURATION 18.0
#define GLITCH_AMT 0.045
// How long the hologram takes to resolve out of static when it first boots.
#define INTRO_DURATION 1.8
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord.xy / iResolution.xy;
float t = iTime;
// --- 1. occasional glitch/roll burst ---
// glitchAmount is ~0 almost all the time and spikes for under a second
// once every GLITCH_DURATION seconds.
float glitchAmount = SS(GLITCH_DURATION * 0.002, GLITCH_DURATION * GLITCH_AMT, mod(t, GLITCH_DURATION));
float rowY = fragCoord.y;
float tear = gnoise(vec3(0.0, rowY * 0.01, t * 500.0)) * (glitchAmount * 0.03 + 0.0015);
float roll = glitchAmount * gnoise(vec3(t * 3.0, 0.0, 0.0)) * 0.05;
vec2 guv = uv + vec2(tear, roll);
guv.y = fract(guv.y); // vertical roll wraps top/bottom, like a CRT losing vertical hold
vec2 caOffset = vec2(2.0 / iResolution.x, 0.0) * (1.0 + glitchAmount * 3.0);
vec4 color;
color.r = texture(iChannel0, guv + caOffset).r;
color.g = texture(iChannel0, guv).g;
color.b = texture(iChannel0, guv - caOffset).b;
color.a = texture(iChannel0, guv).a;
// --- 2. bloom: color-preserving glow around bright glyphs ---
vec2 bstep = vec2(1.414) / iResolution.xy;
for (int i = 0; i < 24; i++) {
vec3 s = samples[i];
vec4 c = texture(iChannel0, guv + s.xy * bstep);
float l = lum(c);
if (l > 0.25) {
color += l * s.z * c * 0.15;
}
}
// burst noise: a brief flash of static riding on top of the tear/roll
float burst = hash33(vec3(fragCoord, mod(float(iFrame), 1000.0))).r;
color.rgb += burst * glitchAmount * 0.35;
// --- 3. scanlines: a hard-edged dark line every 3rd physical row ---
float scan = mod(fragCoord.y, 3.0) < 1.0 ? 0.90 : 1.0;
color.rgb *= scan;
// holographic grain: sparse, low-alpha, slowly time-varying speckles in
// the theme's accent/violet/danger hues. Update rate capped at ~8Hz and
// intensity kept low deliberately — this sits behind text all day, so it
// should read as ambient shimmer, not a strobe.
vec2 grainCell = floor(fragCoord.xy / 2.0) + floor(iTime * 8.0);
float n = hash(grainCell);
if (n > 0.985) {
vec3 speck = n > 0.9925 ? vec3(0.894, 0.0, 0.271) // accent E40046
: n > 0.99 ? vec3(0.961, 0.020, 0.020) // danger F50505
: vec3(0.314, 0.094, 0.867); // violet 5018DD
color.rgb += speck * 0.10;
}
// --- 4. hologram boot intro: resolve out of colored static over
// INTRO_DURATION seconds via a downward sweep, then never run again for
// the life of the shader (t only grows). Uses gnoise (continuous) rather
// than a per-frame hash flip, so the static shimmers instead of strobing.
float introProgress = clamp(t / INTRO_DURATION, 0.0, 1.0);
float sweepFrac = introProgress * 1.15 - 0.05;
float resolved = 1.0 - smoothstep(sweepFrac - 0.05, sweepFrac + 0.05, uv.y);
float n1 = gnoise01(vec3(fragCoord * 0.08, t * 6.0));
float n2 = gnoise01(vec3(fragCoord * 0.08 + 19.0, t * 6.0));
vec3 violet = vec3(0.314, 0.094, 0.867);
vec3 accent = vec3(0.894, 0.0, 0.271);
vec3 danger = vec3(0.961, 0.020, 0.020);
vec3 introField = mix(violet, accent, n1);
introField = mix(introField, danger, smoothstep(0.75, 1.0, n2) * 0.5);
introField *= (0.18 + 0.22 * n2); // keep it dim — ambient static, not a flash
float sweepBand = 1.0 - smoothstep(0.0, 0.12, abs(uv.y - sweepFrac));
vec3 sweepGlow = violet * sweepBand * 1.2;
vec3 finalRGB = mix(introField, color.rgb, resolved) + sweepGlow;
fragColor = vec4(finalRGB, color.a);
}

View File

@ -0,0 +1,16 @@
[terminal]
# The VT to run the greeter on. Can be "next", "current" or a number
# designating the VT.
vt = 1
# The default session, also known as the greeter.
[default_session]
# `agreety` is the bundled agetty/login-lookalike. You can replace `/bin/sh`
# with whatever you want started, such as `sway`.
command = "tuigreet --cmd Hyprland"
# The user to run the command as. The privileges this user must have depends
# on the greeter. A graphical greeter may for example require the user to be
# in the `video` group.
user = "greeter"

View File

@ -0,0 +1 @@
file://$HOME/Nextcloud

View File

@ -0,0 +1,17 @@
[Settings]
gtk-theme-name=cyberqueer
gtk-icon-theme-name=Papirus-Dark
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=Nordzy-cursors-lefthand
gtk-cursor-theme-size=50
gtk-toolbar-style=GTK_TOOLBAR_ICONS
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
gtk-button-images=0
gtk-menu-images=0
gtk-enable-event-sounds=1
gtk-enable-input-feedback-sounds=0
gtk-xft-antialias=1
gtk-xft-hinting=1
gtk-xft-hintstyle=hintslight
gtk-xft-rgba=rgb
gtk-application-prefer-dark-theme=1

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -0,0 +1,77 @@
"""All-apps data source (AstalApps, same library astal-menu's appdrawer uses) plus
favorite-app persistence a plain JSON list of desktop-entry IDs under
~/.local/state/horizon-dock/favorites.json (see paths.py for why STATE_DIR, not
CONFIG_DIR)."""
from __future__ import annotations
import json
import gi
gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps # noqa: E402
from paths import FAVORITES_FILE, ensure_dirs
class AppSource:
def __init__(self) -> None:
self._apps = AstalApps.Apps()
self._favorites: list[str] = self._load_favorites()
self._wm_index: dict[str, str] = {}
# -- listing -------------------------------------------------------------
def all_apps(self) -> list:
"""Every installed app, alphabetical by name."""
apps = list(self._apps.get_list())
apps.sort(key=lambda a: (a.get_name() or "").lower())
return apps
def icon_for_window(self, window: dict) -> str:
"""Map an open window's wm class to its installed app's icon — same
wm-class/executable/name index astal-menu/ui/taskbar.py builds for its
own taskbar, rebuilt lazily here on first use."""
if not self._wm_index:
for app in self._apps.get_list():
for key in (app.get_wm_class(), app.get_executable(), app.get_name()):
if key:
self._wm_index.setdefault(key.lower(), app.get_icon_name())
cls = (window.get("class") or "").lower()
return self._wm_index.get(cls) or cls or "application-x-executable"
def favorite_apps(self) -> list:
"""Favorited apps, in the order they were pinned. Entries whose app has
since been uninstalled are silently dropped (not un-favorited the
desktop file may just be temporarily missing, e.g. mid-update)."""
by_entry = {a.get_entry(): a for a in self._apps.get_list()}
return [by_entry[e] for e in self._favorites if e in by_entry]
@staticmethod
def launch(app) -> None:
app.launch()
# -- favorites persistence ------------------------------------------------
def is_favorite(self, app) -> bool:
return app.get_entry() in self._favorites
def toggle_favorite(self, app) -> None:
entry = app.get_entry()
if not entry:
return
if entry in self._favorites:
self._favorites.remove(entry)
else:
self._favorites.append(entry)
self._save_favorites()
def _load_favorites(self) -> list[str]:
try:
data = json.loads(FAVORITES_FILE.read_text())
return [e for e in data if isinstance(e, str)]
except (FileNotFoundError, json.JSONDecodeError):
return []
def _save_favorites(self) -> None:
ensure_dirs()
FAVORITES_FILE.write_text(json.dumps(self._favorites, indent=2) + "\n")

View File

@ -0,0 +1,37 @@
"""Tiny user-editable config file: ~/.local/state/horizon-dock/config.json.
Read once at startup (main.py); flags are passed into HorizonDock's constructor
rather than polled, so a change takes effect on the next horizon-dock-start.sh
restart, not live. Same pattern as orbit-menu/config.py.
"""
from __future__ import annotations
import json
from paths import CONFIG_FILE, ensure_dirs
# "tray": still undecided whether the full StatusNotifierItem satellite earns its
# keep vs a simpler dock — default on, but easy to switch off without touching code.
# "hologram": same toggle orbit-menu/astro-menu expose for their own overlay.
_DEFAULTS = {"tray": True, "hologram": True}
def _load() -> dict:
try:
data = json.loads(CONFIG_FILE.read_text())
except (FileNotFoundError, json.JSONDecodeError):
data = {}
merged = {**_DEFAULTS, **data}
if not CONFIG_FILE.exists():
ensure_dirs()
CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
return merged
def tray_enabled() -> bool:
return bool(_load().get("tray", True))
def hologram_enabled() -> bool:
return bool(_load().get("hologram", True))

View File

@ -0,0 +1,660 @@
"""horizon-dock — a hover-scrollable orbital dock, matching orbit-menu's visual
language (same CyberQueer glow/hover "planet" node styling).
Anchored full-width to the bottom of the screen. Toggled show/hide (not an
always-resident hover-reveal dock); every time it's shown it fades+slides up
into place from below the screen edge, and hiding reverses that driven by the
same per-frame tick-callback+easing pattern orbit-menu uses for its own
animations. Three rows of circular "planet" icon buttons Open Windows,
Favorites, All Apps each laid out evenly-spaced along a shared giant-circle
arc (the "horizon" curve: a shallow dip toward the screen edges, radius derived
from the monitor width so the sag reads consistently at any resolution).
Hovering a row's Y-band selects it as the active scroll target — mouse-wheel
scrolling only ever moves the currently-hovered row (Gtk.EventControllerScroll),
independent of the other two. The tray satellite sits at the fixed right edge of
the Favorites row: its position is never touched by that row's scroll
repositioning, and it's added to the canvas last so it paints on top — meaning
overflowing favorites scrolling under it are simply covered, reading as "going
behind" the satellite.
"""
from __future__ import annotations
import json
import math
import random
import subprocess
import time
from typing import Callable, Optional
import cairo
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gdk, GLib, Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
import apps as apps_source
import windows as windows_source
from lib.hologram import HologramOverlay
from tray import TrayHost
# CyberQueer accent/violet — hardcoded here as orbit-menu's own orbit_menu.py
# does for its Cairo drawing, since Cairo paints directly and doesn't see GTK
# CSS @define-color names. Kept in sync by eye with style/_colors.css.
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
class HorizonDock(Gtk.Window):
# The dock is one big "orbit node" (a glowing violet planet) whose centre sits
# below the screen, so only its top cap rises above the bottom edge. The three
# item rows are concentric rings on that cap and their icons ride it like
# satellites orbiting the node — the same visual language as orbit-menu.
# DOCK_HEIGHT is derived per-monitor from the two shape constraints below.
# A horizon-perspective dock: the orbits are foreshortened ellipse arcs that
# all converge to a HORIZON_Y line near the top, so they read as concentric
# rings lying on a plane receding to the horizon (à la a low-angle view of a
# ringed planet). Icons ride the front (near) edge of each ring. Short.
DOCK_HEIGHT = 210 # placeholder; recomputed per width (see _dock_height)
PLANET_SIZE = 52
ITEM_SPACING = 66.0 # center-to-center distance between planets in a row
BASE_MARGIN = 16.0 # the horizon line sits this far above the bottom edge
PERSPECTIVE_SQUASH = 0.19 # vertical foreshortening: low = flat, far-away horizon
ROW_ORDER = ["windows", "favorites", "apps"] # far -> near, top(horizon) -> bottom(front)
# each ring's horizontal radius as a fraction of the screen width; nearer rings
# (apps) are wider, farther rings (windows) narrower — that's the perspective.
ROW_HALFWIDTH = {"windows": 0.17, "favorites": 0.245, "apps": 0.32}
ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"}
HOVER_BAND = 46.0 # how near the cursor must be to a ring to select it
GLYPH_FONT = "Agave Nerd Font Mono"
# decorative glyphs sprinkled along the orbits between icons (nerd-font)
ORBIT_GLYPHS = ["\uf444", "\uf10c", "\U000f0471", "\U000f1383", "\uf005"]
# Two ornamental satellite rings framing the (innermost) windows orbit \u2014 one
# just inside it, one just outside \u2014 as fractions of the windows ring radius.
SAT_RING_FACTORS = (0.60, 1.20)
TRAY_SIZE = 40
TRAY_MARGIN = 30.0
SCROLL_SENSITIVITY = 0.9
def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True,
hologram_enabled: bool = True):
super().__init__()
self.add_css_class("horizon-dock-window")
self._on_close = on_close
self._tray_enabled = False # tray removed — the dock is a clean orbit node now
self._apps = apps_source.AppSource()
self._tray = None
self._width = self._monitor_width()
self.DOCK_HEIGHT = self._dock_height() # per-monitor, shadows the class default
self._scroll_offset = {row: 0.0 for row in self.ROW_ORDER}
self._items: dict[str, list] = {row: [] for row in self.ROW_ORDER} # source objects
self._widgets: dict[str, list[Gtk.Widget]] = {row: [] for row in self.ROW_ORDER}
self._hovered_row: Optional[str] = None
self._sat_time = 0.0
self._last_tick: Optional[float] = None
self._tick_id: Optional[int] = None
# cursor tracking for the mouse-reactive ornamental satellites
self._mouse_x = self._width / 2
self._mouse_y = 0.0
self._mouse_sx = self._mouse_x # smoothed (eased) toward the raw position
self._mouse_sy = self._mouse_y
self._satellites = self._make_satellites()
self._init_layer_shell()
self._bg = Gtk.DrawingArea()
self._bg.set_size_request(int(self._width), self.DOCK_HEIGHT)
self._bg.set_draw_func(self._draw_background)
self._bg.add_css_class("horizon-canvas")
self._content = Gtk.Fixed()
self._content.set_size_request(int(self._width), self.DOCK_HEIGHT)
self._content.put(self._bg, 0, 0)
self._viewport = Gtk.Fixed()
self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT)
self._viewport.put(self._content, 0, 0)
# A Gtk.Fixed grows to the bounding box of ALL its children, and the dock
# places every app button at an absolute position (many far off-screen,
# each with an arc "dip" that grows unbounded with distance from centre).
# So the viewport's natural size balloons to thousands of px in both axes,
# and the layer-shell surface adopts that size — covering the whole screen
# with an (input-grabbing) surface. Clip it: a base Overlay sized ONLY to a
# DOCK_HEIGHT sizer, with the viewport added as a non-measured overlay
# child and overflow hidden, pins the window to exactly (width x
# DOCK_HEIGHT) no matter how large the Fixed inside gets.
self._sizer = Gtk.DrawingArea()
self._sizer.set_size_request(int(self._width), self.DOCK_HEIGHT)
clip = Gtk.Overlay()
clip.set_overflow(Gtk.Overflow.HIDDEN)
clip.set_child(self._sizer)
clip.add_overlay(self._viewport)
clip.set_measure_overlay(self._viewport, False)
self._hologram = HologramOverlay(enabled=hologram_enabled, clip_func=self._holo_clip,
fade_widget=self._content, intro_duration=2.4)
overlay = Gtk.Overlay()
overlay.set_child(clip)
overlay.add_overlay(self._hologram.widget)
self.set_child(overlay)
motion = Gtk.EventControllerMotion()
motion.connect("motion", self._on_motion)
motion.connect("leave", self._on_motion_leave)
self.add_controller(motion)
scroll = Gtk.EventControllerScroll()
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
scroll.connect("scroll", self._on_scroll)
self.add_controller(scroll)
self._tray_satellite: Optional[Gtk.Widget] = None
self._tray_popover: Optional[Gtk.Popover] = None
self.set_visible(False)
self._rebuild_all()
# -- layer shell ------------------------------------------------------
def _init_layer_shell(self) -> None:
LayerShell.init_for_window(self)
LayerShell.set_layer(self, LayerShell.Layer.TOP)
LayerShell.set_namespace(self, "horizon-dock")
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE)
LayerShell.set_exclusive_zone(self, 0)
for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.BOTTOM):
LayerShell.set_anchor(self, edge, True)
LayerShell.set_margin(self, edge, 0)
def _focused_gdk_monitor(self):
"""The Gdk monitor for Hyprland's currently-focused output, so the dock
opens on (and is sized to) whichever monitor you're on — not always
monitor 0. Falls back to monitor 0 if the lookup fails."""
display = Gdk.Display.get_default()
if display is None:
return None
monitors = display.get_monitors()
n = monitors.get_n_items() if monitors is not None else 0
name = None
try:
out = subprocess.run(["hyprctl", "-j", "monitors"],
capture_output=True, text=True, timeout=1).stdout
for m in json.loads(out):
if m.get("focused"):
name = m.get("name")
break
except Exception:
name = None
if name is not None:
for i in range(n):
mon = monitors.get_item(i)
if mon is not None and mon.get_connector() == name:
return mon
return monitors.get_item(0) if n else None
def _monitor_width(self) -> int:
mon = self._focused_gdk_monitor()
# Gdk logical width (already divided by the monitor's scale), which is the
# coordinate space layer-shell / GTK lay out in — not hyprctl's raw px.
return mon.get_geometry().width if mon is not None else 1920
# -- layout math (horizon perspective) ----------------------------------
def _ring_rx(self, row: str) -> float:
return self.ROW_HALFWIDTH[row] * self._width
def _ring_ry(self, row: str) -> float:
return self._ring_rx(row) * self.PERSPECTIVE_SQUASH
def _dock_height(self) -> int:
ry_max = max(self._ring_ry(r) for r in self.ROW_ORDER)
return int(self.BASE_MARGIN + ry_max + self.PLANET_SIZE / 2 + 14)
def _base_y(self) -> float:
"""The horizon line: near the BOTTOM of the dock. Orbits arch UP from it."""
return self.DOCK_HEIGHT - self.BASE_MARGIN
def _row_y_at(self, row: str, x: float) -> float:
"""y on the near edge of a ring's foreshortened ellipse — highest at the
centre, curving back down to the horizon line toward the sides."""
cx = self._width / 2
rx, ry = self._ring_rx(row), self._ring_ry(row)
dx = x - cx
if abs(dx) >= rx:
return self._base_y()
return self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
def _center_offset(self, row: str) -> float:
"""The scroll offset that centres a row's items on the arc: the middle item
lands at the centre, so a short row is a centred cluster and a long row
fills the arc symmetrically (overflowing/fading equally on both sides)."""
return max(0.0, (len(self._items[row]) - 1) / 2.0)
def _item_position(self, row: str, index: int) -> tuple[float, float]:
slot = index - self._scroll_offset[row]
x = self._width / 2 + slot * self.ITEM_SPACING
return x, self._row_y_at(row, x)
def _clamp_scroll(self, row: str) -> None:
count = len(self._items[row])
max_offset = max(0.0, count - 1)
self._scroll_offset[row] = min(max(self._scroll_offset[row], 0.0), max_offset)
# -- planet buttons -------------------------------------------------------
def _make_planet(self, icon_name: str, tooltip: str, size: int = PLANET_SIZE) -> Gtk.Button:
btn = Gtk.Button()
btn.set_has_frame(False)
btn.add_css_class("horizon-planet")
btn.set_size_request(size, size)
btn.set_tooltip_text(tooltip)
image = Gtk.Image.new_from_icon_name(icon_name or "application-x-executable")
image.set_pixel_size(int(size * 0.55))
btn.set_child(image)
return btn
def _rebuild_row(self, row: str) -> None:
for w in self._widgets[row]:
if w.get_parent() is not None:
self._content.remove(w)
self._widgets[row] = []
if row == "windows":
self._items[row] = windows_source.open_windows()
elif row == "favorites":
self._items[row] = self._apps.favorite_apps()
else:
self._items[row] = self._apps.all_apps()
# start each orbit centred on the arc (re-centred whenever it's rebuilt)
self._scroll_offset[row] = self._center_offset(row)
self._clamp_scroll(row)
for i, item in enumerate(self._items[row]):
btn = self._build_item_button(row, item)
x, y = self._item_position(row, i)
self._place_item(row, btn, x, y, put=True)
self._widgets[row].append(btn)
self._bg.queue_draw()
def _build_item_button(self, row: str, item) -> Gtk.Button:
if row == "windows":
icon = self._apps.icon_for_window(item)
title = item.get("title") or item.get("class") or "?"
btn = self._make_planet(icon, title)
addr = item.get("address")
btn.connect("clicked", lambda *_a, a=addr: windows_source.focus_window(a))
else:
btn = self._make_planet(item.get_icon_name(), item.get_name() or "")
btn.connect("clicked", lambda *_a, a=item: self._apps.launch(a))
if row == "apps":
right_click = Gtk.GestureClick(button=3)
right_click.connect("pressed", lambda *_a, a=item: self._toggle_favorite(a))
btn.add_controller(right_click)
return btn
def _toggle_favorite(self, item) -> None:
self._apps.toggle_favorite(item)
self._rebuild_row("favorites")
def _edge_fade(self, row: str, x: float) -> float:
"""1.0 in the middle of an orbit, smoothly fading to 0 as an icon nears the
ring's horizon extremity — so overflowing icons dissolve into the horizon
at the sides instead of piling up / being hard-clipped off the edge."""
rx = self._ring_rx(row)
dx = abs(x - self._width / 2)
start = rx * 0.68
if dx <= start:
return 1.0
if dx >= rx:
return 0.0
t = (dx - start) / (rx - start)
return 1.0 - t * t * (3 - 2 * t) # smoothstep down
def _place_item(self, row: str, w: Gtk.Widget, x: float, y: float, put: bool) -> None:
fx, fy = x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2
if put:
self._content.put(w, fx, fy)
else:
self._content.move(w, fx, fy)
fade = self._edge_fade(row, x)
w.set_opacity(fade)
w.set_can_target(fade > 0.05) # faded-out icons don't grab clicks
w.set_sensitive(fade > 0.05)
def _reflow_row(self, row: str) -> None:
for i, w in enumerate(self._widgets[row]):
x, y = self._item_position(row, i)
self._place_item(row, w, x, y, put=False)
def _rebuild_all(self) -> None:
for row in self.ROW_ORDER:
self._rebuild_row(row)
# -- hover / scroll routing -------------------------------------------------
def _row_at(self, x: float, y: float) -> Optional[str]:
"""The ring nearest the cursor (by vertical distance to its arc at x),
within HOVER_BAND so scrolling targets whichever orbit you're over."""
best, best_d = None, self.HOVER_BAND
for row in self.ROW_ORDER:
d = abs(y - self._row_y_at(row, x))
if d < best_d:
best, best_d = row, d
return best
def _on_motion(self, _ctrl, x: float, y: float) -> None:
self._mouse_x, self._mouse_y = x, y # drives the ornamental satellites
row = self._row_at(x, y)
if row != self._hovered_row:
self._hovered_row = row
self._bg.queue_draw()
def _on_motion_leave(self, _ctrl) -> None:
if self._hovered_row is not None:
self._hovered_row = None
self._bg.queue_draw()
def _on_scroll(self, _ctrl, _dx: float, dy: float) -> bool:
row = self._hovered_row
if row is None:
return False
self._scroll_offset[row] += dy * self.SCROLL_SENSITIVITY
self._clamp_scroll(row)
self._reflow_row(row)
return True
# -- background: the giant orbit node + its rings -----------------------
def _draw_background(self, _area, cr, width: float, height: float) -> None:
self._draw_node(cr)
for row in self.ROW_ORDER:
self._draw_ring(cr, row, hovered=(row == self._hovered_row))
self._draw_satellites(cr)
self._draw_center_sphere(cr)
def _front_arc_path(self, cr, rx: float, ry: float, close_on_horizon: bool) -> None:
"""Trace the near edge of a ring's foreshortened ellipse (arching UP) from
the left horizon point across to the right one; optionally close it back
along the horizon line to make a fillable semi-ellipse dome."""
cx = self._width / 2
base = self._base_y()
steps = 72
cr.move_to(cx - rx, base)
for s in range(1, steps + 1):
x = cx - rx + 2 * rx * s / steps
dx = x - cx
y = base - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
cr.line_to(x, y)
if close_on_horizon:
cr.close_path()
def _draw_node(self, cr) -> None:
"""The 'planet' the orbits sit on: the widest ring's foreshortened dome
filled with a vertical violet gradient (brighter at the arching near rim),
with a soft glowing edge a lit surface curving up from the horizon."""
rx = self._ring_rx("apps") * 1.05
ry = self._ring_ry("apps") * 1.05
base = self._base_y()
cr.save()
self._front_arc_path(cr, rx, ry, close_on_horizon=True)
grad = cairo.LinearGradient(0, base - ry, 0, base)
grad.add_color_stop_rgba(0.0, *_VIOLET, 0.30)
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.06)
cr.set_source(grad)
cr.fill()
for lw, a in ((12.0, 0.05), (6.0, 0.10), (2.2, 0.5)):
self._front_arc_path(cr, rx, ry, close_on_horizon=False)
cr.set_source_rgba(*_ACCENT, a)
cr.set_line_width(lw)
cr.stroke()
cr.restore()
def _draw_ring(self, cr, row: str, hovered: bool) -> None:
"""A faint guide arc along a row's foreshortened orbit; the hovered ring
brightens to accent so you can see which orbit the scroll will move."""
cr.save()
if hovered:
cr.set_source_rgba(*_ACCENT, 0.4)
cr.set_line_width(2.0)
else:
cr.set_source_rgba(*_VIOLET, 0.22)
cr.set_line_width(1.2)
self._front_arc_path(cr, self._ring_rx(row), self._ring_ry(row), close_on_horizon=False)
cr.stroke()
cr.restore()
def _holo_clip(self, cr, width: float, height: float) -> None:
"""Path-setter handed to the hologram overlay: trace the node dome (widest
ring, expanded to cover the icon tops) so the scanlines are clipped to the
UI shape instead of painting a full-height rectangle above it."""
pad = self.PLANET_SIZE / 2 + 12
self._front_arc_path(cr, self._ring_rx("apps") + pad,
self._ring_ry("apps") + pad, close_on_horizon=True)
def _draw_center_sphere(self, cr) -> None:
"""The 'planet': a big holographic world rising from the horizon. Its top
fills the clear central band (icon buttons float in front of it), while its
bottom quarter sinks below the screen edge and is clipped away by the dock's
overflow so it reads as a huge planet, not a small bead. Translucent body
(the desktop/scanlines show through) with a strong outer glow + rim."""
cx = self._width / 2
base = self._base_y()
# Clear band below the lowest icon arch (windows); the readout lives here.
band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2
bottom_edge = float(self.DOCK_HEIGHT) # dock surface bottom = screen bottom
vis_h = bottom_edge - band_top # visible vertical extent of the planet
# top anchored at band_top; radius sized so ~1/4 of the disc falls past the
# bottom edge (top 3/4 == the visible band): vis_h = 1.5 * rad.
rad = max(48.0, vis_h / 1.5)
cy = band_top + rad
cr.save()
# gentle breathing pulse so the planet reads as a live, radiant body
pulse = 0.82 + 0.18 * math.sin(self._sat_time * 2.2)
# wide outer bloom halo — the planet glows well beyond its own disc
halo_r = rad * 1.9
halo = cairo.RadialGradient(cx, cy, rad * 0.5, cx, cy, halo_r)
halo.add_color_stop_rgba(0.0, *_ACCENT, 0.40 * pulse)
halo.add_color_stop_rgba(0.45, *_VIOLET, 0.18 * pulse)
halo.add_color_stop_rgba(1.0, *_VIOLET, 0.0)
cr.arc(cx, cy, halo_r, 0, 2 * math.pi)
cr.set_source(halo)
cr.fill()
# translucent holographic body: a soft see-through core fading to a nearly
# transparent rim, so the blur/scanlines/desktop read through it
grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.05, cx, cy, rad)
grad.add_color_stop_rgba(0.0, 1.0, 0.82, 0.98, 0.48)
grad.add_color_stop_rgba(0.4, *_VIOLET, 0.36)
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.10)
cr.arc(cx, cy, rad, 0, 2 * math.pi)
cr.set_source(grad)
cr.fill()
# faint specular sheen (top-left) for a glassy 3D read
spec = cairo.RadialGradient(cx - rad * 0.34, cy - rad * 0.42, 0,
cx - rad * 0.34, cy - rad * 0.42, rad * 0.5)
spec.add_color_stop_rgba(0.0, 1, 1, 1, 0.38 * pulse)
spec.add_color_stop_rgba(1.0, 1, 1, 1, 0.0)
cr.arc(cx, cy, rad, 0, 2 * math.pi)
cr.set_source(spec)
cr.fill()
# strong, wide rim glow rings, capped by a crisp bright edge
for lw, a in ((24.0, 0.06 * pulse), (14.0, 0.12 * pulse),
(7.0, 0.22 * pulse), (2.6, 0.85)):
cr.arc(cx, cy, rad, 0, 2 * math.pi)
cr.set_source_rgba(*_ACCENT, a)
cr.set_line_width(lw)
cr.stroke()
# date + time readout, centred in the VISIBLE band (not at cy, which is low)
vis_cy = (band_top + bottom_edge) / 2
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
clock = time.strftime("%H:%M")
cr.set_font_size(vis_h * 0.40)
ext = cr.text_extents(clock)
cr.move_to(cx - ext.width / 2 - ext.x_bearing,
vis_cy - ext.height / 2 - ext.y_bearing - vis_h * 0.05)
cr.set_source_rgba(0.98, 0.92, 0.99, 0.98)
cr.show_text(clock)
date = time.strftime("%a %d %b")
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(vis_h * 0.20)
ext2 = cr.text_extents(date)
cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, vis_cy + vis_h * 0.30)
cr.set_source_rgba(*_ACCENT, 0.95)
cr.show_text(date)
cr.restore()
def _make_satellites(self) -> list[dict]:
"""A handful of ornamental glyphs, split across the two satellite rings that
frame the windows orbit (SAT_RING_FACTORS). Each ring gets a random 37 of
them at fixed base angles, and each satellite's *motion source* is picked at
random it either leans TOWARD the cursor, or is driven by the cursor's X,
or by the cursor's Y — so the pair of rings reads as a lively little swarm."""
sats: list[dict] = []
for fac in self.SAT_RING_FACTORS:
for _ in range(random.randint(3, 7)):
sats.append({
"fac": fac,
# base position on the visible (upper) half of the ring ellipse
"angle": random.uniform(0.16 * math.pi, 0.84 * math.pi),
"glyph": random.choice(self.ORBIT_GLYPHS),
"size": random.uniform(11.0, 15.0),
"source": random.choice(("toward", "mouseX", "mouseY")),
"amp": random.uniform(16.0, 32.0),
"wob_amp": random.uniform(1.5, 3.5),
"wob_speed": random.uniform(1.0, 2.0),
"phase": random.uniform(0.0, 2.0 * math.pi),
})
return sats
def _draw_satellites(self, cr) -> None:
"""Draw the two satellite rings (faint guide arcs) and their mouse-reactive
glyphs. Drawn on the background, so they sit behind the icons and sphere."""
cx = self._width / 2
base = self._base_y()
win_rx = self._ring_rx("windows")
mx, my = self._mouse_sx, self._mouse_sy
half_w = max(1.0, self._width / 2)
# faint guide arcs for the two rings
cr.save()
for fac in self.SAT_RING_FACTORS:
self._front_arc_path(cr, win_rx * fac, win_rx * fac * self.PERSPECTIVE_SQUASH,
close_on_horizon=False)
cr.set_source_rgba(*_VIOLET, 0.14)
cr.set_line_width(1.0)
cr.stroke()
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
for s in self._satellites:
rx = win_rx * s["fac"]
ry = rx * self.PERSPECTIVE_SQUASH
bx = cx + rx * math.cos(s["angle"])
by = base - ry * math.sin(s["angle"])
wob = s["wob_amp"] * math.sin(self._sat_time * s["wob_speed"] + s["phase"])
ox = oy = 0.0
if s["source"] == "toward":
ddx, ddy = mx - bx, my - by
d = math.hypot(ddx, ddy) or 1.0
ox, oy = ddx / d * s["amp"], ddy / d * s["amp"] # lean toward the cursor
elif s["source"] == "mouseX":
ox = (mx - cx) / half_w * s["amp"] * 2.0 # driven by cursor X
else: # mouseY
oy = (my - base) / half_w * s["amp"] * 2.0 # driven by cursor Y
x = bx + ox + wob
y = by + oy + wob * 0.5
border_w = min(1.0, x / 44.0, (self._width - x) / 44.0)
if border_w <= 0.0:
continue
glyph = s["glyph"]
cr.set_font_size(s["size"])
ext = cr.text_extents(glyph)
cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing)
cr.set_source_rgba(*_VIOLET, 0.7 * border_w)
cr.show_text(glyph)
cr.restore()
# -- animation loop -------------------------------------------------------
# No slide/fade reveal: the dock simply appears and the hologram's
# "materialise from static" intro (start_intro, below) is the whole opening
# effect — matching every other Cosmonaut Shell surface. The tick only drives
# the hologram animation while the dock is shown.
def _on_tick(self, _widget, frame_clock) -> bool:
now = frame_clock.get_frame_time() / 1_000_000
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
self._last_tick = now
self._sat_time += dt
# ease the smoothed cursor toward the raw position (frame-rate independent)
e = 1.0 - math.exp(-dt * 6.0)
self._mouse_sx += (self._mouse_x - self._mouse_sx) * e
self._mouse_sy += (self._mouse_y - self._mouse_sy) * e
self._hologram.tick(dt)
self._bg.queue_draw() # animate the planet's glow + the satellites
return True
def _ensure_tick(self) -> None:
if self._tick_id is None:
self._tick_id = self.add_tick_callback(self._on_tick)
def _stop_tick(self) -> None:
if self._tick_id is not None:
self.remove_tick_callback(self._tick_id)
self._tick_id = None
# Reset the frame-time baseline so the next show's first tick has dt=0.
# Otherwise a warm reopen sees dt = (time the dock was hidden), which would
# blow past the whole intro in one frame and skip the materialise animation.
self._last_tick = None
# -- external control ----------------------------------------------------
def _apply_width(self, width: int) -> None:
self._width = width
self.DOCK_HEIGHT = self._dock_height()
for w in (self._bg, self._content, self._viewport, self._sizer):
w.set_size_request(int(width), self.DOCK_HEIGHT)
def show_dock(self) -> None:
mon = self._focused_gdk_monitor()
if mon is not None:
LayerShell.set_monitor(self, mon)
self._apply_width(mon.get_geometry().width)
else:
self._apply_width(self._monitor_width())
self._rebuild_all()
self.set_visible(True)
self.present()
self._ensure_tick()
self._hologram.start_intro()
if getattr(self, "_clock_timer_id", None) is None:
self._clock_timer_id = GLib.timeout_add_seconds(15, self._refresh_clock)
def _refresh_clock(self) -> bool:
if self.get_visible():
self._bg.queue_draw() # repaint the planet's date/time
return True
self._clock_timer_id = None
return False
def hide_dock(self) -> None:
# Dissolve into static first, then hide via _finish_hide.
if self._hologram.enabled and self._tick_id is not None:
self._hologram.start_outro(self._finish_hide)
else:
self._finish_hide()
def _finish_hide(self) -> None:
self.set_visible(False)
self._stop_tick()
if self._on_close:
self._on_close()
def toggle(self) -> None:
if self.get_visible():
self.hide_dock()
else:
self.show_dock()

View File

@ -0,0 +1,265 @@
"""Holographic scanline/sweep/noise overlay — same treatment and tuning as
astro-menu's lib/hologram.py (itself matching orbit-menu's), reused here so
horizon-dock reads as one more orbit of the same Cosmonaut Shell suite. No
radial vignette mask: the dock's own module borders/canvas already bound it,
so covering the full rectangle reads as one continuous "HUD screen".
Owns its own animation clock (advanced by dock.py's tick callback via
.tick(dt)) and a persistent particle list for the noise specs, exactly like
astro-menu/orbit-menu's hologram: each spec keeps its position/color for a
real randomized lifetime (fade in, hold, fade out) instead of every spec
teleporting to a new position every frame.
"""
from __future__ import annotations
import math
import random
import cairo
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
# Same CyberQueer violet/magenta/red combo as orbit-menu's/astro-menu's hologram.
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
_MAGENTA = (0.92, 0.0, 0.65)
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
class HologramOverlay:
SCANLINE_GAP = 4.0
SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture
SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass
SWEEP_HALF_HEIGHT = 90.0
NOISE_COUNT = 95 # specs alive at once (each with its own lifetime)
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
INTRO_STATIC = 1200 # static specks at the very start of the intro
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
intro_duration: float | None = None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
# widget whose opacity is ramped 0->1 during the intro so the UI genuinely
# fades in (a gradual reveal), rather than a solid haze block popping on
self._fade_widget = fade_widget
if intro_duration is not None:
self.INTRO_DURATION = intro_duration # per-instance override of the class default
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None # >=0 while the materialise intro plays
self._outro_t: float | None = None # >=0 while the closing dissolve plays
self._outro_done = None # callback fired when the dissolve finishes
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
self.widget = Gtk.DrawingArea()
self.widget.set_can_target(False) # never steals clicks from content underneath
self.widget.add_css_class("horizon-hologram")
self.widget.set_hexpand(True)
self.widget.set_vexpand(True)
self.widget.set_halign(Gtk.Align.FILL)
self.widget.set_valign(Gtk.Align.FILL)
self.widget.set_draw_func(self._draw_frame)
def tick(self, dt: float) -> None:
if not self.enabled:
return
self._sat_time += dt
if self._intro_t is not None:
self._intro_t += dt
if self._intro_t >= self.INTRO_DURATION:
self._intro_t = None
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0)
elif self._fade_widget is not None:
p = self._intro_t / self.INTRO_DURATION
self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1
if self._outro_t is not None:
self._outro_t += dt
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0
if self._outro_t >= self.OUTRO_DURATION:
done = self._outro_done
self._outro_t = None
self._outro_done = None
if done is not None:
done()
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'hologram materialising out of static' opening effect."""
if self.enabled:
self._outro_t = None # cancel any in-flight closing dissolve
self._outro_done = None
self._intro_t = 0.0
if self._fade_widget is not None:
self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up
def start_outro(self, on_done) -> None:
"""Play a quick reverse of the intro (content dissolving back into static),
then call on_done to actually hide. If disabled, hide immediately."""
if not self.enabled:
on_done()
return
self._intro_t = None # cancel any in-flight opening intro
self._outro_t = 0.0
self._outro_done = on_done
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
cr.clip()
# Render the holo field into a group, then composite it back through a
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
# more like a projected hologram than a hard-edged rectangle).
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
elif self._outro_t is not None:
self._draw_outro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
# No solid veil block: the content itself fades in (see tick's fade_widget
# ramp). Here we only lay churning static over it — dense at first, thinning
# to nothing — so the UI resolves out of noise as it fades up.
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height # a bright scan wiping down as it resolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_outro(self, cr, width: float, height: float) -> None:
# Reverse of the intro: the content is already fading back out (tick's
# fade_widget ramp 1->0); here the static thickens from nothing as it goes,
# so the panel dissolves into noise just before it vanishes.
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (po ** 0.5))
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = (1.0 - po) * height # scan wiping back up as it dissolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
r, g, b = _VIOLET
cr.save()
cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA)
cr.set_line_width(1.0)
y = 0.0
while y < height:
cr.move_to(0, y)
cr.line_to(width, y)
y += self.SCANLINE_GAP
cr.stroke()
cr.restore()
phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD
sweep_y = phase * height
hh = self.SWEEP_HALF_HEIGHT
grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh)
grad.add_color_stop_rgba(0.0, r, g, b, 0.0)
grad.add_color_stop_rgba(0.5, r, g, b, 0.07)
grad.add_color_stop_rgba(1.0, r, g, b, 0.0)
cr.set_source(grad)
cr.rectangle(0, sweep_y - hh, width, hh * 2)
cr.fill()
flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0)
cr.set_source_rgba(r, g, b, max(0.0, flicker))
cr.paint()
self._draw_noise(cr, width, height)
def _draw_noise(self, cr, width: float, height: float) -> None:
now = self._sat_time
self._particles = [p for p in self._particles if now - p["birth"] < p["life"]]
while len(self._particles) < self.NOISE_COUNT:
self._particles.append({
"x": random.uniform(0, width),
"y": random.uniform(0, height),
"w": random.uniform(1.0, 2.6),
"h": random.uniform(1.0, 2.0),
"color": random.choice(self.NOISE_COLORS),
"peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE),
"birth": now,
"life": random.uniform(*self.NOISE_LIFETIME),
})
for p in self._particles:
t = (now - p["birth"]) / p["life"]
if t < self.NOISE_FADE_IN:
envelope = t / self.NOISE_FADE_IN
elif t > 1.0 - self.NOISE_FADE_OUT:
envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT)
else:
envelope = 1.0
r, g, b = p["color"]
cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope)
cr.rectangle(p["x"], p["y"], p["w"], p["h"])
cr.fill()

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