Compare commits

...

8 Commits

Author SHA1 Message Date
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
41 changed files with 3147 additions and 279 deletions

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

@ -24,7 +24,7 @@ VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
# 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.34
FILL_ALPHA = 0.28
def _rounded_rect(cr, x, y, w, h, r) -> None:
@ -39,8 +39,9 @@ def _rounded_rect(cr, x, y, w, h, r) -> None:
# 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.
_GLOW_LAYERS = [(10, 0.05), (6, 0.09), (3, 0.15)]
# 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):
@ -63,7 +64,7 @@ def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool):
return draw
def bordered(child: Gtk.Widget, border: int = 3, radius: int = 16,
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/

View File

@ -35,16 +35,32 @@ class HologramOverlay:
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, _ACCENT]
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) -> None:
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
@ -59,12 +75,138 @@ class HologramOverlay:
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()

View File

@ -115,7 +115,7 @@ class _BluetoothView(Gtk.Box):
self._list.remove(child)
child = self._list.get_first_child()
if not self.bt:
self._list.append(Gtk.Label(label="No Bluetooth adapter"))
self._list.append(Gtk.Label(label="No System Radio hardware"))
return
devices = list(self.bt.get_devices())
@ -283,7 +283,7 @@ def build(ctx: ModuleContext) -> ModuleInstance:
SPEC = ModuleSpec(
id="bluetooth",
title="Bluetooth",
title="System Radio",
icon="", # nf-fa-bluetooth
build=build,
default_enabled=True,

View File

@ -50,6 +50,9 @@ class _MapView(Gtk.Box):
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

View File

@ -518,7 +518,7 @@ def build(ctx: ModuleContext) -> ModuleInstance:
SPEC = ModuleSpec(
id="network",
title="Network",
title="Laser Antenna Uplink",
icon="", # nf-md-lan
build=build,
default_enabled=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

@ -10,7 +10,8 @@ from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
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
@ -28,6 +29,11 @@ class _WeatherView(Gtk.Box):
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…")

View File

@ -7,11 +7,11 @@ specs fill the 2x2 grid; extra specs are kept for future paginated layouts.
from __future__ import annotations
from modules import bluetooth, location, network, weather
from modules import bluetooth, network, sysmon, weather
from module_base import ModuleSpec
ALL_SPECS: list[ModuleSpec] = [
location.SPEC,
sysmon.SPEC,
weather.SPEC,
bluetooth.SPEC,
network.SPEC,

View File

@ -7,11 +7,29 @@
* .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
@ -96,6 +114,20 @@ drawingarea {
.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 {
@ -236,9 +268,13 @@ button:checked.quad-action { background: @accent; color: @bg; border-color: @acc
.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: alpha(@violet, 0.4);
background: transparent;
background-color: transparent;
color: @text;
font-family: "Agave Nerd Font Mono", monospace;
font-size: 11pt;

View File

@ -99,7 +99,7 @@ class MenuWindow(Gtk.ApplicationWindow):
overlay = Gtk.Overlay()
overlay.set_child(self.root)
self._hologram = HologramOverlay(enabled=config.hologram_enabled())
self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root)
overlay.add_overlay(self._hologram.widget)
close = Gtk.Button(label="")
@ -201,12 +201,21 @@ class MenuWindow(Gtk.ApplicationWindow):
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)

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.4 # quick 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,82 @@
#!/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 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)
self.hold() # stay alive with no visible window
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,188 @@
"""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 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
_SQUIGGLE = "" * 16 # ∿ sine-wave "tuned signal" divider
_CARD_RADIUS = 16.0
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)
squig = Gtk.Label(label=_SQUIGGLE, xalign=0.0)
squig.add_css_class("beacon-squiggle")
textcol.append(squig)
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)
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,90 @@
/* 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-squiggle divider (the tuned-signal line) */
.beacon-squiggle {
color: #8A5CFF;
font-size: 9pt;
margin: 1px 0;
opacity: 0.9;
}
.beacon-card.critical .beacon-squiggle { color: @accent; }
.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,128 @@
"""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
# -- 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

@ -107,7 +107,12 @@
frame_width = 3
# Defines color of the frame around the notification window.
frame_color = "#5018DD"
# 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
@ -140,7 +145,7 @@
### Text ###
font = Agave Nerd Font 12
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.
@ -179,8 +184,12 @@
# %p progress value if set ([ 0%] to [100%]) or nothing
# %n progress value if set without any extra characters
# %% Literal %
# Markup is allowed
format = "<b>%s</b>\n%b"
# 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".
@ -334,29 +343,39 @@
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.
background = "#1a1a1a"
foreground = "#5018dd"
frame_color = "#5018dd"
# 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 = "#1a1a1a"
foreground = "#E40046"
frame_color = "#5018dd"
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]
background = "#E40046"
foreground = "#5018dd"
frame_color = "#5018dd"
# 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

View File

@ -21,17 +21,21 @@ 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("Gsk", "4.0")
gi.require_version("Graphene", "1.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gdk, Graphene, Gsk, Gtk # noqa: E402
from gi.repository import Gdk, GLib, Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
import apps as apps_source
@ -47,44 +51,67 @@ _VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
class HorizonDock(Gtk.Window):
DOCK_HEIGHT = 214
# 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
ARC_SAG = 34.0 # how far a row dips toward the screen edges vs. its center
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"] # back -> front, top -> bottom
ROW_Y = {"windows": 46.0, "favorites": 112.0, "apps": 176.0}
ROW_BAND_TOP = {"windows": 0.0, "favorites": 79.0, "apps": 144.0}
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
REVEAL_DURATION = 0.28
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 = tray_enabled
self._tray_enabled = False # tray removed — the dock is a clean orbit node now
self._apps = apps_source.AppSource()
self._tray = TrayHost() if tray_enabled else None
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._reveal_progress = 0.0
self._reveal_target = 0.0
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()
@ -100,9 +127,27 @@ class HorizonDock(Gtk.Window):
self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT)
self._viewport.put(self._content, 0, 0)
self._hologram = HologramOverlay(enabled=hologram_enabled)
# 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(self._viewport)
overlay.set_child(clip)
overlay.add_overlay(self._hologram.widget)
self.set_child(overlay)
@ -133,24 +178,68 @@ class HorizonDock(Gtk.Window):
LayerShell.set_anchor(self, edge, True)
LayerShell.set_margin(self, edge, 0)
def _monitor_width(self) -> int:
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()
monitors = display.get_monitors() if display is not None else None
mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None
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 --------------------------------------------------------
def _arc_radius(self) -> float:
half_w = self._width / 2
sag = self.ARC_SAG
return (sag * sag + half_w * half_w) / (2 * sag)
# -- 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:
row_y = self.ROW_Y[row]
dx = x - self._width / 2
r = self._arc_radius()
dip = r - math.sqrt(max(r * r - dx * dx, 0.0))
return row_y + dip
"""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]
@ -187,17 +276,16 @@ class HorizonDock(Gtk.Window):
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._content.put(btn, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
self._place_item(row, btn, x, y, put=True)
self._widgets[row].append(btn)
if row == "favorites":
self._place_tray_satellite()
self._bg.queue_draw()
def _build_item_button(self, row: str, item) -> Gtk.Button:
@ -220,60 +308,54 @@ class HorizonDock(Gtk.Window):
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._content.move(w, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
self._place_item(row, w, x, y, put=False)
def _rebuild_all(self) -> None:
for row in self.ROW_ORDER:
self._rebuild_row(row)
# -- tray satellite -------------------------------------------------------
def _place_tray_satellite(self) -> None:
if not self._tray_enabled:
return
if self._tray_satellite is not None and self._tray_satellite.get_parent() is not None:
self._content.remove(self._tray_satellite)
x = self._width - self.TRAY_MARGIN
y = self.ROW_Y["favorites"]
btn = Gtk.MenuButton()
btn.add_css_class("horizon-planet")
btn.add_css_class("horizon-tray-satellite")
btn.set_size_request(self.TRAY_SIZE, self.TRAY_SIZE)
btn.set_tooltip_text("Tray")
icon = Gtk.Image.new_from_icon_name("view-list-symbolic")
icon.set_pixel_size(int(self.TRAY_SIZE * 0.5))
btn.set_child(icon)
popover = Gtk.Popover()
popover.add_css_class("horizon-tray-popover")
popover.set_child(self._build_tray_box())
btn.set_popover(popover)
self._tray_popover = popover
self._content.put(btn, x - self.TRAY_SIZE / 2, y - self.TRAY_SIZE / 2)
self._tray_satellite = btn
def _build_tray_box(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
box.add_css_class("horizon-tray-box")
if self._tray is None:
box.append(Gtk.Label(label="Tray disabled"))
return box
self._tray.build_into(box)
return box
# -- hover / scroll routing -------------------------------------------------
def _row_at_y(self, y: float) -> Optional[str]:
for row in reversed(self.ROW_ORDER):
if y >= self.ROW_BAND_TOP[row]:
return row
return None
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:
row = self._row_at_y(y)
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()
@ -292,63 +374,228 @@ class HorizonDock(Gtk.Window):
self._reflow_row(row)
return True
# -- background: the horizon arcs + hover band --------------------------
# -- 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_row_arc(cr, row, width)
if self._hovered_row is not None:
self._draw_hover_band(cr, self._hovered_row, width)
self._draw_ring(cr, row, hovered=(row == self._hovered_row))
self._draw_satellites(cr)
self._draw_center_sphere(cr)
def _draw_row_arc(self, cr, row: str, width: float) -> None:
cr.save()
cr.set_source_rgba(*_VIOLET, 0.16)
cr.set_line_width(1.2)
cr.move_to(0, self._row_y_at(row, 0))
steps = 48
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 = width * s / steps
cr.line_to(x, self._row_y_at(row, x))
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 _draw_hover_band(self, cr, row: str, width: float) -> None:
top = self.ROW_BAND_TOP[row]
bottom_candidates = [self.ROW_BAND_TOP[r] for r in self.ROW_ORDER if self.ROW_BAND_TOP[r] > top]
bottom = min(bottom_candidates) if bottom_candidates else self.DOCK_HEIGHT
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()
cr.set_source_rgba(*_ACCENT, 0.05)
cr.rectangle(0, top, width, bottom - top)
# 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()
# -- reveal animation -----------------------------------------------------
def _reveal_transform(self, progress: float) -> Gsk.Transform:
offset = (1.0 - progress) * self.DOCK_HEIGHT
t = Gsk.Transform.new()
t = t.translate(Graphene.Point().init(0, offset))
return t
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)
ease = 1 - math.exp(-dt * (1.0 / self.REVEAL_DURATION) * 3.2)
self._reveal_progress += (self._reveal_target - self._reveal_progress) * ease
if abs(self._reveal_target - self._reveal_progress) < 0.002:
self._reveal_progress = self._reveal_target
self._viewport.set_child_transform(self._content, self._reveal_transform(self._reveal_progress))
self._content.set_opacity(max(0.0, min(1.0, self._reveal_progress)))
if self._reveal_progress == self._reveal_target and self._reveal_target == 0.0:
self.set_visible(False)
self._tick_id = None # GTK drops the callback itself on a False return
if self._on_close:
self._on_close()
return False
self._bg.queue_draw() # animate the planet's glow + the satellites
return True
def _ensure_tick(self) -> None:
@ -359,22 +606,55 @@ class HorizonDock(Gtk.Window):
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:
self._width = self._monitor_width()
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._reveal_target = 1.0
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:
self._reveal_target = 0.0
self._ensure_tick()
# 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() and self._reveal_target == 1.0:
if self.get_visible():
self.hide_dock()
else:
self.show_dock()

View File

@ -34,16 +34,32 @@ class HologramOverlay:
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, _ACCENT]
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) -> None:
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
@ -58,12 +74,138 @@ class HologramOverlay:
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()

View File

@ -9,6 +9,16 @@ window.horizon-dock-window {
background: transparent;
}
/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }`, which
* otherwise paints EVERY node (the Overlay, the Fixed viewport/content, every
* Box) an opaque dark slab. Blanking by node name is fragile it missed
* `overlay`/`fixed` here so blank them all (this provider sits at USER+1,
* above the theme). Planets / tray satellite / popover re-assert their fills via
* the higher-specificity .class rules below. */
* {
background-color: transparent;
}
/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea
* nodes specifically (see orbit-menu/style/style.css's .orbit-hologram fix for
* the same discovery) the background arc canvas is a DrawingArea sitting

View File

@ -9,7 +9,7 @@ hl.on("hyprland.start", function()
hl.exec_cmd("systemctl --user start hyprpolkitagent")
hl.exec_cmd("hyprsunset")
hl.exec_cmd("nm-applet")
hl.exec_cmd("dunst")
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/beacon-start.sh")
hl.exec_cmd("[workspace special:magic silent] kitty")
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
hl.exec_cmd("hyprpaper")

View File

@ -259,17 +259,16 @@ hl.bind(mainMod .. " + SHIFT + ALT + j", hl.dsp.group.move_window("d"))
-- astro-menu is astal-menu re-themed as a hologram info display — same
-- functionality (Location/Weather/Bluetooth/Network quads, taskbar, app
-- drawer), same binds it always had in hyprlua.
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/astro-menu.sh toggle top"), { release = true })
-- Super+D now opens horizon-dock (see below); astro-menu moved to Super+Shift+D.
hl.bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd("~/.config/scripts/astro-menu.sh toggle top"), { release = true })
hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/astro-menu.sh appdrawer"))
--------------------
---- HORIZON-DOCK --
--------------------
-- Super+D went to astro-menu (its "og" bind); horizon-dock gets its own key.
-- (H is fully claimed by vim-style hjkl focus/move/resize across every
-- modifier combo, so this uses D too, distinguished by Shift.)
hl.bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-dock.sh toggle"), { release = true })
-- horizon-dock gets the primary Super+D; astro-menu moved to Super+Shift+D.
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-dock.sh toggle"), { release = true })
--------------------
---- STATION-BAR ---
@ -278,7 +277,10 @@ hl.bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-do
-- station-bar autostarts (see hypr/usr/autostart.lua) and is visible by
-- default; this just lets you hide/show it (e.g. to reclaim the reserved
-- top-edge space for a fullscreen app that doesn't already do so itself).
-- Super+B toggles every monitor's bar; Super+Z toggles only the bar on the
-- monitor you're currently focused on.
hl.bind(mainMod .. " + B", hl.dsp.exec_cmd("~/.config/scripts/station-bar.sh toggle"), { release = true })
hl.bind(mainMod .. " + Z", hl.dsp.exec_cmd("~/.config/scripts/station-bar.sh toggle here"), { release = true })
--------------------
---- SCREENSHOT ----

View File

@ -111,6 +111,30 @@ hl.window_rule({
opacity = "0.5 0.05",
})
-- Cosmonaut Shell layer-shell surfaces (astro-menu / orbit-menu / horizon-dock /
-- station-bar) are translucent "holographic" panels. Blur what shows through their
-- module fills for a frosted-glass look + text readability. ignore_alpha keeps the
-- fully-transparent gaps between modules sharp (no blur there), so the panels still
-- read as floating pieces rather than one big frosted slab. (Global blur is on but
-- layer-shell surfaces only get it via an explicit per-namespace layerrule.)
--
-- no_anim opts them OUT of the global `layers = slide` open animation: they must
-- NOT slide in from an edge — each instead "materialises out of static" via its
-- own in-app hologram intro (start_intro), so the compositor should just map them
-- in place and let that intro be the whole opening effect.
for _, ns in ipairs({ "astro-menu", "orbit-menu", "horizon-dock", "station-bar" }) do
hl.layer_rule({ name = "cosmoshell-blur-" .. ns, match = { namespace = ns },
blur = true, ignore_alpha = 0.1, no_anim = true })
end
-- beacon (our notification daemon, namespace "beacon") renders holographic
-- notification cards. Blur what shows through their translucent violet glass so
-- they read as the same frosted holo panels as the menus. no_anim: each card
-- materialises out of static via its own in-app hologram intro, so the compositor
-- should map the surface in place rather than slide it.
hl.layer_rule({ name = "cosmoshell-blur-beacon", match = { namespace = "beacon" },
blur = true, ignore_alpha = 0.1, no_anim = true })
-- smart gaps
hl.workspace_rule({ workspace = "w[tv1]s[false]", gaps_out = 0, gaps_in = 0 })
hl.workspace_rule({ workspace = "f[1]s[false]", gaps_out = 0, gaps_in = 0 })

View File

@ -20,8 +20,14 @@ from lib.proc import fire
def launch(cmd: str) -> None:
"""Run cmd via Hyprland's exec dispatcher — cmd may carry a `[tag ...]` /
`[workspace ...]` window-rule-on-launch prefix, same as binds.lua."""
fire(["hyprctl", "dispatch", "exec", cmd])
`[workspace ...]` window-rule-on-launch prefix, same as binds.lua.
NB: `hyprctl dispatch` evaluates its argument as Lua here (hyprlua), so this
must go through `hl.dsp.exec_cmd(...)` NOT the native `dispatch exec <cmd>`
form, which Lua parses as a syntax error and silently drops (see binds.lua's
`hl.dsp.exec_cmd("[tag +mixer] ...")`)."""
esc = cmd.replace("\\", "\\\\").replace('"', '\\"')
fire(["hyprctl", "dispatch", f'hl.dsp.exec_cmd("{esc}")'])
def hyprshutdown(post_cmd: str | None = None) -> None:

View File

@ -39,7 +39,7 @@ from gi.repository import Gio, GLib, Gtk # noqa: E402
import config # noqa: E402
import menu_tree # noqa: E402
import theme # noqa: E402
from orbit_menu import OrbitMenu # noqa: E402
from orbit_menu import MenuItem, OrbitMenu # noqa: E402
from paths import APP_ID # noqa: E402
@ -106,8 +106,56 @@ class OrbitMenuApp(Gtk.Application):
pass # resident instance: nothing to do on plain activate
class DmenuApp(Gtk.Application):
"""Standalone 'classic dmenu' mode: read newline-delimited items from stdin,
show them as ring nodes, print the chosen one to stdout and exit 0 (exit 1 on
cancel, printing nothing). NON_UNIQUE so it never routes to the resident daemon
(which couldn't write to this caller's stdout) it's its own throwaway process."""
def __init__(self, items: list[str]) -> None:
super().__init__(application_id=APP_ID + ".dmenu",
flags=Gio.ApplicationFlags.NON_UNIQUE)
self._items = items
self.result: str | None = None
self.window: OrbitMenu | None = None
def do_activate(self) -> None:
if self.window is not None: # do_activate can fire more than once
return
theme.load_css()
children = [MenuItem(label=line, action=(lambda ln=line: self._pick(ln)))
for line in self._items]
root = MenuItem(label="", children=children)
self.window = OrbitMenu(root, on_close=self._on_closed,
satellites=config.satellites_enabled(),
hologram=config.hologram_enabled())
self.window.open_at_root()
self.hold() # a hidden layer-shell window wouldn't keep the app alive alone
def _pick(self, line: str) -> None:
self.result = line # the leaf's action; _close() then runs the outro + on_close
def _on_closed(self) -> None:
if self.result is not None:
sys.stdout.write(self.result + "\n")
sys.stdout.flush()
self.quit()
def _run_dmenu() -> int:
items = [ln.rstrip("\n") for ln in sys.stdin.readlines()]
items = [ln for ln in items if ln != ""]
if not items:
return 1
app = DmenuApp(items)
app.run([sys.argv[0]]) # don't forward --dmenu into GApplication arg handling
return 0 if app.result is not None else 1
def main() -> int:
GLib.set_prgname("orbit-menu")
if "--dmenu" in sys.argv[1:]:
return _run_dmenu()
return OrbitMenuApp().run(sys.argv)

View File

@ -136,8 +136,16 @@ class OrbitMenu(Gtk.Window):
self._current_ring: Gtk.DrawingArea | None = None
self._current_page: Gtk.Fixed | None = None
self._transition: dict | None = None # active zoom/fade nav transition, if any
self._intro: dict | None = None # active opening "materialize" animation, if any
self._intro: dict | None = None # active opening node-condense animation, if any
self._holo_intro_t: float | None = None # >=0 while the "materialize out of static" intro plays
self._holo_outro_t: float | None = None # >=0 while the closing dissolve plays
self._holo_particles: list[dict] = [] # persistent hologram noise specs, see _update_hologram_particles
# Where the hologram glow mask is centred + how big, so it follows the
# zoom into/out of a submenu instead of staying pinned at canvas centre.
c0 = self.CANVAS_SIZE / 2
self._holo_cx = c0
self._holo_cy = c0
self._holo_scale = 1.0
# satellite/mouse-parallax state (unused entirely if satellites=False)
cx = cy = self.CANVAS_SIZE / 2
@ -508,6 +516,12 @@ class OrbitMenu(Gtk.Window):
# faded out completely by here — capped so it fully resolves to 0 before the
# canvas edge (CANVAS_SIZE/2), not just before the corners
_HOLO_MASK_OUTER = min(RADIUS + NODE_SIZE * 1.4, CANVAS_SIZE / 2 - 10)
# "materialize out of static" opening intro — same treatment as the other
# Cosmonaut Shell surfaces' shared hologram lib (haze + burst of static specks
# that thins out, plus a bright scan band wiping through as it resolves).
_HOLO_INTRO_DURATION = 2.4 # long, noisy fade-in
_HOLO_INTRO_STATIC = 1300 # specks at the very start, thinning to 0
_HOLO_OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
def _draw_hologram_frame(self, _area, cr, width: float, height: float) -> None:
if self._hologram_enabled:
@ -518,17 +532,90 @@ class OrbitMenu(Gtk.Window):
through a radial-gradient alpha mask centered on the canvas full strength
around the ring, soft-fading to nothing by _HOLO_MASK_OUTER, so it reads as
one big glow over the menu instead of tinting the whole square canvas."""
cx, cy = width / 2, height / 2
cx, cy = self._holo_cx, self._holo_cy
cr.push_group()
self._draw_hologram_content(cr, width, height)
pattern = cr.pop_group()
mask = cairo.RadialGradient(cx, cy, self._HOLO_MASK_INNER, cx, cy, self._HOLO_MASK_OUTER)
inner = self._HOLO_MASK_INNER * self._holo_scale
outer = self._HOLO_MASK_OUTER * self._holo_scale
mask = cairo.RadialGradient(cx, cy, inner, cx, cy, outer)
mask.add_color_stop_rgba(0.0, 1, 1, 1, 1)
mask.add_color_stop_rgba(1.0, 1, 1, 1, 0)
cr.set_source(pattern)
cr.mask(mask)
if self._holo_intro_t is not None:
self._draw_holo_intro(cr, cx, cy, outer)
elif self._holo_outro_t is not None:
self._draw_holo_outro(cr, cx, cy, outer)
def _draw_holo_intro(self, cr, cx: float, cy: float, outer: float) -> None:
"""The opening burst: a violet haze thick with static specks that thins as
the menu resolves, plus a bright scan band sweeping through confined to
the same radial footprint as the ambient hologram so the menu looks like it
condenses out of the noise (not a full-screen static wash over the desktop)."""
p = min(1.0, max(0.0, (self._holo_intro_t or 0.0) / self._HOLO_INTRO_DURATION))
strength = 1.0 - p
# a slightly wider footprint than the ambient glow so the static reads as a
# cloud the menu emerges from, faded out by its edge with a radial mask
reach = outer * 1.15
cr.push_group()
# No solid veil block: the ring itself fades in (viewport opacity ramp in
# _on_tick). Here we only lay churning static over it — dense at first,
# thinning to nothing — so the menu resolves out of noise as it fades up.
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self._HOLO_INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
for _ in range(count):
x = cx + random.uniform(-reach, reach)
y = cy + random.uniform(-reach, reach)
cr.set_source_rgba(*random.choice(colors),
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()
# bright scan band wiping down through the footprint as it resolves
band_y = cy - reach + p * (2 * reach)
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(cx - reach, band_y - 1.5, 2 * reach, 3.0)
cr.fill()
pat = cr.pop_group()
imask = cairo.RadialGradient(cx, cy, 0, cx, cy, reach)
imask.add_color_stop_rgba(0.0, 1, 1, 1, 1)
imask.add_color_stop_rgba(0.82, 1, 1, 1, 1)
imask.add_color_stop_rgba(1.0, 1, 1, 1, 0)
cr.set_source(pat)
cr.mask(imask)
def _draw_holo_outro(self, cr, cx: float, cy: float, outer: float) -> None:
"""Reverse of the intro: the ring is already fading back out (viewport
opacity ramp in _on_tick); here the static thickens from nothing as it goes,
so the menu dissolves into noise just before it vanishes."""
po = min(1.0, max(0.0, (self._holo_outro_t or 0.0) / self._HOLO_OUTRO_DURATION))
reach = outer * 1.15
cr.push_group()
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self._HOLO_INTRO_STATIC * (po ** 0.5)) # static grows as it dissolves
for _ in range(count):
x = cx + random.uniform(-reach, reach)
y = cy + random.uniform(-reach, reach)
cr.set_source_rgba(*random.choice(colors),
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_y = cy + reach - po * (2 * reach) # scan wiping back up as it dissolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
cr.rectangle(cx - reach, band_y - 1.5, 2 * reach, 3.0)
cr.fill()
pat = cr.pop_group()
imask = cairo.RadialGradient(cx, cy, 0, cx, cy, reach)
imask.add_color_stop_rgba(0.0, 1, 1, 1, 1)
imask.add_color_stop_rgba(0.82, 1, 1, 1, 1)
imask.add_color_stop_rgba(1.0, 1, 1, 1, 0)
cr.set_source(pat)
cr.mask(imask)
def _draw_hologram_content(self, cr, width: float, height: float) -> None:
r, g, b = self._HOLO_TINT
@ -633,6 +720,23 @@ class OrbitMenu(Gtk.Window):
if self._intro is not None:
self._update_intro()
if self._holo_intro_t is not None:
self._holo_intro_t += dt
if self._holo_intro_t >= self._HOLO_INTRO_DURATION:
self._holo_intro_t = None
self._viewport.set_opacity(1.0)
else:
q = self._holo_intro_t / self._HOLO_INTRO_DURATION
self._viewport.set_opacity(q * q * (3 - 2 * q)) # smooth fade in of the ring
if self._holo_outro_t is not None:
self._holo_outro_t += dt
po = min(1.0, self._holo_outro_t / self._HOLO_OUTRO_DURATION)
self._viewport.set_opacity(1.0 - po * po * (3 - 2 * po)) # fade out the ring
if self._holo_outro_t >= self._HOLO_OUTRO_DURATION:
self._finish_close()
return True # tick already removed by _finish_close
if self._hologram_enabled:
self._hologram_area.queue_draw()
return True # keep ticking every frame while the window is mapped
@ -735,6 +839,11 @@ class OrbitMenu(Gtk.Window):
slide_scale = slide_s0 + (slide_s1 - slide_s0) * eased
self._viewport.set_child_transform(slide_page, self._slide_zoom_transform(tx, ty, slide_scale))
# Make the hologram glow mask ride the slide page (the ring that becomes /
# leaves the full view), so the scanline vignette dives into the focal
# point with the submenu instead of staying pinned at canvas centre.
self._holo_cx, self._holo_cy, self._holo_scale = tx, ty, slide_scale
if zoom_page is not None:
zoom_scale = zoom_s0 + (zoom_s1 - zoom_s0) * eased
self._viewport.set_child_transform(zoom_page, self._focal_zoom_transform(fx, fy, zoom_scale))
@ -760,6 +869,9 @@ class OrbitMenu(Gtk.Window):
if tr["old"].get_parent() is not None:
self._viewport.remove(tr["old"])
self._transition = None
# settled back on the canvas-centred full view — reset the glow mask
c = self.CANVAS_SIZE / 2
self._holo_cx, self._holo_cy, self._holo_scale = c, c, 1.0
# -- opening intro: nodes materialize from holo-noise ----------------------
# Only plays when the menu transitions closed -> open (see open_at_root), never
@ -865,10 +977,23 @@ class OrbitMenu(Gtk.Window):
self._close()
def _close(self) -> None:
# Play the closing dissolve (ring fades back into static), then hide via
# _finish_close. If already dissolving, or the hologram is off / not
# ticking, just hide immediately.
if (self._hologram_enabled and self._tick_id is not None
and self._holo_outro_t is None):
self._holo_intro_t = None # cancel any in-flight opening intro
self._holo_outro_t = 0.0
else:
self._finish_close()
def _finish_close(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._holo_outro_t = None
self._viewport.set_opacity(1.0) # reset for the next open
if self._on_close:
self._on_close()
@ -899,6 +1024,8 @@ class OrbitMenu(Gtk.Window):
self._current_page = None
self._stack_origins.clear()
self._intro = None
self._holo_intro_t = None
self._holo_outro_t = None
self._root = root
def open_at_root(self) -> None:
@ -909,7 +1036,15 @@ class OrbitMenu(Gtk.Window):
on in-menu navigation."""
self._show_path((), animate=False)
self._start_intro(self._current_page)
if self._hologram_enabled:
self._holo_outro_t = None # cancel any in-flight closing dissolve
self._holo_intro_t = 0.0 # play the "materialize out of static" burst
self._viewport.set_opacity(0.0) # start hidden; _on_tick ramps it up
self.set_visible(True)
self.present()
# Reset the frame-time baseline so the first tick's dt is 0. Otherwise a
# WARM reopen would see dt = (time the menu was closed), advancing the intro
# past its whole duration in one frame — the intro would silently not play.
self._last_tick = None
if self._tick_id is None:
self._tick_id = self.add_tick_callback(self._on_tick)

View File

@ -8,6 +8,15 @@ window.orbit-menu-window {
background: transparent;
}
/* Neutralise the CyberQueer theme's `* { background-color:#1a1a1a }` on EVERY
* node the per-level Gtk.Fixed pages and the _viewport Fixed have no css class,
* so blanking by name (below) misses them and they paint an opaque dark slab over
* the whole menu. This provider is at USER+1; the node gradients survive because
* their .orbit-* rules out-specify `*`. */
* {
background-color: transparent;
}
/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea
* nodes too (unlike plain Box/Frame containers, which this GTK build's renderer
* leaves alone see lib/border.py in astal-menu for the same observation). The

View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Resident launcher for beacon, the Cosmonaut Shell notification daemon (replaces
# dunst). Same LD_PRELOAD requirement and rationale as the rest of the suite's
# start scripts: gtk4-layer-shell must load before libwayland-client, which isn't
# guaranteed under PyGObject.
APP="${HOME}/.config/beacon/main.py"
SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')"
if [[ -n "${SO:-}" ]]; then
export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}"
fi
exec python3 "$APP" "$@"

View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# orbit-menu in "classic dmenu" mode: pipe newline-delimited items on stdin, the
# radial menu opens with those items, and the chosen one is printed to stdout
# (exit 1, nothing printed, on cancel). Runs as its own throwaway process (NOT the
# resident daemon), so its stdout is the caller's.
#
# printf 'Logout\nLock\nReboot' | ~/.config/scripts/orbit-dmenu.sh
#
# Same LD_PRELOAD requirement as orbit-menu-start.sh: gtk4-layer-shell must load
# before libwayland-client, which isn't guaranteed under PyGObject.
APP="${HOME}/.config/orbit-menu/main.py"
SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')"
if [[ -n "${SO:-}" ]]; then
export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}"
fi
exec python3 "$APP" --dmenu "$@"

View File

@ -3,9 +3,13 @@
# resident daemon over D-Bus; if the daemon isn't running yet, starts it
# first. Mirrors horizon-dock.sh/orbit-menu.sh's pattern.
#
# station-bar.sh -> --toggle (default)
# station-bar.sh show -> --show
# station-bar.sh hide -> --hide
# station-bar.sh -> --toggle every monitor's bar (default)
# station-bar.sh show|hide -> --show / --hide every monitor's bar
# station-bar.sh toggle here -> toggle ONLY the bar on the focused monitor
# station-bar.sh show DP-1 -> act on a specific monitor by connector name
#
# The optional 2nd arg is a monitor target: empty = all, "here" = the currently
# focused monitor (resolved via hyprctl), or a literal connector like "HDMI-A-1".
#
# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not
# abort the script before it forwards the verb.)
@ -20,19 +24,25 @@ case "${1:-toggle}" in
*) VERB="--toggle"; ACTION="toggle" ;;
esac
TARGET="${2:-}"
if [[ "$TARGET" == "here" ]]; then
TARGET="$(hyprctl -j monitors 2>/dev/null \
| python3 -c 'import sys,json; print(next((m["name"] for m in json.load(sys.stdin) if m.get("focused")), ""))' 2>/dev/null)"
fi
registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; }
if registered; then
exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \
--method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null
--method org.gtk.Actions.Activate "$ACTION" "[<'${TARGET}'>]" "{}" >/dev/null
fi
"${HOME}/.config/scripts/station-bar-start.sh" >/dev/null 2>&1 &
for _ in $(seq 1 25); do
if registered; then
exec "$0" "$@"
exec "$0" "$1" "$TARGET"
fi
sleep 0.2
done
exec python3 "$APP" "$VERB"
exec python3 "$APP" "$VERB" "$TARGET"

View File

@ -60,20 +60,23 @@ ICON_STATION = chr(0xf1383)
ICON_SPACESHIP = chr(0xf135)
ICON_WINDOW = chr(0xf10ac)
ICON_VOLUME = chr(0xf04c3)
ICON_FUEL = chr(0xf07c5) # md-fuel — the "battery" reads as a fuel tank
class StationBar(Gtk.Window):
BAR_HEIGHT = 30
BAR_HEIGHT = 44 # a touch larger — the elements are bare glowing glyphs now
ARC_SAG = 6.0 # shallow — "narrow curved space", not horizon-dock's deep dip
BATTERY_POLL_S = 20
CLOCK_POLL_S = 1
VOLUME_POLL_S = 3 # cheap fallback so hardware volume keys still reflect promptly
def __init__(self, hologram_enabled: bool = True) -> None:
def __init__(self, hologram_enabled: bool = True, monitor=None) -> None:
super().__init__()
self.add_css_class("station-bar-window")
self._monitor = monitor
self._mon_name = self._monitor_connector()
self._width = self._monitor_width()
self._workspaces: list[dict] = []
self._active_ws: Optional[int] = None
@ -82,8 +85,13 @@ class StationBar(Gtk.Window):
self._init_layer_shell()
# Width-adaptive: no hardcoded pixel width. The layer surface is stretched
# to the monitor by the LEFT+RIGHT anchors, and _bg/content fill it via
# hexpand, so the bar adapts to any resolution (and to hotplug) with no
# captured width. Only the height is fixed.
self._bg = Gtk.DrawingArea()
self._bg.set_size_request(int(self._width), self.BAR_HEIGHT)
self._bg.set_size_request(-1, self.BAR_HEIGHT)
self._bg.set_hexpand(True)
self._bg.set_draw_func(self._draw_background)
self._bg.add_css_class("station-canvas")
@ -104,17 +112,27 @@ class StationBar(Gtk.Window):
content = Gtk.CenterBox(orientation=Gtk.Orientation.HORIZONTAL)
content.add_css_class("station-content")
content.set_size_request(int(self._width), self.BAR_HEIGHT)
content.set_hexpand(True)
content.set_valign(Gtk.Align.CENTER)
content.set_start_widget(self._left)
content.set_center_widget(self._center_label)
content.set_center_widget(self._build_center())
content.set_end_widget(self._right)
self._hologram = HologramOverlay(enabled=hologram_enabled)
self._hologram = HologramOverlay(enabled=hologram_enabled, fade_widget=content)
# The bar's height must stay BAR_HEIGHT: nerd-font button labels have tall
# line metrics, so the CenterBox's natural height would otherwise balloon
# the layer surface to ~70px while the exclusive zone still only reserves
# BAR_HEIGHT — the overflow then overlaps windows below. Pin the surface to
# _bg's height (BAR_HEIGHT), don't let the overlay children grow it, and
# clip: content is vertically centred within the thin strip.
overlay = Gtk.Overlay()
overlay.set_overflow(Gtk.Overflow.HIDDEN)
overlay.set_child(self._bg)
overlay.add_overlay(content)
overlay.set_measure_overlay(content, False)
overlay.add_overlay(self._hologram.widget)
overlay.set_measure_overlay(self._hologram.widget, False)
self.set_child(overlay)
self._build_left()
@ -128,7 +146,7 @@ class StationBar(Gtk.Window):
on_active_workspace=self._on_active_workspace,
on_active_window=self._on_active_window,
)
self._active_ws = hypr_ipc.initial_active_workspace_id()
self._active_ws = self._derive_active_ws()
self._refresh_workspaces()
self._on_active_window(hypr_ipc.initial_active_window_title())
@ -141,6 +159,7 @@ class StationBar(Gtk.Window):
self.set_visible(True)
self._ensure_tick()
self._hologram.start_intro() # materialise on first appearance too
# -- hologram animation loop -----------------------------------------------
def _on_tick(self, _widget, frame_clock) -> bool:
@ -166,28 +185,47 @@ class StationBar(Gtk.Window):
LayerShell.set_layer(self, LayerShell.Layer.TOP)
LayerShell.set_namespace(self, "station-bar")
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE)
if self._monitor is not None:
LayerShell.set_monitor(self, self._monitor)
for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.TOP):
LayerShell.set_anchor(self, edge, True)
LayerShell.set_margin(self, edge, 0)
LayerShell.set_exclusive_zone(self, self.BAR_HEIGHT)
def _monitor_connector(self) -> Optional[str]:
"""The Gdk connector name (e.g. "DP-1"), which equals the Hyprland
monitor `name` so it keys this bar's workspaces/active out of the
global lists. None (unknown monitor) falls back to showing everything."""
mon = self._monitor
if mon is None:
return None
try:
return mon.get_connector()
except Exception:
return None
def _monitor_width(self) -> int:
display = Gdk.Display.get_default()
monitors = display.get_monitors() if display is not None else None
mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None
mon = self._monitor
if mon is None:
display = Gdk.Display.get_default()
monitors = display.get_monitors() if display is not None else None
mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None
# Gdk logical width (already scaled), the coordinate space layer-shell
# lays out in.
return mon.get_geometry().width if mon is not None else 1920
# -- background curve -------------------------------------------------------
def _arc_radius(self) -> float:
half_w = self._width / 2
def _arc_radius(self, width: float) -> float:
half_w = width / 2
sag = self.ARC_SAG
return (sag * sag + half_w * half_w) / (2 * sag)
def _curve_dip_at(self, x: float) -> float:
def _curve_dip_at(self, x: float, width: float) -> float:
"""0 at the bar's center, rising to ARC_SAG at its edges — same shape as
horizon-dock's row arcs, so the bar reads as one shallow shared curve."""
dx = x - self._width / 2
r = self._arc_radius()
horizon-dock's row arcs, so the bar reads as one shallow shared curve.
Uses the live allocated width so it adapts to any monitor."""
dx = x - width / 2
r = self._arc_radius(width)
return r - math.sqrt(max(r * r - dx * dx, 0.0))
def _draw_background(self, _area, cr, width: float, height: float) -> None:
@ -195,11 +233,11 @@ class StationBar(Gtk.Window):
cr.save()
cr.set_source_rgba(*_VIOLET, 0.22)
cr.set_line_width(1.2)
cr.move_to(0, base_y + self._curve_dip_at(0))
cr.move_to(0, base_y + self._curve_dip_at(0, width))
steps = 64
for s in range(1, steps + 1):
x = width * s / steps
cr.line_to(x, base_y + self._curve_dip_at(x))
cr.line_to(x, base_y + self._curve_dip_at(x, width))
cr.stroke()
cr.restore()
@ -207,23 +245,26 @@ class StationBar(Gtk.Window):
def _build_left(self) -> None:
self._battery_widget = Gtk.Label()
self._battery_widget.add_css_class("station-badge")
self._battery_widget.add_css_class("station-battery")
self._battery_widget.set_visible(False)
self._left.append(self._battery_widget)
orbit_btn = self._make_launcher(ICON_ORBIT, "Orbit Menu",
orbit_btn = self._make_launcher(ICON_ORBIT, "Orbit Menu", "station-orbit",
["bash", "-c", "$HOME/.config/scripts/orbit-menu.sh menu"])
astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu",
astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu", "station-astro",
["bash", "-c", "$HOME/.config/scripts/astro-menu.sh toggle top"])
self._left.append(orbit_btn)
self._left.append(astro_btn)
self._ws_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
self._ws_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._ws_row.add_css_class("station-ws-row")
self._left.append(self._ws_row)
def _make_launcher(self, icon: str, tooltip: str, argv: list[str]) -> Gtk.Button:
def _make_launcher(self, icon: str, tooltip: str, css_class: str, argv: list[str]) -> Gtk.Button:
btn = Gtk.Button(label=icon)
btn.add_css_class("station-planet")
btn.add_css_class("station-launcher")
btn.add_css_class(css_class)
btn.set_has_frame(False)
btn.set_tooltip_text(tooltip)
btn.connect("clicked", lambda *_a: subprocess.Popen(
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
@ -231,8 +272,20 @@ class StationBar(Gtk.Window):
def _refresh_workspaces(self) -> None:
self._workspaces = hypr_ipc.initial_workspaces()
self._active_ws = self._derive_active_ws()
self._rebuild_workspace_row()
def _derive_active_ws(self) -> Optional[int]:
"""This monitor's own active workspace (from `hyprctl monitors`), not the
globally-focused one so each bar highlights the workspace its monitor is
actually showing, even while another monitor holds keyboard focus."""
if self._mon_name is None:
return hypr_ipc.initial_active_workspace_id()
for mon in hypr_ipc.initial_monitors():
if mon.get("name") == self._mon_name:
return (mon.get("activeWorkspace") or {}).get("id")
return None
def _rebuild_workspace_row(self) -> None:
child = self._ws_row.get_first_child()
while child is not None:
@ -244,18 +297,90 @@ class StationBar(Gtk.Window):
wid = ws.get("id")
if not isinstance(wid, int) or wid < 0:
continue # special:* workspaces aren't shown as numbered stations
if self._mon_name is not None and ws.get("monitor") != self._mon_name:
continue # this bar only shows its own monitor's workspaces
selected = wid == self._active_ws
btn = Gtk.Button(label=f"{ICON_SPACESHIP if selected else ICON_STATION}{wid}")
has_windows = ws.get("windows", 0) > 0
# A populated workspace is a "station" (its glyph); an empty one shows
# just the bare spaceship + number instead — never hidden. The focused
# workspace is framed by the reticle, with the rocket prepended when
# there's a station to dock at (skipped when empty, so no double ship).
base = ICON_STATION if has_windows else ICON_SPACESHIP
if selected:
prefix = ICON_SPACESHIP if has_windows else ""
label = f"-< {prefix}{base}{wid} >-"
else:
label = f"{base}{wid}"
btn = Gtk.Button(label=label)
btn.add_css_class("station-node")
btn.set_has_frame(False)
if selected:
btn.add_css_class("station-node-active")
btn.connect("clicked", lambda *_a, w=wid: hypr_ipc.focus_workspace(w))
self._ws_row.append(btn)
def _on_active_workspace(self, ws_id: int) -> None:
self._active_ws = ws_id
def _on_active_workspace(self, _ws_id: int) -> None:
# the `workspace` event only names the focused monitor's new workspace;
# re-derive from `monitors` so this bar reflects its own monitor's active.
self._active_ws = self._derive_active_ws()
self._rebuild_workspace_row()
# -- center: focused window title in a clickable trapezoid ------------------
def _build_center(self) -> Gtk.Widget:
"""The window title inside a faint elongated trapezoid (wider at the top,
which is left un-bordered) that opens the Astro Menu when clicked a
little 'viewport screen' framing the title, like a HUD readout panel."""
trap = Gtk.DrawingArea()
trap.set_draw_func(self._draw_center_trap)
trap.set_can_target(False)
self._center_label.set_margin_start(30)
self._center_label.set_margin_end(30)
self._center_label.set_margin_top(3)
self._center_label.set_margin_bottom(4)
overlay = Gtk.Overlay()
overlay.add_css_class("station-center")
# hug the title: size to the label and stay centred (no hexpand, or the
# CenterBox would stretch the trapezoid to fill the whole middle zone).
overlay.set_halign(Gtk.Align.CENTER)
overlay.set_child(trap)
overlay.add_overlay(self._center_label)
overlay.set_measure_overlay(self._center_label, True)
click = Gtk.GestureClick()
click.connect("released", lambda *_a: subprocess.Popen(
["bash", "-c", "$HOME/.config/scripts/astro-menu.sh toggle top"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
overlay.add_controller(click)
cursor = Gdk.Cursor.new_from_name("pointer", None)
if cursor is not None:
overlay.set_cursor(cursor)
overlay.set_tooltip_text("Astro Menu")
return overlay
def _draw_center_trap(self, _area, cr, width: float, height: float) -> None:
if width <= 0 or height <= 0:
return
inset = min(height * 0.85, width * 0.10) # bottom is the shorter side
# faint translucent fill (top edge included, just not stroked)
cr.move_to(0, 0)
cr.line_to(width, 0)
cr.line_to(width - inset, height)
cr.line_to(inset, height)
cr.close_path()
cr.set_source_rgba(*_VIOLET, 0.14)
cr.fill()
# borders on left / bottom / right only — the (longer) top edge stays open
for lw, a in ((5.0, 0.05), (1.6, 0.34)):
cr.move_to(0, 0)
cr.line_to(inset, height)
cr.line_to(width - inset, height)
cr.line_to(width, 0)
cr.set_source_rgba(*_ACCENT, a)
cr.set_line_width(lw)
cr.stroke()
# -- center: focused window title -------------------------------------------
def _on_active_window(self, title: str) -> None:
text = title.strip()
@ -277,6 +402,8 @@ class StationBar(Gtk.Window):
self._volume_btn = Gtk.Button(label=f"{ICON_VOLUME} --%")
self._volume_btn.add_css_class("station-badge")
self._volume_btn.add_css_class("station-volume")
self._volume_btn.set_has_frame(False)
self._volume_btn.connect("clicked", lambda *_a: (volume_source.toggle_mute(), self._refresh_volume()))
scroll = Gtk.EventControllerScroll()
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
@ -286,6 +413,7 @@ class StationBar(Gtk.Window):
self._clock_label = Gtk.Label()
self._clock_label.add_css_class("station-badge")
self._clock_label.add_css_class("station-clock")
self._right.append(self._clock_label)
def _draw_station_pictogram(self, _, cr, w: float, h: float) -> None:
@ -314,8 +442,11 @@ class StationBar(Gtk.Window):
if state is None:
self._battery_widget.set_visible(False)
else:
# the ship runs on "fuel", not a battery — a fixed fuel-tank glyph in
# place of the charge-level battery icon (only shown when a battery
# actually exists, e.g. on a laptop).
self._battery_widget.set_visible(True)
self._battery_widget.set_label(f"{state.icon} {state.percent}%")
self._battery_widget.set_label(f"{ICON_FUEL} {state.percent}%")
return True
def _refresh_volume(self) -> bool:
@ -344,8 +475,16 @@ class StationBar(Gtk.Window):
self.set_visible(True)
self.present()
self._ensure_tick()
self._hologram.start_intro()
def hide_bar(self) -> None:
# Dissolve into static first, then release the exclusive zone + 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:
LayerShell.set_exclusive_zone(self, 0)
self.set_visible(False)
self._stop_tick()

View File

@ -55,6 +55,15 @@ def initial_active_workspace_id() -> Optional[int]:
return data.get("id") if isinstance(data, dict) else None
def initial_monitors() -> list[dict]:
"""Each monitor dict carries `name` (the connector, e.g. "DP-1", matching a
Gdk.Monitor's connector) and `activeWorkspace: {id, name}` — the workspace
that monitor is currently showing, independent of which monitor has focus.
Used to give each per-monitor bar its own workspace list and active-highlight."""
data = _hyprctl_json("monitors")
return data if isinstance(data, list) else []
def initial_active_window_title() -> str:
data = _hyprctl_json("activewindow")
return (data.get("title") or "") if isinstance(data, dict) else ""
@ -122,7 +131,13 @@ class HyprIPC:
self._on_active_workspace(int(payload))
except ValueError:
pass # special:* workspaces aren't shown as numbered stations
elif event in ("createworkspace", "destroyworkspace", "moveworkspace"):
elif event in ("createworkspace", "destroyworkspace", "moveworkspace",
"openwindow", "closewindow", "movewindow", "movewindowv2",
"focusedmon"):
# any of these can change a workspace's window count OR which monitor
# a workspace lives on (moveworkspace) OR a monitor's active workspace
# (focusedmon) — all of which per-monitor bars (bar.py) filter on, so
# re-fetch the list + re-derive each bar's active from `monitors`.
self._on_workspaces_changed()
elif event == "activewindow":
_cls, _, title = payload.partition(",")

View File

@ -19,7 +19,7 @@ import cairo
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
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)
@ -33,16 +33,39 @@ class HologramOverlay:
SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass
SWEEP_HALF_HEIGHT = 24.0 # much shorter than the popups' — the bar itself is only ~30px tall
NOISE_COUNT = 40 # thinner strip than a popup panel, so fewer specs at once
NOISE_COLORS = [_MAGENTA, _ACCENT]
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) -> None:
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
# Wall-clock safety net for the intro: the frame-clock tick that normally
# advances the intro only fires while the compositor sends frame callbacks.
# When the bar is (re)started into an otherwise-idle compositor those can
# stall, freezing the intro at t=0 — i.e. the bar sits in permanent static
# with content stuck at opacity 0. This timeout force-resolves the intro on
# real time regardless, so content always appears.
self._intro_deadline_id: int | None = None
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
@ -57,12 +80,162 @@ class HologramOverlay:
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)) # 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 _finish_intro(self) -> None:
"""End the intro: clear its state, cancel the safety timeout, and make the
content fully opaque. Safe to call from either the tick or the timeout."""
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() # in case the frame clock has stalled
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
# Wall-clock backstop: if frame callbacks stall, force the intro done a
# little past its nominal length so content can never get stuck hidden.
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:
"""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
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 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()

View File

@ -28,55 +28,108 @@ 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
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, Gio, GLib, Gtk # noqa: E402
import config # noqa: E402
import theme # noqa: E402
from bar import StationBar # noqa: E402
from paths import APP_ID # noqa: E402
from sni_watcher import SniWatcher # noqa: E402
class StationBarApp(Gtk.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID,
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
self.window: StationBar | None = None
# One bar per monitor, keyed by connector name so hotplugged monitors can
# be matched on the "items-changed" signal.
self.bars: dict[str, StationBar] = {}
self._hologram = True
self._visible = True
self._sni_watcher: SniWatcher | None = None
def do_startup(self) -> None:
Gtk.Application.do_startup(self)
theme.load_css()
self.window = StationBar(hologram_enabled=config.hologram_enabled())
# Provide the StatusNotifierWatcher ourselves (no DE does it here), so tray
# apps have something to register with and the bars' tray fills up.
self._sni_watcher = SniWatcher()
self._hologram = config.hologram_enabled()
display = Gdk.Display.get_default()
if display is not None:
monitors = display.get_monitors()
monitors.connect("items-changed", lambda *_a: self._sync_bars())
self._sync_bars()
self._register_actions()
self.hold() # stay alive even while the bar is briefly hidden
self.hold() # stay alive even while the bars are briefly hidden
def _sync_bars(self) -> None:
"""Build a bar for every current monitor (and drop bars for monitors that
went away) a single layer-shell window only ever lands on one output, so
a per-monitor bar is the only way to get one on every screen."""
display = Gdk.Display.get_default()
if display is None:
return
monitors = display.get_monitors()
seen: set[str] = set()
for i in range(monitors.get_n_items()):
mon = monitors.get_item(i)
conn = (mon.get_connector() if mon is not None else None) or f"mon{i}"
seen.add(conn)
if conn not in self.bars:
bar = StationBar(hologram_enabled=self._hologram, monitor=mon)
if not self._visible:
bar.hide_bar()
self.bars[conn] = bar
for conn in list(self.bars):
if conn not in seen:
self.bars.pop(conn).destroy()
def _apply(self, verb: str, target: str = "") -> None:
"""target="" acts on every monitor's bar (global); a connector name acts
only on that monitor's bar (Super+Z toggles the bar you're looking at)."""
if target:
bars = [self.bars[target]] if target in self.bars else []
else:
bars = list(self.bars.values())
if verb == "show":
self._visible = True
elif verb == "hide":
self._visible = False
elif verb == "toggle":
self._visible = not self._visible
for bar in bars:
if verb == "show":
bar.show_bar()
elif verb == "hide":
bar.hide_bar()
elif verb == "toggle":
# per-monitor toggle keys off that bar's own state; the global
# toggle drives every bar from the shared _visible flag.
bar.toggle() if target else (bar.show_bar() if self._visible else bar.hide_bar())
def _register_actions(self) -> None:
def add(name: str, callback) -> None:
action = Gio.SimpleAction.new(name, None)
action.connect("activate", callback)
stype = GLib.VariantType.new("s")
for name in ("show", "hide", "toggle"):
action = Gio.SimpleAction.new(name, stype)
action.connect(
"activate",
lambda _a, p, n=name: self._apply(n, p.get_string() if p is not None else ""),
)
self.add_action(action)
def guarded(fn):
def wrapper(*_a) -> None:
assert self.window is not None
fn(self.window)
return wrapper
add("show", guarded(lambda w: w.show_bar()))
add("hide", guarded(lambda w: w.hide_bar()))
add("toggle", guarded(lambda w: w.toggle()))
def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
args = list(cmdline.get_arguments()[1:])
verb = args[0] if args else "--daemon"
if self.window is None:
return 0
target = args[1] if len(args) > 1 else ""
if verb == "--show":
self.window.show_bar()
self._apply("show", target)
elif verb == "--hide":
self.window.hide_bar()
self._apply("hide", target)
elif verb == "--toggle":
self.window.toggle()
# --daemon and anything else: no-op (bar already shown by do_startup)
self._apply("toggle", target)
# --daemon and anything else: no-op (bars already shown by do_startup)
return 0
def do_activate(self) -> None:

View File

@ -0,0 +1,147 @@
"""A minimal StatusNotifierWatcher (org.kde.StatusNotifierWatcher).
Under a full desktop this bus name is provided by the panel/DE; hyprdrive has no
such component (we replaced waybar/eww), so tray apps have nowhere to register
and every SNI host including our own bar's tray.py — sees an empty tray. This
owns the name, tracks registered items/hosts, and emits the registration signals
+ PropertiesChanged so hosts stay live as apps come and go.
Kept deliberately small: it does not proxy the items themselves (the host talks
to each StatusNotifierItem directly), it just is the registry the spec requires.
"""
from __future__ import annotations
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
_NAME = "org.kde.StatusNotifierWatcher"
_PATH = "/StatusNotifierWatcher"
_IFACE = "org.kde.StatusNotifierWatcher"
_XML = f"""
<node>
<interface name="{_IFACE}">
<method name="RegisterStatusNotifierItem">
<arg name="service" type="s" direction="in"/>
</method>
<method name="RegisterStatusNotifierHost">
<arg name="service" type="s" direction="in"/>
</method>
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
<signal name="StatusNotifierItemRegistered"><arg name="service" type="s"/></signal>
<signal name="StatusNotifierItemUnregistered"><arg name="service" type="s"/></signal>
<signal name="StatusNotifierHostRegistered"/>
<signal name="StatusNotifierHostUnregistered"/>
</interface>
</node>
"""
class SniWatcher:
def __init__(self) -> None:
self._conn: Gio.DBusConnection | None = None
self._items: dict[str, str] = {} # "service/path" -> owner unique name
self._hosts: set[str] = set()
self._reg_id = 0
# NAME_OWNER flags let a real DE watcher take over cleanly if one appears.
self._owner_id = Gio.bus_own_name(
Gio.BusType.SESSION, _NAME,
Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT,
self._on_bus_acquired, None, self._on_name_lost,
)
# -- bus lifecycle -----------------------------------------------------
def _on_bus_acquired(self, conn: Gio.DBusConnection, _name: str) -> None:
self._conn = conn
info = Gio.DBusNodeInfo.new_for_xml(_XML).interfaces[0]
try:
self._reg_id = conn.register_object(
_PATH, info, self._on_method, self._on_get_property, None)
except GLib.Error:
return
# drop items whose owner leaves the bus
conn.signal_subscribe(
"org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged",
"/org/freedesktop/DBus", None, Gio.DBusSignalFlags.NONE,
self._on_name_owner_changed)
def _on_name_lost(self, _conn, _name) -> None:
# another watcher grabbed the name — step aside quietly
self._conn = None
# -- method / property handlers ---------------------------------------
def _on_method(self, conn, sender, _path, _iface, method, params, invocation) -> None:
if method == "RegisterStatusNotifierItem":
self._register_item(sender, params.unpack()[0])
invocation.return_value(None)
elif method == "RegisterStatusNotifierHost":
self._hosts.add(params.unpack()[0])
conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierHostRegistered", None)
self._emit_props({"IsStatusNotifierHostRegistered": GLib.Variant("b", True)})
invocation.return_value(None)
else:
invocation.return_value(None)
def _on_get_property(self, _conn, _sender, _path, _iface, prop):
if prop == "RegisteredStatusNotifierItems":
return GLib.Variant("as", list(self._items.keys()))
if prop == "IsStatusNotifierHostRegistered":
return GLib.Variant("b", bool(self._hosts))
if prop == "ProtocolVersion":
return GLib.Variant("i", 0)
return None
# -- registry bookkeeping ---------------------------------------------
@staticmethod
def _entry_for(sender: str, service: str) -> str:
# apps register either an object path (use the caller as the service),
# a full "busname/path", or just a bus name (default item path).
if service.startswith("/"):
return sender + service
if "/" in service:
return service
return service + "/StatusNotifierItem"
def _register_item(self, sender: str, service: str) -> None:
if self._conn is None:
return
entry = self._entry_for(sender, service)
if entry in self._items:
return
self._items[entry] = sender
self._conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierItemRegistered",
GLib.Variant("(s)", (entry,)))
self._emit_items_changed()
def _on_name_owner_changed(self, _conn, _sender, _path, _iface, _signal, params) -> None:
name, _old, new_owner = params.unpack()
if new_owner:
return # a name was acquired, not lost
gone = [e for e, owner in self._items.items()
if owner == name or e.split("/", 1)[0] == name]
for e in gone:
del self._items[e]
if self._conn is not None:
self._conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierItemUnregistered",
GLib.Variant("(s)", (e,)))
if name in self._hosts:
self._hosts.discard(name)
if gone:
self._emit_items_changed()
# -- signalling --------------------------------------------------------
def _emit_items_changed(self) -> None:
self._emit_props({"RegisteredStatusNotifierItems":
GLib.Variant("as", list(self._items.keys()))})
def _emit_props(self, changed: dict) -> None:
if self._conn is None:
return
self._conn.emit_signal(
None, _PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (_IFACE, changed, [])))

View File

@ -8,9 +8,18 @@
* of a solid strip.
*/
/* Glow palette bright, saturated variants that read as emitted light over the
* dark/blurred strip (the raw @violet is too dark for glowing text). Added in
* this process only, so the rest of the suite keeps its palette. */
@define-color glow_violet #8A5CFF;
@define-color glow_magenta #EB00A6;
@define-color glow_cyan #22D3EE;
@define-color glow_green #2BE08A;
@define-color glow_amber #F5A623;
* {
font-family: "Agave Nerd Font Mono", monospace;
font-size: 11pt;
font-size: 13pt;
}
window,
@ -28,65 +37,73 @@ drawingarea {
background: transparent;
}
/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }` on
* every node enumerating node names above misses some (labels, the CenterBox
* boxes). This provider is at USER+1, above the theme; interactive elements
* re-assert their own fills via the higher-specificity .class rules below. */
* {
background-color: transparent;
}
/* Everything on the bar is now a bare glowing glyph no pills, borders, fills
* or box-shadows. Each element type is a distinct colour and glows via
* text-shadow, like the satellites orbiting an orbit-menu node. */
.station-launcher,
.station-node,
button.station-badge,
.station-badge {
background: none;
background-color: transparent;
border: none;
box-shadow: none;
min-width: 0;
min-height: 0;
padding: 0 5px;
}
.station-title {
color: @text;
opacity: 0.9;
opacity: 0.95;
font-size: 12pt;
text-shadow: 0 0 7px alpha(@glow_violet, 0.7);
}
/* generic small readout pill — battery / volume / clock */
.station-badge {
color: @text;
background: transparent;
border: none;
padding: 1px 6px;
min-height: 20px;
}
button.station-badge {
transition: color 160ms ease;
}
button.station-badge:hover {
color: @accent;
}
/* readouts — battery / volume / clock, each its own colour */
.station-badge { color: @text; transition: text-shadow 160ms ease; }
.station-battery { color: @glow_green; text-shadow: 0 0 7px alpha(@glow_green, 0.85); }
.station-volume { color: @glow_cyan; text-shadow: 0 0 7px alpha(@glow_cyan, 0.85); }
.station-clock { color: @glow_amber; text-shadow: 0 0 7px alpha(@glow_amber, 0.85); }
.station-volume:hover { text-shadow: 0 0 13px @glow_cyan; }
/* Orbit / Astro launcher buttons small planet nodes, same glow language as
* orbit-menu's/horizon-dock's .horizon-planet / node styling. */
.station-planet {
color: @accent;
background: alpha(@violet, 0.4);
border: 2px solid @violet;
border-radius: 50%;
min-width: 22px;
min-height: 22px;
padding: 0;
transition: border-color 160ms ease, box-shadow 200ms ease;
box-shadow: 0 0 0 0 alpha(@accent, 0);
}
.station-planet:hover {
border-color: @accent;
box-shadow: 0 0 10px 1px alpha(@accent, 0.5);
/* Orbit / Astro launchers — larger glowing icon glyphs */
.station-launcher {
font-size: 17pt;
padding: 0 7px;
transition: text-shadow 160ms ease, color 160ms ease;
}
.station-orbit { color: @glow_violet; text-shadow: 0 0 9px alpha(@glow_violet, 0.9); }
.station-astro { color: @accent; text-shadow: 0 0 9px alpha(@accent, 0.9); }
.station-orbit:hover { text-shadow: 0 0 16px @glow_violet, 0 0 5px @glow_violet; }
.station-astro:hover { text-shadow: 0 0 16px @accent, 0 0 5px @accent; }
/* workspace "stations" row */
.station-ws-row { padding: 0 2px; }
.station-ws-row { padding: 0 4px; }
.station-node {
color: @text;
background: transparent;
border: 1px solid alpha(@violet, 0.6);
border-radius: 10px;
padding: 0 6px;
min-height: 20px;
transition: border-color 160ms ease, color 160ms ease, box-shadow 200ms ease;
box-shadow: 0 0 0 0 alpha(@accent, 0);
color: alpha(@glow_violet, 0.9);
font-size: 13pt;
text-shadow: 0 0 6px alpha(@glow_violet, 0.6);
transition: color 160ms ease, text-shadow 160ms ease;
}
.station-node:hover {
border-color: @accent;
box-shadow: 0 0 8px 1px alpha(@accent, 0.4);
color: @glow_violet;
text-shadow: 0 0 11px @glow_violet;
}
/* the focused workspace — brightest, magenta, reticle-framed in bar.py */
.station-node-active {
color: @bg;
background: @accent;
border-color: @accent;
box-shadow: 0 0 10px 1px alpha(@accent, 0.55);
color: @glow_magenta;
font-weight: 700;
font-size: 14pt;
text-shadow: 0 0 13px @glow_magenta, 0 0 4px @glow_magenta;
}
/* tray pod — the drawn space-station pictogram + its icon row */
@ -103,3 +120,36 @@ button.station-badge:hover {
opacity: 0.85;
}
.station-tray-item:hover { opacity: 1; }
/* Right-click context menu, rendered by us from the item's com.canonical.dbusmenu
* (tray.py). Themed to match the suite: translucent violet-charcoal glass, violet
* frame, accent on the hovered/active row. */
.station-tray-menu > contents,
.station-tray-menu > arrow {
background-color: alpha(@bg, 0.92);
border: 1px solid alpha(@violet, 0.85);
border-radius: 12px;
box-shadow: 0 0 18px alpha(@violet, 0.35);
}
.station-tray-menu modelbutton {
border-radius: 8px;
padding: 4px 10px;
color: @text;
min-height: 22px;
}
.station-tray-menu modelbutton:hover {
background-color: alpha(@violet, 0.35);
color: #F0E6EA;
}
.station-tray-menu modelbutton:active,
.station-tray-menu modelbutton:checked {
background-color: alpha(@accent, 0.40);
}
.station-tray-menu separator {
background-color: alpha(@violet, 0.35);
min-height: 1px;
margin: 3px 6px;
}
.station-tray-menu modelbutton:disabled {
color: alpha(@text, 0.45);
}

View File

@ -27,22 +27,31 @@ class TrayHost:
self._on_change = on_change
self._watcher: Optional[Gio.DBusProxy] = None
self._registered_host = False
self._connect()
# Watch the name rather than connecting once: our own SniWatcher may not
# own it yet at construction, and this also recovers if the watcher (ours
# or a real DE's) restarts.
self._watch_id = Gio.bus_watch_name(
Gio.BusType.SESSION, _WATCHER_BUS, Gio.BusNameWatcherFlags.NONE,
self._on_watcher_appeared, self._on_watcher_vanished)
def _connect(self) -> None:
def _on_watcher_appeared(self, _conn, _name, _owner) -> None:
try:
proxy = Gio.DBusProxy.new_for_bus_sync(
self._watcher = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
_WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None,
)
self._watcher = proxy if proxy.get_name_owner() is not None else None
except GLib.Error:
self._watcher = None
return
if self._watcher is None:
return
self._watcher.connect("g-properties-changed", lambda *_a: self._on_change())
self._registered_host = False
self._ensure_host_registered()
self._on_change()
def _on_watcher_vanished(self, _conn, _name) -> None:
self._watcher = None
self._registered_host = False
self._on_change()
def _ensure_host_registered(self) -> None:
if self._registered_host or self._watcher is None:
@ -108,7 +117,8 @@ class TrayHost:
btn.connect("clicked", lambda *_a, p=proxy: self._activate(p))
right_click = Gtk.GestureClick(button=3)
right_click.connect("pressed", lambda *_a, p=proxy: self._context_menu(p))
right_click.connect("pressed",
lambda *_a, p=proxy, b=btn: self._context_menu(p, b))
btn.add_controller(right_click)
return btn
@ -122,7 +132,112 @@ class TrayHost:
proxy.call("Activate", GLib.Variant("(ii)", (0, 0)),
Gio.DBusCallFlags.NONE, -1, None, None, None)
# -- context menu (com.canonical.dbusmenu) --------------------------------
#
# SNI items don't hand us a positioned menu — most expose a dbusmenu object
# (the `Menu` property) that the HOST is expected to render itself. The old
# code just called the item's ContextMenu(0,0); apps that implement it (e.g.
# Discord) popped their menu at screen (0,0) — top-left — and apps that only
# do dbusmenu (nm-applet, blueman) got nothing at all. So instead we fetch
# the dbusmenu layout and render it in a GTK popover anchored to the icon.
def _context_menu(self, proxy: Gio.DBusProxy, button: Gtk.Button) -> None:
menu_path = self._prop_str(proxy, "Menu")
if not menu_path:
# No dbusmenu — last-ditch: ask the item to show its own context menu.
proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)),
Gio.DBusCallFlags.NONE, -1, None, None, None)
return
try:
dm = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
proxy.get_name(), menu_path, "com.canonical.dbusmenu", None,
)
except GLib.Error:
return
# Best-effort: let the app refresh its menu before we read it.
try:
dm.call_sync("AboutToShow", GLib.Variant("(i)", (0,)),
Gio.DBusCallFlags.NONE, 800, None)
except GLib.Error:
pass
try:
res = dm.call_sync("GetLayout", GLib.Variant("(iias)", (0, -1, [])),
Gio.DBusCallFlags.NONE, 1500, None)
except GLib.Error:
return
_revision, layout = res.unpack()
actions = Gio.SimpleActionGroup()
model = self._build_model(dm, layout, actions)
popover = Gtk.PopoverMenu.new_from_model(model)
popover.add_css_class("station-tray-menu")
popover.set_parent(button)
popover.set_position(Gtk.PositionType.BOTTOM)
popover.insert_action_group("traymenu", actions)
# Keep a ref while shown, and tear the surface down cleanly on close.
self._open_popover = popover
popover.connect("closed", self._on_popover_closed)
popover.popup()
def _on_popover_closed(self, popover: Gtk.Popover) -> None:
popover.unparent()
if getattr(self, "_open_popover", None) is popover:
self._open_popover = None
def _build_model(self, dm: Gio.DBusProxy, node, actions: Gio.SimpleActionGroup) -> Gio.Menu:
"""Turn a dbusmenu (id, props, children) node into a Gio.Menu. Separators
split the run into sections (PopoverMenu draws a divider between them)."""
menu = Gio.Menu()
section = Gio.Menu()
def flush() -> None:
nonlocal section
if section.get_n_items() > 0:
menu.append_section(None, section)
section = Gio.Menu()
for child in node[2]:
cid, props, _kids = child
if not props.get("visible", True):
continue
if props.get("type") == "separator":
flush()
continue
label = props.get("label", "") or ""
if props.get("children-display") == "submenu":
item = Gio.MenuItem.new(label, None)
item.set_submenu(self._build_model(dm, child, actions))
section.append_item(item)
continue
aname = f"i{cid}"
if props.get("toggle-type") in ("checkmark", "radio"):
state = GLib.Variant("b", bool(props.get("toggle-state", 0)))
act = Gio.SimpleAction.new_stateful(aname, None, state)
else:
act = Gio.SimpleAction.new(aname, None)
act.set_enabled(bool(props.get("enabled", True)))
act.connect("activate", lambda _a, _p, i=cid: self._menu_event(dm, i))
actions.add_action(act)
item = Gio.MenuItem.new(label, f"traymenu.{aname}")
icon_name = props.get("icon-name")
if icon_name:
try:
item.set_icon(Gio.ThemedIcon.new(icon_name))
except GLib.Error:
pass
section.append_item(item)
flush()
return menu
@staticmethod
def _context_menu(proxy: Gio.DBusProxy) -> None:
proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)),
Gio.DBusCallFlags.NONE, -1, None, None, None)
def _menu_event(dm: Gio.DBusProxy, item_id: int) -> None:
dm.call("Event",
GLib.Variant("(isvu)", (item_id, "clicked", GLib.Variant("i", 0), 0)),
Gio.DBusCallFlags.NONE, -1, None, None, None)