From 8dfd505ce4328fcae5f88c358ff17db985572f4e Mon Sep 17 00:00:00 2001 From: The_miro Date: Sat, 18 Jul 2026 17:17:48 +0200 Subject: [PATCH] feat(hyprdrive): materialize animations, dmenu mode, ship-systems astro rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../hyprdrive/astro-menu/backend/sysmon.sh | 101 ++++++++ .../hyprdrive/astro-menu/lib/hologram.py | 81 ++++++- .../hyprdrive/astro-menu/modules/bluetooth.py | 4 +- .../hyprdrive/astro-menu/modules/network.py | 2 +- .../hyprdrive/astro-menu/modules/sysmon.py | 195 ++++++++++++++++ .../hyprdrive/astro-menu/modules/weather.py | 8 +- desktopenvs/hyprdrive/astro-menu/registry.py | 4 +- .../hyprdrive/astro-menu/style/style.css | 14 ++ desktopenvs/hyprdrive/astro-menu/window.py | 10 +- desktopenvs/hyprdrive/horizon-dock/dock.py | 217 ++++++++++++------ .../hyprdrive/horizon-dock/lib/hologram.py | 81 ++++++- desktopenvs/hyprdrive/hypr/usr/binds.lua | 9 +- .../hyprdrive/hypr/usr/windowrules.lua | 8 +- desktopenvs/hyprdrive/orbit-menu/main.py | 50 +++- .../hyprdrive/orbit-menu/orbit_menu.py | 121 +++++++++- desktopenvs/hyprdrive/scripts/orbit-dmenu.sh | 18 ++ desktopenvs/hyprdrive/station-bar/bar.py | 15 +- .../hyprdrive/station-bar/lib/hologram.py | 81 ++++++- 18 files changed, 907 insertions(+), 112 deletions(-) create mode 100755 desktopenvs/hyprdrive/astro-menu/backend/sysmon.sh create mode 100644 desktopenvs/hyprdrive/astro-menu/modules/sysmon.py create mode 100755 desktopenvs/hyprdrive/scripts/orbit-dmenu.sh diff --git a/desktopenvs/hyprdrive/astro-menu/backend/sysmon.sh b/desktopenvs/hyprdrive/astro-menu/backend/sysmon.sh new file mode 100755 index 0000000..8bfdabd --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/backend/sysmon.sh @@ -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 NAME USAGE_PCT TEMP_C 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) diff --git a/desktopenvs/hyprdrive/astro-menu/lib/hologram.py b/desktopenvs/hyprdrive/astro-menu/lib/hologram.py index 72a8627..6c65a22 100644 --- a/desktopenvs/hyprdrive/astro-menu/lib/hologram.py +++ b/desktopenvs/hyprdrive/astro-menu/lib/hologram.py @@ -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: diff --git a/desktopenvs/hyprdrive/astro-menu/modules/bluetooth.py b/desktopenvs/hyprdrive/astro-menu/modules/bluetooth.py index dfb79b5..38d816f 100644 --- a/desktopenvs/hyprdrive/astro-menu/modules/bluetooth.py +++ b/desktopenvs/hyprdrive/astro-menu/modules/bluetooth.py @@ -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, diff --git a/desktopenvs/hyprdrive/astro-menu/modules/network.py b/desktopenvs/hyprdrive/astro-menu/modules/network.py index 16b631c..10eb53a 100644 --- a/desktopenvs/hyprdrive/astro-menu/modules/network.py +++ b/desktopenvs/hyprdrive/astro-menu/modules/network.py @@ -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, diff --git a/desktopenvs/hyprdrive/astro-menu/modules/sysmon.py b/desktopenvs/hyprdrive/astro-menu/modules/sysmon.py new file mode 100644 index 0000000..573d8e0 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/modules/sysmon.py @@ -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"{markup}") + 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"{'█' * filled}" + f"{'░' * (self._bar_w - filled)}") + meter = (f"-<{bar}" + f">-") + 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"-( " + f"{temp_txt:>5} " + f")-") + + icon = _ICONS.get(r["cat"], "") + self._grid.attach(self._span(f"{icon}"), 0, i, 1, 1) + self._grid.attach(self._span(f"{_esc(r['name'])}"), 1, i, 1, 1) + self._grid.attach(self._span(meter), 2, i, 1, 1) + self._grid.attach(self._span(f"{usage:>3}%", 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"{_esc(r['detail'])}"), 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, +) diff --git a/desktopenvs/hyprdrive/astro-menu/modules/weather.py b/desktopenvs/hyprdrive/astro-menu/modules/weather.py index 2ebe8bd..9862a7d 100644 --- a/desktopenvs/hyprdrive/astro-menu/modules/weather.py +++ b/desktopenvs/hyprdrive/astro-menu/modules/weather.py @@ -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…") diff --git a/desktopenvs/hyprdrive/astro-menu/registry.py b/desktopenvs/hyprdrive/astro-menu/registry.py index 77e5a4e..51ac023 100644 --- a/desktopenvs/hyprdrive/astro-menu/registry.py +++ b/desktopenvs/hyprdrive/astro-menu/registry.py @@ -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, diff --git a/desktopenvs/hyprdrive/astro-menu/style/style.css b/desktopenvs/hyprdrive/astro-menu/style/style.css index 80c0e82..aea11e4 100644 --- a/desktopenvs/hyprdrive/astro-menu/style/style.css +++ b/desktopenvs/hyprdrive/astro-menu/style/style.css @@ -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 { diff --git a/desktopenvs/hyprdrive/astro-menu/window.py b/desktopenvs/hyprdrive/astro-menu/window.py index 51c76ad..4f4ca3b 100644 --- a/desktopenvs/hyprdrive/astro-menu/window.py +++ b/desktopenvs/hyprdrive/astro-menu/window.py @@ -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) diff --git a/desktopenvs/hyprdrive/horizon-dock/dock.py b/desktopenvs/hyprdrive/horizon-dock/dock.py index 8a4d34c..54fbd02 100644 --- a/desktopenvs/hyprdrive/horizon-dock/dock.py +++ b/desktopenvs/hyprdrive/horizon-dock/dock.py @@ -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 3–7 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() diff --git a/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py b/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py index b84ee05..d0d613b 100644 --- a/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py +++ b/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py @@ -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: diff --git a/desktopenvs/hyprdrive/hypr/usr/binds.lua b/desktopenvs/hyprdrive/hypr/usr/binds.lua index abc1839..16891d9 100644 --- a/desktopenvs/hyprdrive/hypr/usr/binds.lua +++ b/desktopenvs/hyprdrive/hypr/usr/binds.lua @@ -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 --- diff --git a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua index eda3174..3fef059 100644 --- a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua +++ b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua @@ -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 diff --git a/desktopenvs/hyprdrive/orbit-menu/main.py b/desktopenvs/hyprdrive/orbit-menu/main.py index d12ce0d..83c1a33 100644 --- a/desktopenvs/hyprdrive/orbit-menu/main.py +++ b/desktopenvs/hyprdrive/orbit-menu/main.py @@ -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) diff --git a/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py b/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py index 53638f9..3cdfb84 100644 --- a/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py +++ b/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py @@ -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) diff --git a/desktopenvs/hyprdrive/scripts/orbit-dmenu.sh b/desktopenvs/hyprdrive/scripts/orbit-dmenu.sh new file mode 100755 index 0000000..8edd6af --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/orbit-dmenu.sh @@ -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 "$@" diff --git a/desktopenvs/hyprdrive/station-bar/bar.py b/desktopenvs/hyprdrive/station-bar/bar.py index 30f176e..958c600 100644 --- a/desktopenvs/hyprdrive/station-bar/bar.py +++ b/desktopenvs/hyprdrive/station-bar/bar.py @@ -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() diff --git a/desktopenvs/hyprdrive/station-bar/lib/hologram.py b/desktopenvs/hyprdrive/station-bar/lib/hologram.py index c044d16..19a2921 100644 --- a/desktopenvs/hyprdrive/station-bar/lib/hologram.py +++ b/desktopenvs/hyprdrive/station-bar/lib/hologram.py @@ -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: