"""Holographic scanline/sweep/noise overlay — same treatment and tuning as astro-menu's/horizon-dock's lib/hologram.py (itself matching orbit-menu's), reused here so station-bar reads as one more orbit of the same Cosmonaut Shell suite. No radial vignette mask: covering the full bar rectangle reads as one continuous "HUD screen" strip. Unlike the other three components, station-bar has no reveal/show-hide animation loop to piggyback a tick callback on — it's a persistent bar, not a popup — so bar.py starts/stops its own frame-clock tick callback directly in show_bar()/hide_bar() and feeds it into .tick(dt) here. """ from __future__ import annotations import math import random import cairo import gi gi.require_version("Gtk", "4.0") from gi.repository import GLib, Gtk # noqa: E402 # Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram. _VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) _MAGENTA = (0.92, 0.0, 0.65) _ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) class HologramOverlay: SCANLINE_GAP = 4.0 SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass SWEEP_HALF_HEIGHT = 24.0 # much shorter than the popups' — the bar itself is only ~30px tall NOISE_COUNT = 40 # thinner strip than a popup panel, so fewer specs at once NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks NOISE_LIFETIME = (0.5, 1.4) NOISE_FADE_IN = 0.2 NOISE_FADE_OUT = 0.35 NOISE_ALPHA_RANGE = (0.10, 0.34) EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field EDGE_FADE_Y = 40.0 # smooth vertical fade-out INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in INTRO_STATIC = 1200 # static specks at the very start of the intro OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, intro_duration: float | None = None) -> None: self.enabled = enabled self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape # widget whose opacity is ramped 0->1 during the intro so the UI genuinely # fades in (a gradual reveal), rather than a solid haze block popping on self._fade_widget = fade_widget if intro_duration is not None: self.INTRO_DURATION = intro_duration # per-instance override of the class default self._sat_time = 0.0 self._particles: list[dict] = [] self._intro_t: float | None = None # >=0 while the materialise intro plays self._outro_t: float | None = None # >=0 while the closing dissolve plays self._outro_done = None # callback fired when the dissolve finishes # Wall-clock safety net for the intro: the frame-clock tick that normally # advances the intro only fires while the compositor sends frame callbacks. # When the bar is (re)started into an otherwise-idle compositor those can # stall, freezing the intro at t=0 — i.e. the bar sits in permanent static # with content stuck at opacity 0. This timeout force-resolves the intro on # real time regardless, so content always appears. self._intro_deadline_id: int | None = None self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask self.widget = Gtk.DrawingArea() self.widget.set_can_target(False) # never steals clicks from content underneath self.widget.add_css_class("station-hologram") self.widget.set_hexpand(True) self.widget.set_vexpand(True) self.widget.set_halign(Gtk.Align.FILL) self.widget.set_valign(Gtk.Align.FILL) self.widget.set_draw_func(self._draw_frame) def tick(self, dt: float) -> None: if not self.enabled: return self._sat_time += dt if self._intro_t is not None: self._intro_t += dt if self._intro_t >= self.INTRO_DURATION: self._finish_intro() elif self._fade_widget is not None: p = self._intro_t / self.INTRO_DURATION self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1 if self._outro_t is not None: self._outro_t += dt po = min(1.0, self._outro_t / self.OUTRO_DURATION) if self._fade_widget is not None: self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0 if self._outro_t >= self.OUTRO_DURATION: done = self._outro_done self._outro_t = None self._outro_done = None if done is not None: done() self.widget.queue_draw() def _finish_intro(self) -> None: """End the intro: clear its state, cancel the safety timeout, and make the content fully opaque. Safe to call from either the tick or the timeout.""" self._intro_t = None if self._intro_deadline_id is not None: GLib.source_remove(self._intro_deadline_id) self._intro_deadline_id = None if self._fade_widget is not None: self._fade_widget.set_opacity(1.0) self.widget.queue_draw() # in case the frame clock has stalled def start_intro(self) -> None: """Kick off the 'hologram materialising out of static' opening effect.""" if self.enabled: self._outro_t = None # cancel any in-flight closing dissolve self._outro_done = None self._intro_t = 0.0 if self._fade_widget is not None: self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up # Wall-clock backstop: if frame callbacks stall, force the intro done a # little past its nominal length so content can never get stuck hidden. if self._intro_deadline_id is not None: GLib.source_remove(self._intro_deadline_id) self._intro_deadline_id = GLib.timeout_add( int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline) def _on_intro_deadline(self) -> bool: self._intro_deadline_id = None if self._intro_t is not None: self._finish_intro() return False # one-shot def start_outro(self, on_done) -> None: """Play a quick reverse of the intro (content dissolving back into static), then call on_done to actually hide. If disabled, hide immediately.""" if not self.enabled: on_done() return self._intro_t = None # cancel any in-flight opening intro if self._intro_deadline_id is not None: GLib.source_remove(self._intro_deadline_id) self._intro_deadline_id = None self._outro_t = 0.0 self._outro_done = on_done # -- drawing -------------------------------------------------------------- def _draw_frame(self, _area, cr, width: float, height: float) -> None: if not self.enabled or width <= 0 or height <= 0: return if self._clip_func is not None: cr.save() self._clip_func(cr, width, height) # clip the scanlines to the UI shape cr.clip() # Render the holo field into a group, then composite it back through a # soft edge-fade mask so the scanlines dissolve at the borders (reads far # more like a projected hologram than a hard-edged rectangle). cr.push_group() self._draw_content(cr, width, height) cr.pop_group_to_source() cr.mask(self._edge_fade_mask(width, height)) if self._intro_t is not None: self._draw_intro(cr, width, height) elif self._outro_t is not None: self._draw_outro(cr, width, height) if self._clip_func is not None: cr.restore() def _edge_fade_mask(self, width: float, height: float): key = (int(width), int(height)) if self._mask_cache is not None and self._mask_cache[0] == key: return self._mask_cache[1] w, h = max(1, key[0]), max(1, key[1]) surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) m = cairo.Context(surf) m.set_source_rgba(0, 0, 0, 1) m.paint() m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid fx = min(self.EDGE_FADE_X, w / 2) fy = min(self.EDGE_FADE_Y, h / 2) def band(x0, y0, x1, y1, rx, ry, rw, rh): gr = cairo.LinearGradient(x0, y0, x1, y1) gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) m.set_source(gr) m.rectangle(rx, ry, rw, rh) m.fill() band(0, 0, fx, 0, 0, 0, fx, h) # left band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right band(0, 0, 0, fy, 0, 0, w, fy) # top band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom pattern = cairo.SurfacePattern(surf) self._mask_cache = (key, pattern) return pattern def _draw_intro(self, cr, width: float, height: float) -> None: p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) strength = 1.0 - p # No solid veil block: the content itself fades in (see tick's fade_widget # ramp). Here we only lay churning static over it — dense at first, thinning # to nothing — so the UI resolves out of noise as it fades up. colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none for _ in range(count): x = random.uniform(0, width) y = random.uniform(0, height) col = random.choice(colors) cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) sz = random.uniform(1.0, 2.8) cr.rectangle(x, y, sz, sz) cr.fill() band = p * height # a bright scan wiping down as it resolves cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) cr.rectangle(0, band - 2.0, width, 4.0) cr.fill() def _draw_outro(self, cr, width: float, height: float) -> None: # Reverse of the intro: the content is already fading back out (tick's # fade_widget ramp 1->0); here the static thickens from nothing as it goes, # so the panel dissolves into noise just before it vanishes. po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] count = int(self.INTRO_STATIC * (po ** 0.5)) for _ in range(count): x = random.uniform(0, width) y = random.uniform(0, height) col = random.choice(colors) cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) sz = random.uniform(1.0, 2.8) cr.rectangle(x, y, sz, sz) cr.fill() band = (1.0 - po) * height # scan wiping back up as it dissolves cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) cr.rectangle(0, band - 2.0, width, 4.0) cr.fill() def _draw_content(self, cr, width: float, height: float) -> None: r, g, b = _VIOLET cr.save() cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) cr.set_line_width(1.0) y = 0.0 while y < height: cr.move_to(0, y) cr.line_to(width, y) y += self.SCANLINE_GAP cr.stroke() cr.restore() phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD sweep_y = phase * height hh = self.SWEEP_HALF_HEIGHT grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) grad.add_color_stop_rgba(0.0, r, g, b, 0.0) grad.add_color_stop_rgba(0.5, r, g, b, 0.07) grad.add_color_stop_rgba(1.0, r, g, b, 0.0) cr.set_source(grad) cr.rectangle(0, sweep_y - hh, width, hh * 2) cr.fill() flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) cr.set_source_rgba(r, g, b, max(0.0, flicker)) cr.paint() self._draw_noise(cr, width, height) def _draw_noise(self, cr, width: float, height: float) -> None: now = self._sat_time self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] while len(self._particles) < self.NOISE_COUNT: self._particles.append({ "x": random.uniform(0, width), "y": random.uniform(0, height), "w": random.uniform(1.0, 2.6), "h": random.uniform(1.0, 2.0), "color": random.choice(self.NOISE_COLORS), "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), "birth": now, "life": random.uniform(*self.NOISE_LIFETIME), }) for p in self._particles: t = (now - p["birth"]) / p["life"] if t < self.NOISE_FADE_IN: envelope = t / self.NOISE_FADE_IN elif t > 1.0 - self.NOISE_FADE_OUT: envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) else: envelope = 1.0 r, g, b = p["color"] cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) cr.rectangle(p["x"], p["y"], p["w"], p["h"]) cr.fill()