"""Holographic scanline/sweep/noise overlay — the same visual treatment as orbit-menu's hologram effect, adapted for astro-menu's rectangular panel instead of orbit-menu's circular canvas: no radial vignette mask here, since the panel's own Cairo-drawn module borders (lib/border.py) already bound it — covering the full rectangle reads as one continuous "HUD screen" rather than tinting past its own edges. Owns its own animation clock (advanced by window.py's tick callback via .tick(dt)) and a persistent particle list for the noise specs, exactly like orbit-menu's _update_hologram_particles: each spec keeps its position/color for a real randomized lifetime (fade in, hold, fade out) instead of every spec teleporting to a new position every frame. """ from __future__ import annotations import math import random import cairo import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk # noqa: E402 # Same CyberQueer violet/magenta/red combo as orbit-menu's hologram overlay. _VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) _MAGENTA = (0.92, 0.0, 0.65) _ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) class HologramOverlay: SCANLINE_GAP = 4.0 SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass SWEEP_HALF_HEIGHT = 90.0 NOISE_COUNT = 95 # specs alive at once (each with its own lifetime) NOISE_COLORS = [_MAGENTA, _ACCENT] NOISE_LIFETIME = (0.5, 1.4) NOISE_FADE_IN = 0.2 NOISE_FADE_OUT = 0.35 NOISE_ALPHA_RANGE = (0.10, 0.34) def __init__(self, enabled: bool = True) -> None: self.enabled = enabled self._sat_time = 0.0 self._particles: list[dict] = [] self.widget = Gtk.DrawingArea() self.widget.set_can_target(False) # never steals clicks from content underneath self.widget.add_css_class("astro-hologram") self.widget.set_hexpand(True) self.widget.set_vexpand(True) self.widget.set_halign(Gtk.Align.FILL) self.widget.set_valign(Gtk.Align.FILL) self.widget.set_draw_func(self._draw_frame) def tick(self, dt: float) -> None: if not self.enabled: return self._sat_time += dt self.widget.queue_draw() # -- drawing -------------------------------------------------------------- def _draw_frame(self, _area, cr, width: float, height: float) -> None: if not self.enabled or width <= 0 or height <= 0: return 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()