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>
main
Amir Alexander Abdelbaki 2026-07-18 17:17:48 +02:00
parent f48c70273a
commit 8dfd505ce4
18 changed files with 907 additions and 112 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

@ -42,15 +42,24 @@ class HologramOverlay:
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 = 0.6 # 'materialise out of static' opening animation
INTRO_STATIC = 520 # static specks at the very start of the intro
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
INTRO_STATIC = 1200 # static specks at the very start of the intro
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
def __init__(self, enabled: bool = True, clip_func=None) -> 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()
@ -70,12 +79,42 @@ class HologramOverlay:
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:
@ -94,6 +133,8 @@ class HologramOverlay:
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()
@ -127,20 +168,42 @@ class HologramOverlay:
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
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
cr.paint()
# 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)]
for _ in range(int(self.INTRO_STATIC * strength)):
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.8) * (0.4 + 0.6 * strength))
sz = random.uniform(1.0, 2.4)
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 - 1.5, width, 3.0)
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:

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

@ -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

@ -114,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 {

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="")
@ -208,6 +208,14 @@ class MenuWindow(Gtk.ApplicationWindow):
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

@ -23,6 +23,7 @@ from __future__ import annotations
import json
import math
import random
import subprocess
import time
from typing import Callable, Optional
@ -33,10 +34,8 @@ 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, GLib, 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
@ -77,13 +76,14 @@ class HorizonDock(Gtk.Window):
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__()
@ -101,12 +101,17 @@ class HorizonDock(Gtk.Window):
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()
@ -139,7 +144,8 @@ class HorizonDock(Gtk.Window):
clip.add_overlay(self._viewport)
clip.set_measure_overlay(self._viewport, False)
self._hologram = HologramOverlay(enabled=hologram_enabled, clip_func=self._holo_clip)
self._hologram = HologramOverlay(enabled=hologram_enabled, clip_func=self._holo_clip,
fade_widget=self._content, intro_duration=2.4)
overlay = Gtk.Overlay()
overlay.set_child(clip)
overlay.add_overlay(self._hologram.widget)
@ -348,6 +354,7 @@ class HorizonDock(Gtk.Window):
return best
def _on_motion(self, _ctrl, x: float, y: float) -> None:
self._mouse_x, self._mouse_y = x, y # drives the ornamental satellites
row = self._row_at(x, y)
if row != self._hovered_row:
self._hovered_row = row
@ -372,7 +379,7 @@ class HorizonDock(Gtk.Window):
self._draw_node(cr)
for row in self.ROW_ORDER:
self._draw_ring(cr, row, hovered=(row == self._hovered_row))
self._draw_orbit_glyphs(cr, row)
self._draw_satellites(cr)
self._draw_center_sphere(cr)
def _front_arc_path(self, cr, rx: float, ry: float, close_on_horizon: bool) -> None:
@ -435,98 +442,160 @@ class HorizonDock(Gtk.Window):
self._ring_ry("apps") + pad, close_on_horizon=True)
def _draw_center_sphere(self, cr) -> None:
"""The 'planet': a glowing sphere sitting on the surface below the orbits,
displaying the date and time. Sits in the clear central column so the icon
rows don't cover the readout."""
"""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()
# Sit in the clear band BELOW the lowest icon arch (windows) so the icon
# rows never cover the readout.
# Clear band below the lowest icon arch (windows); the readout lives here.
band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2
cy = (band_top + base) / 2
rad = max(26.0, min((base - band_top) / 2 - 2, 40.0))
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()
grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.1, cx, cy, rad)
grad.add_color_stop_rgba(0.0, *_VIOLET, 0.9)
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.4)
# 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()
for lw, a in ((11.0, 0.06), (5.5, 0.12), (2.4, 0.7)):
# 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
# 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(rad * 0.62)
cr.set_font_size(vis_h * 0.40)
ext = cr.text_extents(clock)
cr.move_to(cx - ext.width / 2 - ext.x_bearing, cy - ext.height / 2 - ext.y_bearing - rad * 0.16)
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(rad * 0.34)
cr.set_font_size(vis_h * 0.20)
ext2 = cr.text_extents(date)
cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, cy + rad * 0.5)
cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, vis_cy + vis_h * 0.30)
cr.set_source_rgba(*_ACCENT, 0.95)
cr.show_text(date)
cr.restore()
def _draw_orbit_glyphs(self, cr, row: str) -> None:
"""Sprinkle small faint nerd-font glyphs along a ring, on the integer slots
between the (half-slot) icons the orbit-menu satellites' glyph language,
so the empty stretches of orbit still read as 'in orbit'."""
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
rx, ry = self._ring_rx(row), self._ring_ry(row)
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)
cr.set_font_size(12)
n = int(rx / self.ITEM_SPACING) + 1
gi = 0
for k in range(-n, n + 1):
x = cx + k * self.ITEM_SPACING # integer slots = the gaps between icons
dx = x - cx
if abs(dx) >= rx or x < 8 or x > self._width - 8:
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
y = self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
glyph = self.ORBIT_GLYPHS[gi % len(self.ORBIT_GLYPHS)]
gi += 1
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.5)
cr.set_source_rgba(*_VIOLET, 0.7 * border_w)
cr.show_text(glyph)
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
# -- 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:
@ -537,6 +606,10 @@ 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:
@ -553,7 +626,6 @@ class HorizonDock(Gtk.Window):
else:
self._apply_width(self._monitor_width())
self._rebuild_all()
self._reveal_target = 1.0
self.set_visible(True)
self.present()
self._ensure_tick()
@ -562,18 +634,27 @@ class HorizonDock(Gtk.Window):
self._clock_timer_id = GLib.timeout_add_seconds(15, self._refresh_clock)
def _refresh_clock(self) -> bool:
if self.get_visible() and self._reveal_target == 1.0:
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

@ -41,15 +41,24 @@ class HologramOverlay:
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 = 0.6 # 'materialise out of static' opening animation
INTRO_STATIC = 520 # static specks at the very start of the intro
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
INTRO_STATIC = 1200 # static specks at the very start of the intro
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
def __init__(self, enabled: bool = True, clip_func=None) -> 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()
@ -69,12 +78,42 @@ class HologramOverlay:
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:
@ -93,6 +132,8 @@ class HologramOverlay:
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()
@ -126,20 +167,42 @@ class HologramOverlay:
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
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
cr.paint()
# 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)]
for _ in range(int(self.INTRO_STATIC * strength)):
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.8) * (0.4 + 0.6 * strength))
sz = random.uniform(1.0, 2.4)
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 - 1.5, width, 3.0)
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:

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 ---

View File

@ -117,8 +117,14 @@ hl.window_rule({
-- 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 })
hl.layer_rule({ name = "cosmoshell-blur-" .. ns, match = { namespace = ns },
blur = true, ignore_alpha = 0.1, no_anim = true })
end
-- smart gaps

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,7 +136,9 @@ 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.
@ -514,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:
@ -537,6 +545,77 @@ class OrbitMenu(Gtk.Window):
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
@ -641,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
@ -881,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()
@ -915,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:
@ -925,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

@ -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

@ -60,6 +60,7 @@ 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):
@ -117,7 +118,7 @@ class StationBar(Gtk.Window):
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
@ -441,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:
@ -474,6 +478,13 @@ class StationBar(Gtk.Window):
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

@ -40,15 +40,24 @@ class HologramOverlay:
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 = 0.6 # 'materialise out of static' opening animation
INTRO_STATIC = 520 # static specks at the very start of the intro
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
INTRO_STATIC = 1200 # static specks at the very start of the intro
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
def __init__(self, enabled: bool = True, clip_func=None) -> 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()
@ -68,12 +77,42 @@ class HologramOverlay:
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:
@ -92,6 +131,8 @@ class HologramOverlay:
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()
@ -125,20 +166,42 @@ class HologramOverlay:
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
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
cr.paint()
# 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)]
for _ in range(int(self.INTRO_STATIC * strength)):
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.8) * (0.4 + 0.6 * strength))
sz = random.uniform(1.0, 2.4)
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 - 1.5, width, 3.0)
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: