202 lines
8.4 KiB
Python
202 lines
8.4 KiB
Python
"""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 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 = 0.6 # 'materialise out of static' opening animation
|
|
INTRO_STATIC = 520 # static specks at the very start of the intro
|
|
|
|
def __init__(self, enabled: bool = True, clip_func=None) -> None:
|
|
self.enabled = enabled
|
|
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
|
|
self._sat_time = 0.0
|
|
self._particles: list[dict] = []
|
|
self._intro_t: float | None = None # >=0 while the materialise intro plays
|
|
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._intro_t = None
|
|
self.widget.queue_draw()
|
|
|
|
def start_intro(self) -> None:
|
|
"""Kick off the 'hologram materialising out of static' opening effect."""
|
|
if self.enabled:
|
|
self._intro_t = 0.0
|
|
|
|
# -- 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)
|
|
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
|
|
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
|
|
cr.paint()
|
|
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
|
for _ in range(int(self.INTRO_STATIC * strength)):
|
|
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.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.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()
|