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
parent
f48c70273a
commit
8dfd505ce4
|
|
@ -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)
|
||||||
|
|
@ -42,15 +42,24 @@ class HologramOverlay:
|
||||||
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
||||||
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
||||||
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
||||||
INTRO_DURATION = 0.6 # 'materialise out of static' opening animation
|
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
|
||||||
INTRO_STATIC = 520 # static specks at the very start of the intro
|
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.enabled = enabled
|
||||||
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
|
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._sat_time = 0.0
|
||||||
self._particles: list[dict] = []
|
self._particles: list[dict] = []
|
||||||
self._intro_t: float | None = None # >=0 while the materialise intro plays
|
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._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
|
||||||
|
|
||||||
self.widget = Gtk.DrawingArea()
|
self.widget = Gtk.DrawingArea()
|
||||||
|
|
@ -70,12 +79,42 @@ class HologramOverlay:
|
||||||
self._intro_t += dt
|
self._intro_t += dt
|
||||||
if self._intro_t >= self.INTRO_DURATION:
|
if self._intro_t >= self.INTRO_DURATION:
|
||||||
self._intro_t = None
|
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()
|
self.widget.queue_draw()
|
||||||
|
|
||||||
def start_intro(self) -> None:
|
def start_intro(self) -> None:
|
||||||
"""Kick off the 'hologram materialising out of static' opening effect."""
|
"""Kick off the 'hologram materialising out of static' opening effect."""
|
||||||
if self.enabled:
|
if self.enabled:
|
||||||
|
self._outro_t = None # cancel any in-flight closing dissolve
|
||||||
|
self._outro_done = None
|
||||||
self._intro_t = 0.0
|
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 --------------------------------------------------------------
|
# -- drawing --------------------------------------------------------------
|
||||||
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
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))
|
cr.mask(self._edge_fade_mask(width, height))
|
||||||
if self._intro_t is not None:
|
if self._intro_t is not None:
|
||||||
self._draw_intro(cr, width, height)
|
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:
|
if self._clip_func is not None:
|
||||||
cr.restore()
|
cr.restore()
|
||||||
|
|
||||||
|
|
@ -127,20 +168,42 @@ class HologramOverlay:
|
||||||
def _draw_intro(self, cr, width: float, height: float) -> None:
|
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))
|
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
|
||||||
strength = 1.0 - p
|
strength = 1.0 - p
|
||||||
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
|
# No solid veil block: the content itself fades in (see tick's fade_widget
|
||||||
cr.paint()
|
# 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)]
|
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)
|
x = random.uniform(0, width)
|
||||||
y = random.uniform(0, height)
|
y = random.uniform(0, height)
|
||||||
col = random.choice(colors)
|
col = random.choice(colors)
|
||||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.8) * (0.4 + 0.6 * strength))
|
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
|
||||||
sz = random.uniform(1.0, 2.4)
|
sz = random.uniform(1.0, 2.8)
|
||||||
cr.rectangle(x, y, sz, sz)
|
cr.rectangle(x, y, sz, sz)
|
||||||
cr.fill()
|
cr.fill()
|
||||||
band = p * height # a bright scan wiping down as it resolves
|
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.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()
|
cr.fill()
|
||||||
|
|
||||||
def _draw_content(self, cr, width: float, height: float) -> None:
|
def _draw_content(self, cr, width: float, height: float) -> None:
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ class _BluetoothView(Gtk.Box):
|
||||||
self._list.remove(child)
|
self._list.remove(child)
|
||||||
child = self._list.get_first_child()
|
child = self._list.get_first_child()
|
||||||
if not self.bt:
|
if not self.bt:
|
||||||
self._list.append(Gtk.Label(label="No Bluetooth adapter"))
|
self._list.append(Gtk.Label(label="No System Radio hardware"))
|
||||||
return
|
return
|
||||||
|
|
||||||
devices = list(self.bt.get_devices())
|
devices = list(self.bt.get_devices())
|
||||||
|
|
@ -283,7 +283,7 @@ def build(ctx: ModuleContext) -> ModuleInstance:
|
||||||
|
|
||||||
SPEC = ModuleSpec(
|
SPEC = ModuleSpec(
|
||||||
id="bluetooth",
|
id="bluetooth",
|
||||||
title="Bluetooth",
|
title="System Radio",
|
||||||
icon="", # nf-fa-bluetooth
|
icon="", # nf-fa-bluetooth
|
||||||
build=build,
|
build=build,
|
||||||
default_enabled=True,
|
default_enabled=True,
|
||||||
|
|
|
||||||
|
|
@ -518,7 +518,7 @@ def build(ctx: ModuleContext) -> ModuleInstance:
|
||||||
|
|
||||||
SPEC = ModuleSpec(
|
SPEC = ModuleSpec(
|
||||||
id="network",
|
id="network",
|
||||||
title="Network",
|
title="Laser Antenna Uplink",
|
||||||
icon="", # nf-md-lan
|
icon="", # nf-md-lan
|
||||||
build=build,
|
build=build,
|
||||||
default_enabled=True,
|
default_enabled=True,
|
||||||
|
|
|
||||||
|
|
@ -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}'>-<</span>{bar}"
|
||||||
|
f"<span foreground='{_ACCENT}'>>-</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,
|
||||||
|
)
|
||||||
|
|
@ -10,7 +10,8 @@ from __future__ import annotations
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
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.ansi import AnsiRenderer
|
||||||
from lib.proc import run_text
|
from lib.proc import run_text
|
||||||
|
|
@ -28,6 +29,11 @@ class _WeatherView(Gtk.Box):
|
||||||
self.opts = opts
|
self.opts = opts
|
||||||
self._loc = ""
|
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.renderer = AnsiRenderer()
|
||||||
self.append(self.renderer.view)
|
self.append(self.renderer.view)
|
||||||
self._status = Gtk.Label(label="Loading weather…")
|
self._status = Gtk.Label(label="Loading weather…")
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ specs fill the 2x2 grid; extra specs are kept for future paginated layouts.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from modules import bluetooth, location, network, weather
|
from modules import bluetooth, network, sysmon, weather
|
||||||
from module_base import ModuleSpec
|
from module_base import ModuleSpec
|
||||||
|
|
||||||
ALL_SPECS: list[ModuleSpec] = [
|
ALL_SPECS: list[ModuleSpec] = [
|
||||||
location.SPEC,
|
sysmon.SPEC,
|
||||||
weather.SPEC,
|
weather.SPEC,
|
||||||
bluetooth.SPEC,
|
bluetooth.SPEC,
|
||||||
network.SPEC,
|
network.SPEC,
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,20 @@ drawingarea {
|
||||||
|
|
||||||
.quad-body { color: @text; }
|
.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 */
|
/* pill action buttons */
|
||||||
.quad-action,
|
.quad-action,
|
||||||
.enable-btn {
|
.enable-btn {
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ class MenuWindow(Gtk.ApplicationWindow):
|
||||||
overlay = Gtk.Overlay()
|
overlay = Gtk.Overlay()
|
||||||
overlay.set_child(self.root)
|
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)
|
overlay.add_overlay(self._hologram.widget)
|
||||||
|
|
||||||
close = Gtk.Button(label="✕")
|
close = Gtk.Button(label="✕")
|
||||||
|
|
@ -208,6 +208,14 @@ class MenuWindow(Gtk.ApplicationWindow):
|
||||||
self.taskbar.collapse_panel()
|
self.taskbar.collapse_panel()
|
||||||
self.grid.hide_takeover()
|
self.grid.hide_takeover()
|
||||||
self.appdrawer.set_expanded(False)
|
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)
|
self.set_visible(False)
|
||||||
if self._tick_id is not None:
|
if self._tick_id is not None:
|
||||||
self.remove_tick_callback(self._tick_id)
|
self.remove_tick_callback(self._tick_id)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import random
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
@ -33,10 +34,8 @@ import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Gdk", "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")
|
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
|
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
|
||||||
|
|
||||||
import apps as apps_source
|
import apps as apps_source
|
||||||
|
|
@ -77,13 +76,14 @@ class HorizonDock(Gtk.Window):
|
||||||
GLYPH_FONT = "Agave Nerd Font Mono"
|
GLYPH_FONT = "Agave Nerd Font Mono"
|
||||||
# decorative glyphs sprinkled along the orbits between icons (nerd-font)
|
# decorative glyphs sprinkled along the orbits between icons (nerd-font)
|
||||||
ORBIT_GLYPHS = ["\uf444", "\uf10c", "\U000f0471", "\U000f1383", "\uf005"]
|
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_SIZE = 40
|
||||||
TRAY_MARGIN = 30.0
|
TRAY_MARGIN = 30.0
|
||||||
SCROLL_SENSITIVITY = 0.9
|
SCROLL_SENSITIVITY = 0.9
|
||||||
|
|
||||||
REVEAL_DURATION = 0.28
|
|
||||||
|
|
||||||
def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True,
|
def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True,
|
||||||
hologram_enabled: bool = True):
|
hologram_enabled: bool = True):
|
||||||
super().__init__()
|
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._widgets: dict[str, list[Gtk.Widget]] = {row: [] for row in self.ROW_ORDER}
|
||||||
self._hovered_row: Optional[str] = None
|
self._hovered_row: Optional[str] = None
|
||||||
|
|
||||||
self._reveal_progress = 0.0
|
|
||||||
self._reveal_target = 0.0
|
|
||||||
self._sat_time = 0.0
|
self._sat_time = 0.0
|
||||||
self._last_tick: Optional[float] = None
|
self._last_tick: Optional[float] = None
|
||||||
self._tick_id: Optional[int] = 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._init_layer_shell()
|
||||||
|
|
||||||
self._bg = Gtk.DrawingArea()
|
self._bg = Gtk.DrawingArea()
|
||||||
|
|
@ -139,7 +144,8 @@ class HorizonDock(Gtk.Window):
|
||||||
clip.add_overlay(self._viewport)
|
clip.add_overlay(self._viewport)
|
||||||
clip.set_measure_overlay(self._viewport, False)
|
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 = Gtk.Overlay()
|
||||||
overlay.set_child(clip)
|
overlay.set_child(clip)
|
||||||
overlay.add_overlay(self._hologram.widget)
|
overlay.add_overlay(self._hologram.widget)
|
||||||
|
|
@ -348,6 +354,7 @@ class HorizonDock(Gtk.Window):
|
||||||
return best
|
return best
|
||||||
|
|
||||||
def _on_motion(self, _ctrl, x: float, y: float) -> None:
|
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)
|
row = self._row_at(x, y)
|
||||||
if row != self._hovered_row:
|
if row != self._hovered_row:
|
||||||
self._hovered_row = row
|
self._hovered_row = row
|
||||||
|
|
@ -372,7 +379,7 @@ class HorizonDock(Gtk.Window):
|
||||||
self._draw_node(cr)
|
self._draw_node(cr)
|
||||||
for row in self.ROW_ORDER:
|
for row in self.ROW_ORDER:
|
||||||
self._draw_ring(cr, row, hovered=(row == self._hovered_row))
|
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)
|
self._draw_center_sphere(cr)
|
||||||
|
|
||||||
def _front_arc_path(self, cr, rx: float, ry: float, close_on_horizon: bool) -> None:
|
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)
|
self._ring_ry("apps") + pad, close_on_horizon=True)
|
||||||
|
|
||||||
def _draw_center_sphere(self, cr) -> None:
|
def _draw_center_sphere(self, cr) -> None:
|
||||||
"""The 'planet': a glowing sphere sitting on the surface below the orbits,
|
"""The 'planet': a big holographic world rising from the horizon. Its top
|
||||||
displaying the date and time. Sits in the clear central column so the icon
|
fills the clear central band (icon buttons float in front of it), while its
|
||||||
rows don't cover the readout."""
|
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
|
cx = self._width / 2
|
||||||
base = self._base_y()
|
base = self._base_y()
|
||||||
# Sit in the clear band BELOW the lowest icon arch (windows) so the icon
|
# Clear band below the lowest icon arch (windows); the readout lives here.
|
||||||
# rows never cover the readout.
|
|
||||||
band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2
|
band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2
|
||||||
cy = (band_top + base) / 2
|
bottom_edge = float(self.DOCK_HEIGHT) # dock surface bottom = screen bottom
|
||||||
rad = max(26.0, min((base - band_top) / 2 - 2, 40.0))
|
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()
|
cr.save()
|
||||||
grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.1, cx, cy, rad)
|
# gentle breathing pulse so the planet reads as a live, radiant body
|
||||||
grad.add_color_stop_rgba(0.0, *_VIOLET, 0.9)
|
pulse = 0.82 + 0.18 * math.sin(self._sat_time * 2.2)
|
||||||
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.4)
|
# 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.arc(cx, cy, rad, 0, 2 * math.pi)
|
||||||
cr.set_source(grad)
|
cr.set_source(grad)
|
||||||
cr.fill()
|
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.arc(cx, cy, rad, 0, 2 * math.pi)
|
||||||
cr.set_source_rgba(*_ACCENT, a)
|
cr.set_source_rgba(*_ACCENT, a)
|
||||||
cr.set_line_width(lw)
|
cr.set_line_width(lw)
|
||||||
cr.stroke()
|
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)
|
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
|
||||||
clock = time.strftime("%H:%M")
|
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)
|
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.set_source_rgba(0.98, 0.92, 0.99, 0.98)
|
||||||
cr.show_text(clock)
|
cr.show_text(clock)
|
||||||
date = time.strftime("%a %d %b")
|
date = time.strftime("%a %d %b")
|
||||||
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
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)
|
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.set_source_rgba(*_ACCENT, 0.95)
|
||||||
cr.show_text(date)
|
cr.show_text(date)
|
||||||
cr.restore()
|
cr.restore()
|
||||||
|
|
||||||
def _draw_orbit_glyphs(self, cr, row: str) -> None:
|
def _make_satellites(self) -> list[dict]:
|
||||||
"""Sprinkle small faint nerd-font glyphs along a ring, on the integer slots
|
"""A handful of ornamental glyphs, split across the two satellite rings that
|
||||||
between the (half-slot) icons — the orbit-menu satellites' glyph language,
|
frame the windows orbit (SAT_RING_FACTORS). Each ring gets a random 3–7 of
|
||||||
so the empty stretches of orbit still read as 'in orbit'."""
|
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
|
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()
|
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.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
||||||
cr.set_font_size(12)
|
for s in self._satellites:
|
||||||
n = int(rx / self.ITEM_SPACING) + 1
|
rx = win_rx * s["fac"]
|
||||||
gi = 0
|
ry = rx * self.PERSPECTIVE_SQUASH
|
||||||
for k in range(-n, n + 1):
|
bx = cx + rx * math.cos(s["angle"])
|
||||||
x = cx + k * self.ITEM_SPACING # integer slots = the gaps between icons
|
by = base - ry * math.sin(s["angle"])
|
||||||
dx = x - cx
|
wob = s["wob_amp"] * math.sin(self._sat_time * s["wob_speed"] + s["phase"])
|
||||||
if abs(dx) >= rx or x < 8 or x > self._width - 8:
|
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
|
continue
|
||||||
y = self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
|
glyph = s["glyph"]
|
||||||
glyph = self.ORBIT_GLYPHS[gi % len(self.ORBIT_GLYPHS)]
|
cr.set_font_size(s["size"])
|
||||||
gi += 1
|
|
||||||
ext = cr.text_extents(glyph)
|
ext = cr.text_extents(glyph)
|
||||||
cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing)
|
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.show_text(glyph)
|
||||||
cr.restore()
|
cr.restore()
|
||||||
|
|
||||||
# -- reveal animation -----------------------------------------------------
|
# -- animation loop -------------------------------------------------------
|
||||||
def _reveal_transform(self, progress: float) -> Gsk.Transform:
|
# No slide/fade reveal: the dock simply appears and the hologram's
|
||||||
offset = (1.0 - progress) * self.DOCK_HEIGHT
|
# "materialise from static" intro (start_intro, below) is the whole opening
|
||||||
t = Gsk.Transform.new()
|
# effect — matching every other Cosmonaut Shell surface. The tick only drives
|
||||||
t = t.translate(Graphene.Point().init(0, offset))
|
# the hologram animation while the dock is shown.
|
||||||
return t
|
|
||||||
|
|
||||||
def _on_tick(self, _widget, frame_clock) -> bool:
|
def _on_tick(self, _widget, frame_clock) -> bool:
|
||||||
now = frame_clock.get_frame_time() / 1_000_000
|
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)
|
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
|
||||||
self._last_tick = now
|
self._last_tick = now
|
||||||
self._sat_time += dt
|
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)
|
self._hologram.tick(dt)
|
||||||
|
self._bg.queue_draw() # animate the planet's glow + the satellites
|
||||||
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
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _ensure_tick(self) -> None:
|
def _ensure_tick(self) -> None:
|
||||||
|
|
@ -537,6 +606,10 @@ class HorizonDock(Gtk.Window):
|
||||||
if self._tick_id is not None:
|
if self._tick_id is not None:
|
||||||
self.remove_tick_callback(self._tick_id)
|
self.remove_tick_callback(self._tick_id)
|
||||||
self._tick_id = None
|
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 ----------------------------------------------------
|
# -- external control ----------------------------------------------------
|
||||||
def _apply_width(self, width: int) -> None:
|
def _apply_width(self, width: int) -> None:
|
||||||
|
|
@ -553,7 +626,6 @@ class HorizonDock(Gtk.Window):
|
||||||
else:
|
else:
|
||||||
self._apply_width(self._monitor_width())
|
self._apply_width(self._monitor_width())
|
||||||
self._rebuild_all()
|
self._rebuild_all()
|
||||||
self._reveal_target = 1.0
|
|
||||||
self.set_visible(True)
|
self.set_visible(True)
|
||||||
self.present()
|
self.present()
|
||||||
self._ensure_tick()
|
self._ensure_tick()
|
||||||
|
|
@ -562,18 +634,27 @@ class HorizonDock(Gtk.Window):
|
||||||
self._clock_timer_id = GLib.timeout_add_seconds(15, self._refresh_clock)
|
self._clock_timer_id = GLib.timeout_add_seconds(15, self._refresh_clock)
|
||||||
|
|
||||||
def _refresh_clock(self) -> bool:
|
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
|
self._bg.queue_draw() # repaint the planet's date/time
|
||||||
return True
|
return True
|
||||||
self._clock_timer_id = None
|
self._clock_timer_id = None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def hide_dock(self) -> None:
|
def hide_dock(self) -> None:
|
||||||
self._reveal_target = 0.0
|
# Dissolve into static first, then hide via _finish_hide.
|
||||||
self._ensure_tick()
|
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:
|
def toggle(self) -> None:
|
||||||
if self.get_visible() and self._reveal_target == 1.0:
|
if self.get_visible():
|
||||||
self.hide_dock()
|
self.hide_dock()
|
||||||
else:
|
else:
|
||||||
self.show_dock()
|
self.show_dock()
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,24 @@ class HologramOverlay:
|
||||||
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
||||||
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
||||||
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
||||||
INTRO_DURATION = 0.6 # 'materialise out of static' opening animation
|
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
|
||||||
INTRO_STATIC = 520 # static specks at the very start of the intro
|
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.enabled = enabled
|
||||||
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
|
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._sat_time = 0.0
|
||||||
self._particles: list[dict] = []
|
self._particles: list[dict] = []
|
||||||
self._intro_t: float | None = None # >=0 while the materialise intro plays
|
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._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
|
||||||
|
|
||||||
self.widget = Gtk.DrawingArea()
|
self.widget = Gtk.DrawingArea()
|
||||||
|
|
@ -69,12 +78,42 @@ class HologramOverlay:
|
||||||
self._intro_t += dt
|
self._intro_t += dt
|
||||||
if self._intro_t >= self.INTRO_DURATION:
|
if self._intro_t >= self.INTRO_DURATION:
|
||||||
self._intro_t = None
|
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()
|
self.widget.queue_draw()
|
||||||
|
|
||||||
def start_intro(self) -> None:
|
def start_intro(self) -> None:
|
||||||
"""Kick off the 'hologram materialising out of static' opening effect."""
|
"""Kick off the 'hologram materialising out of static' opening effect."""
|
||||||
if self.enabled:
|
if self.enabled:
|
||||||
|
self._outro_t = None # cancel any in-flight closing dissolve
|
||||||
|
self._outro_done = None
|
||||||
self._intro_t = 0.0
|
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 --------------------------------------------------------------
|
# -- drawing --------------------------------------------------------------
|
||||||
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
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))
|
cr.mask(self._edge_fade_mask(width, height))
|
||||||
if self._intro_t is not None:
|
if self._intro_t is not None:
|
||||||
self._draw_intro(cr, width, height)
|
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:
|
if self._clip_func is not None:
|
||||||
cr.restore()
|
cr.restore()
|
||||||
|
|
||||||
|
|
@ -126,20 +167,42 @@ class HologramOverlay:
|
||||||
def _draw_intro(self, cr, width: float, height: float) -> None:
|
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))
|
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
|
||||||
strength = 1.0 - p
|
strength = 1.0 - p
|
||||||
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
|
# No solid veil block: the content itself fades in (see tick's fade_widget
|
||||||
cr.paint()
|
# 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)]
|
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)
|
x = random.uniform(0, width)
|
||||||
y = random.uniform(0, height)
|
y = random.uniform(0, height)
|
||||||
col = random.choice(colors)
|
col = random.choice(colors)
|
||||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.8) * (0.4 + 0.6 * strength))
|
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
|
||||||
sz = random.uniform(1.0, 2.4)
|
sz = random.uniform(1.0, 2.8)
|
||||||
cr.rectangle(x, y, sz, sz)
|
cr.rectangle(x, y, sz, sz)
|
||||||
cr.fill()
|
cr.fill()
|
||||||
band = p * height # a bright scan wiping down as it resolves
|
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.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()
|
cr.fill()
|
||||||
|
|
||||||
def _draw_content(self, cr, width: float, height: float) -> None:
|
def _draw_content(self, cr, width: float, height: float) -> None:
|
||||||
|
|
|
||||||
|
|
@ -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
|
-- astro-menu is astal-menu re-themed as a hologram info display — same
|
||||||
-- functionality (Location/Weather/Bluetooth/Network quads, taskbar, app
|
-- functionality (Location/Weather/Bluetooth/Network quads, taskbar, app
|
||||||
-- drawer), same binds it always had in hyprlua.
|
-- 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"))
|
hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/astro-menu.sh appdrawer"))
|
||||||
|
|
||||||
--------------------
|
--------------------
|
||||||
---- HORIZON-DOCK --
|
---- HORIZON-DOCK --
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
-- Super+D went to astro-menu (its "og" bind); horizon-dock gets its own key.
|
-- horizon-dock gets the primary Super+D; astro-menu moved to Super+Shift+D.
|
||||||
-- (H is fully claimed by vim-style hjkl focus/move/resize across every
|
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-dock.sh toggle"), { release = true })
|
||||||
-- 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 })
|
|
||||||
|
|
||||||
--------------------
|
--------------------
|
||||||
---- STATION-BAR ---
|
---- STATION-BAR ---
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,14 @@ hl.window_rule({
|
||||||
-- fully-transparent gaps between modules sharp (no blur there), so the panels still
|
-- 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
|
-- 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.)
|
-- 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
|
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
|
end
|
||||||
|
|
||||||
-- smart gaps
|
-- smart gaps
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||||
import config # noqa: E402
|
import config # noqa: E402
|
||||||
import menu_tree # noqa: E402
|
import menu_tree # noqa: E402
|
||||||
import theme # 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
|
from paths import APP_ID # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,8 +106,56 @@ class OrbitMenuApp(Gtk.Application):
|
||||||
pass # resident instance: nothing to do on plain activate
|
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:
|
def main() -> int:
|
||||||
GLib.set_prgname("orbit-menu")
|
GLib.set_prgname("orbit-menu")
|
||||||
|
if "--dmenu" in sys.argv[1:]:
|
||||||
|
return _run_dmenu()
|
||||||
return OrbitMenuApp().run(sys.argv)
|
return OrbitMenuApp().run(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,9 @@ class OrbitMenu(Gtk.Window):
|
||||||
self._current_ring: Gtk.DrawingArea | None = None
|
self._current_ring: Gtk.DrawingArea | None = None
|
||||||
self._current_page: Gtk.Fixed | None = None
|
self._current_page: Gtk.Fixed | None = None
|
||||||
self._transition: dict | None = None # active zoom/fade nav transition, if any
|
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
|
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
|
# 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.
|
# 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
|
# faded out completely by here — capped so it fully resolves to 0 before the
|
||||||
# canvas edge (CANVAS_SIZE/2), not just before the corners
|
# canvas edge (CANVAS_SIZE/2), not just before the corners
|
||||||
_HOLO_MASK_OUTER = min(RADIUS + NODE_SIZE * 1.4, CANVAS_SIZE / 2 - 10)
|
_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:
|
def _draw_hologram_frame(self, _area, cr, width: float, height: float) -> None:
|
||||||
if self._hologram_enabled:
|
if self._hologram_enabled:
|
||||||
|
|
@ -537,6 +545,77 @@ class OrbitMenu(Gtk.Window):
|
||||||
cr.set_source(pattern)
|
cr.set_source(pattern)
|
||||||
cr.mask(mask)
|
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:
|
def _draw_hologram_content(self, cr, width: float, height: float) -> None:
|
||||||
r, g, b = self._HOLO_TINT
|
r, g, b = self._HOLO_TINT
|
||||||
|
|
||||||
|
|
@ -641,6 +720,23 @@ class OrbitMenu(Gtk.Window):
|
||||||
if self._intro is not None:
|
if self._intro is not None:
|
||||||
self._update_intro()
|
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:
|
if self._hologram_enabled:
|
||||||
self._hologram_area.queue_draw()
|
self._hologram_area.queue_draw()
|
||||||
return True # keep ticking every frame while the window is mapped
|
return True # keep ticking every frame while the window is mapped
|
||||||
|
|
@ -881,10 +977,23 @@ class OrbitMenu(Gtk.Window):
|
||||||
self._close()
|
self._close()
|
||||||
|
|
||||||
def _close(self) -> None:
|
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)
|
self.set_visible(False)
|
||||||
if self._tick_id is not None:
|
if self._tick_id is not None:
|
||||||
self.remove_tick_callback(self._tick_id)
|
self.remove_tick_callback(self._tick_id)
|
||||||
self._tick_id = None
|
self._tick_id = None
|
||||||
|
self._holo_outro_t = None
|
||||||
|
self._viewport.set_opacity(1.0) # reset for the next open
|
||||||
if self._on_close:
|
if self._on_close:
|
||||||
self._on_close()
|
self._on_close()
|
||||||
|
|
||||||
|
|
@ -915,6 +1024,8 @@ class OrbitMenu(Gtk.Window):
|
||||||
self._current_page = None
|
self._current_page = None
|
||||||
self._stack_origins.clear()
|
self._stack_origins.clear()
|
||||||
self._intro = None
|
self._intro = None
|
||||||
|
self._holo_intro_t = None
|
||||||
|
self._holo_outro_t = None
|
||||||
self._root = root
|
self._root = root
|
||||||
|
|
||||||
def open_at_root(self) -> None:
|
def open_at_root(self) -> None:
|
||||||
|
|
@ -925,7 +1036,15 @@ class OrbitMenu(Gtk.Window):
|
||||||
on in-menu navigation."""
|
on in-menu navigation."""
|
||||||
self._show_path((), animate=False)
|
self._show_path((), animate=False)
|
||||||
self._start_intro(self._current_page)
|
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.set_visible(True)
|
||||||
self.present()
|
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:
|
if self._tick_id is None:
|
||||||
self._tick_id = self.add_tick_callback(self._on_tick)
|
self._tick_id = self.add_tick_callback(self._on_tick)
|
||||||
|
|
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -60,6 +60,7 @@ ICON_STATION = chr(0xf1383)
|
||||||
ICON_SPACESHIP = chr(0xf135)
|
ICON_SPACESHIP = chr(0xf135)
|
||||||
ICON_WINDOW = chr(0xf10ac)
|
ICON_WINDOW = chr(0xf10ac)
|
||||||
ICON_VOLUME = chr(0xf04c3)
|
ICON_VOLUME = chr(0xf04c3)
|
||||||
|
ICON_FUEL = chr(0xf07c5) # md-fuel — the "battery" reads as a fuel tank
|
||||||
|
|
||||||
|
|
||||||
class StationBar(Gtk.Window):
|
class StationBar(Gtk.Window):
|
||||||
|
|
@ -117,7 +118,7 @@ class StationBar(Gtk.Window):
|
||||||
content.set_center_widget(self._build_center())
|
content.set_center_widget(self._build_center())
|
||||||
content.set_end_widget(self._right)
|
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
|
# 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
|
# line metrics, so the CenterBox's natural height would otherwise balloon
|
||||||
|
|
@ -441,8 +442,11 @@ class StationBar(Gtk.Window):
|
||||||
if state is None:
|
if state is None:
|
||||||
self._battery_widget.set_visible(False)
|
self._battery_widget.set_visible(False)
|
||||||
else:
|
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_visible(True)
|
||||||
self._battery_widget.set_label(f"{state.icon} {state.percent}%")
|
self._battery_widget.set_label(f"{ICON_FUEL} {state.percent}%")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _refresh_volume(self) -> bool:
|
def _refresh_volume(self) -> bool:
|
||||||
|
|
@ -474,6 +478,13 @@ class StationBar(Gtk.Window):
|
||||||
self._hologram.start_intro()
|
self._hologram.start_intro()
|
||||||
|
|
||||||
def hide_bar(self) -> None:
|
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)
|
LayerShell.set_exclusive_zone(self, 0)
|
||||||
self.set_visible(False)
|
self.set_visible(False)
|
||||||
self._stop_tick()
|
self._stop_tick()
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,24 @@ class HologramOverlay:
|
||||||
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
||||||
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
||||||
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
||||||
INTRO_DURATION = 0.6 # 'materialise out of static' opening animation
|
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
|
||||||
INTRO_STATIC = 520 # static specks at the very start of the intro
|
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.enabled = enabled
|
||||||
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
|
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._sat_time = 0.0
|
||||||
self._particles: list[dict] = []
|
self._particles: list[dict] = []
|
||||||
self._intro_t: float | None = None # >=0 while the materialise intro plays
|
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._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
|
||||||
|
|
||||||
self.widget = Gtk.DrawingArea()
|
self.widget = Gtk.DrawingArea()
|
||||||
|
|
@ -68,12 +77,42 @@ class HologramOverlay:
|
||||||
self._intro_t += dt
|
self._intro_t += dt
|
||||||
if self._intro_t >= self.INTRO_DURATION:
|
if self._intro_t >= self.INTRO_DURATION:
|
||||||
self._intro_t = None
|
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()
|
self.widget.queue_draw()
|
||||||
|
|
||||||
def start_intro(self) -> None:
|
def start_intro(self) -> None:
|
||||||
"""Kick off the 'hologram materialising out of static' opening effect."""
|
"""Kick off the 'hologram materialising out of static' opening effect."""
|
||||||
if self.enabled:
|
if self.enabled:
|
||||||
|
self._outro_t = None # cancel any in-flight closing dissolve
|
||||||
|
self._outro_done = None
|
||||||
self._intro_t = 0.0
|
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 --------------------------------------------------------------
|
# -- drawing --------------------------------------------------------------
|
||||||
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
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))
|
cr.mask(self._edge_fade_mask(width, height))
|
||||||
if self._intro_t is not None:
|
if self._intro_t is not None:
|
||||||
self._draw_intro(cr, width, height)
|
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:
|
if self._clip_func is not None:
|
||||||
cr.restore()
|
cr.restore()
|
||||||
|
|
||||||
|
|
@ -125,20 +166,42 @@ class HologramOverlay:
|
||||||
def _draw_intro(self, cr, width: float, height: float) -> None:
|
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))
|
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
|
||||||
strength = 1.0 - p
|
strength = 1.0 - p
|
||||||
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
|
# No solid veil block: the content itself fades in (see tick's fade_widget
|
||||||
cr.paint()
|
# 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)]
|
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)
|
x = random.uniform(0, width)
|
||||||
y = random.uniform(0, height)
|
y = random.uniform(0, height)
|
||||||
col = random.choice(colors)
|
col = random.choice(colors)
|
||||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.8) * (0.4 + 0.6 * strength))
|
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
|
||||||
sz = random.uniform(1.0, 2.4)
|
sz = random.uniform(1.0, 2.8)
|
||||||
cr.rectangle(x, y, sz, sz)
|
cr.rectangle(x, y, sz, sz)
|
||||||
cr.fill()
|
cr.fill()
|
||||||
band = p * height # a bright scan wiping down as it resolves
|
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.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()
|
cr.fill()
|
||||||
|
|
||||||
def _draw_content(self, cr, width: float, height: float) -> None:
|
def _draw_content(self, cr, width: float, height: float) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue